mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
547
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d36d4f86b3 | ||
|
|
757fbcfd83 | ||
|
|
e0fdf48395 | ||
|
|
63f44c4ac9 | ||
|
|
64ba59db10 | ||
|
|
8ee1cda924 | ||
|
|
ea91418f4e | ||
|
|
e6c46f8198 | ||
|
|
8eaab92d31 | ||
|
|
370b57fa67 | ||
|
|
8562a26315 | ||
|
|
0ac5c728b2 | ||
|
|
ff40b4fcff | ||
|
|
0ac30b5049 | ||
|
|
20c4c7791f | ||
|
|
d422a27715 | ||
|
|
9eb548a778 | ||
|
|
2c73442a87 | ||
|
|
4913a043da | ||
|
|
ba88ea7e4f | ||
|
|
1deea15e0f | ||
|
|
d920d076d4 | ||
|
|
95606cb62c | ||
|
|
083ee55455 | ||
|
|
c4edefdbe4 | ||
|
|
b7325218fb | ||
|
|
428df79d87 | ||
|
|
074acc3bed | ||
|
|
282d1570cd | ||
|
|
4cacfac04c | ||
|
|
a2532dde35 | ||
|
|
d91724e9ce | ||
|
|
51a5f8b7aa | ||
|
|
3bcc01f134 | ||
|
|
63da7edd7d | ||
|
|
fea8444adc | ||
|
|
5083a81681 | ||
|
|
545178b201 | ||
|
|
c75feb0c1a | ||
|
|
69c26101cd | ||
|
|
9b0cbf0a4c | ||
|
|
f44e200874 | ||
|
|
2f701c1605 | ||
|
|
d7f12834df | ||
|
|
3f919955fa | ||
|
|
1fc22d4cad | ||
|
|
647970f697 | ||
|
|
2a52837657 | ||
|
|
d1912f539a | ||
|
|
7a0443d39a | ||
|
|
dd02279cd3 | ||
|
|
781b8d642d | ||
|
|
ff1f9efe5f | ||
|
|
19e89934c1 | ||
|
|
137deac897 | ||
|
|
f4ff04897e | ||
|
|
b6b433dc88 | ||
|
|
2136f43b32 | ||
|
|
dc7477804f | ||
|
|
6719e35909 | ||
|
|
be22711041 | ||
|
|
40d43e88ba | ||
|
|
0222736349 | ||
|
|
7333ee1d11 | ||
|
|
4291df8942 | ||
|
|
40be3b1582 | ||
|
|
586a86b781 | ||
|
|
275f752642 | ||
|
|
e66e4dd26e | ||
|
|
0b18e1b366 | ||
|
|
fe8344ecb2 | ||
|
|
c87f488553 | ||
|
|
a2ac9c966b | ||
|
|
b02d52516c | ||
|
|
21f5516586 | ||
|
|
cdcb628887 | ||
|
|
c0a8e1a789 | ||
|
|
38912ac9bd | ||
|
|
338722cd89 | ||
|
|
f7ca60bab4 | ||
|
|
aa5ee1e4c0 | ||
|
|
fd29317a99 | ||
|
|
1f1af89023 | ||
|
|
a36980e9d2 | ||
|
|
c92eaca6ec | ||
|
|
387bb7a751 | ||
|
|
3ae1f09a4d | ||
|
|
0768c8e0a8 | ||
|
|
c6233b325e | ||
|
|
91dfebe809 | ||
|
|
edaf05a29d | ||
|
|
ea7954572c | ||
|
|
369016b5b5 | ||
|
|
83c3adb6fa | ||
|
|
159b98be80 | ||
|
|
b686686efb | ||
|
|
b2b79443af | ||
|
|
015e02424d | ||
|
|
4bfb26b1eb | ||
|
|
b8432cc907 | ||
|
|
8fe69f6101 | ||
|
|
127f32822f | ||
|
|
927feb9c79 | ||
|
|
b8b180440e | ||
|
|
af37e01b8c | ||
|
|
9b7c16263c | ||
|
|
5826f02dd3 | ||
|
|
584e9633d0 | ||
|
|
c0d6646f73 | ||
|
|
93d41dc6e6 | ||
|
|
8b286b233e | ||
|
|
0faaee8522 | ||
|
|
3a3243931a | ||
|
|
afed0f06b5 | ||
|
|
40f4e29833 | ||
|
|
b734f45a27 | ||
|
|
182d396972 | ||
|
|
8229d2176a | ||
|
|
860492221d | ||
|
|
697758e440 | ||
|
|
0eff48410b | ||
|
|
b90e3d6529 | ||
|
|
598c6bbe0d | ||
|
|
21d3487b28 | ||
|
|
63fed0efec | ||
|
|
d17e0f4e5d | ||
|
|
a916b550cc | ||
|
|
4511415781 | ||
|
|
d45d3d886b | ||
|
|
b37ca5ef76 | ||
|
|
c5e6a8e28b | ||
|
|
e1ac0bb6c3 | ||
|
|
a2efee3628 | ||
|
|
193e88689a | ||
|
|
dad85de1c2 | ||
|
|
a254acbc75 | ||
|
|
5b7af4414b | ||
|
|
894d85e1ea | ||
|
|
c6fcef7226 | ||
|
|
c763b2dd7d | ||
|
|
4ea1d7ae28 | ||
|
|
0c433b5ff2 | ||
|
|
e7f702d5fb | ||
|
|
a6043f0eac | ||
|
|
6ec872bb64 | ||
|
|
5384362e58 | ||
|
|
ef6fbdb692 | ||
|
|
54e3601426 | ||
|
|
851efb0927 | ||
|
|
b308fd378b | ||
|
|
96ee77236e | ||
|
|
59563fc468 | ||
|
|
3b82004dd2 | ||
|
|
8e2363b9bc | ||
|
|
4c909fa698 | ||
|
|
af678ae652 | ||
|
|
7db89bd063 | ||
|
|
c161d0a828 | ||
|
|
1a609182a4 | ||
|
|
0afb81a5b5 | ||
|
|
e8e696971f | ||
|
|
6a179cf0c1 | ||
|
|
e5658ff4db | ||
|
|
a9faf92494 | ||
|
|
208e6b6789 | ||
|
|
bbc1a08848 | ||
|
|
9c4ecda4f1 | ||
|
|
e51d223a0e | ||
|
|
05cdaab74e | ||
|
|
ec6392d013 | ||
|
|
b34d23deea | ||
|
|
e50ae0fd94 | ||
|
|
7324d1fc4f | ||
|
|
c1ad45ca51 | ||
|
|
92fd917760 | ||
|
|
0e8fb6b7cb | ||
|
|
730203c84a | ||
|
|
14b613d84a | ||
|
|
1ab9434e4f | ||
|
|
3064a9d39c | ||
|
|
aef7aeadb4 | ||
|
|
1146a94bbf | ||
|
|
dbfd5b5758 | ||
|
|
e24a08b909 | ||
|
|
4f2db51e55 | ||
|
|
a7d5c69e91 | ||
|
|
31d543c01b | ||
|
|
e38b372d1f | ||
|
|
189db57625 | ||
|
|
6736f95532 | ||
|
|
fc641cb304 | ||
|
|
27dc1c62af | ||
|
|
ef0a07e560 | ||
|
|
fb3396e389 | ||
|
|
aced2a99f8 | ||
|
|
cfb534615c | ||
|
|
409fd30969 | ||
|
|
cd284ae4f3 | ||
|
|
be3658b24f | ||
|
|
072377777f | ||
|
|
27b62b2372 | ||
|
|
627e652f56 | ||
|
|
17c2eb6346 | ||
|
|
01102e2d66 | ||
|
|
81dcf849fb | ||
|
|
1dfe1379cd | ||
|
|
27ec58fa40 | ||
|
|
7ac878d00e | ||
|
|
f90f40e624 | ||
|
|
dfc9ed7f61 | ||
|
|
2c2d7e0b55 | ||
|
|
ef364b69fd | ||
|
|
700d603e64 | ||
|
|
f2b3facd9f | ||
|
|
d1cd072502 | ||
|
|
60820acf9a | ||
|
|
7f08bec0b9 | ||
|
|
bbd4c698a5 | ||
|
|
4beccc3d61 | ||
|
|
60e057318f | ||
|
|
7408e960cc | ||
|
|
33b3beaea0 | ||
|
|
e65af2fd7b | ||
|
|
9cd756f44b | ||
|
|
5d63a9476c | ||
|
|
58c72ace32 | ||
|
|
cd12b04647 | ||
|
|
75cbda7edc | ||
|
|
6f6d8dc36d | ||
|
|
d0f819efb0 | ||
|
|
9671468412 | ||
|
|
380e96eae5 | ||
|
|
e4f9303de5 | ||
|
|
02ce011934 | ||
|
|
8e45763e4c | ||
|
|
0d92c5d738 | ||
|
|
c8a393f12b | ||
|
|
6bf4268e8e | ||
|
|
7114288d2f | ||
|
|
f02b645af7 | ||
|
|
eb3cd06278 | ||
|
|
7ed0b32489 | ||
|
|
2fe46cbf6f | ||
|
|
53ebbec58c | ||
|
|
0382c089e1 | ||
|
|
e9f5bcc6dc | ||
|
|
253ce57d84 | ||
|
|
9beb7d3108 | ||
|
|
525a78e83e | ||
|
|
0e94950a5c | ||
|
|
a2ea0f6464 | ||
|
|
42b174abb3 | ||
|
|
e2ad0f526e | ||
|
|
f8c7dd44db | ||
|
|
cfeee2ec89 | ||
|
|
be3e971064 | ||
|
|
35bb83f57c | ||
|
|
5279ac2836 | ||
|
|
793ba54bda | ||
|
|
a1ca1a3443 | ||
|
|
3c528c6ab5 | ||
|
|
6dd703afce | ||
|
|
10c399f47b | ||
|
|
a4ec7bab61 | ||
|
|
79215d8896 | ||
|
|
2012fc53c7 | ||
|
|
a47de3cb52 | ||
|
|
2be738b8c2 | ||
|
|
93d7f8e1ec | ||
|
|
924c9a0efa | ||
|
|
0d9da150ec | ||
|
|
d307a2ebc3 | ||
|
|
d97439f878 | ||
|
|
8285d7010b | ||
|
|
1f0ddd2bdb | ||
|
|
98db9e6fea | ||
|
|
8e53ec829f | ||
|
|
549c5534d5 | ||
|
|
8fae84148c | ||
|
|
6d14f1e3d6 | ||
|
|
aebe25ae70 | ||
|
|
df045bf144 | ||
|
|
32447885d9 | ||
|
|
195a23e380 | ||
|
|
459ac09ebb | ||
|
|
a1ea4cc546 | ||
|
|
25224cef7b | ||
|
|
452058bd42 | ||
|
|
50c25d8789 | ||
|
|
96a38593bf | ||
|
|
e3b9e71d8f | ||
|
|
01b9f745b6 | ||
|
|
e96384cc7f | ||
|
|
a5bf1765d4 | ||
|
|
c921e7d9a4 | ||
|
|
a1b7f3708a | ||
|
|
6a4a1ffd92 | ||
|
|
105107755b | ||
|
|
028e86b111 | ||
|
|
151c15b38f | ||
|
|
544613c007 | ||
|
|
b2886ed16b | ||
|
|
57e11822f2 | ||
|
|
ff05c33fc9 | ||
|
|
a2ff82187b | ||
|
|
997bba6e2c | ||
|
|
13979790e8 | ||
|
|
19cce8c82b | ||
|
|
02746754f3 | ||
|
|
f940bbc280 | ||
|
|
7e60b64766 | ||
|
|
18330fd1d1 | ||
|
|
ebfdb645a7 | ||
|
|
b89607dbb0 | ||
|
|
0ccea42785 | ||
|
|
2471013aba | ||
|
|
2343f80ed2 | ||
|
|
8d94252990 | ||
|
|
87b4089812 | ||
|
|
d7377fe301 | ||
|
|
518e4a12ac | ||
|
|
fbaa038b0d | ||
|
|
6c4e27cb38 | ||
|
|
5dc87ee178 | ||
|
|
a363ac5812 | ||
|
|
02bfea153c | ||
|
|
43534c34f1 | ||
|
|
a19bf708ac | ||
|
|
bbfa2ac6bf | ||
|
|
16f0304a5b | ||
|
|
e78d78b428 | ||
|
|
f6d846fbda | ||
|
|
b9702b043a | ||
|
|
89b3e27329 | ||
|
|
7e600d54e0 | ||
|
|
87aa2ea931 | ||
|
|
ac3993b789 | ||
|
|
4935066e53 | ||
|
|
315e596e08 | ||
|
|
5f874a3156 | ||
|
|
ae58d60f8f | ||
|
|
8856c7e5ab | ||
|
|
c47fcce17d | ||
|
|
53da304e39 | ||
|
|
2df408a571 | ||
|
|
a965a94f0d | ||
|
|
f9803f3642 | ||
|
|
ddfeb0a1a8 | ||
|
|
0d6fc5f7f7 | ||
|
|
2b381c87be | ||
|
|
193a14febb | ||
|
|
8ffdc671b9 | ||
|
|
837f4ddfaa | ||
|
|
f8bdd950bc | ||
|
|
4fb0f0c470 | ||
|
|
afc27e759b | ||
|
|
ce41379796 | ||
|
|
27b2c1954f | ||
|
|
b36642994c | ||
|
|
701ef1449b | ||
|
|
25291591fa | ||
|
|
0ad1713f25 | ||
|
|
d8efadc9fd | ||
|
|
c0f1504b4a | ||
|
|
b41728968e | ||
|
|
589abaaa90 | ||
|
|
cc8ba20839 | ||
|
|
589001cfb7 | ||
|
|
fcd122d4a1 | ||
|
|
9589f10ffb | ||
|
|
8d14f0f3c2 | ||
|
|
3f67ccf3b2 | ||
|
|
daa3490654 | ||
|
|
eebd9acfa2 | ||
|
|
8b84a13d9f | ||
|
|
249468b095 | ||
|
|
f4bb1775b3 | ||
|
|
f2154a853a | ||
|
|
82c339246c | ||
|
|
2169c2f95c | ||
|
|
d189692a93 | ||
|
|
fee778a775 | ||
|
|
d03f8873c5 | ||
|
|
5f464b1b8e | ||
|
|
68385b42b9 | ||
|
|
e60db5e08a | ||
|
|
71c64e3c6c | ||
|
|
d27fc24cae | ||
|
|
593da6f52d | ||
|
|
0527df7268 | ||
|
|
9ffb0d8642 | ||
|
|
d45b4dd65b | ||
|
|
63278d5d40 | ||
|
|
b6321eea6f | ||
|
|
39138096c0 | ||
|
|
5c355da8c1 | ||
|
|
e96a51bfe4 | ||
|
|
96628e8bd0 | ||
|
|
352de0131e | ||
|
|
df9284b9ab | ||
|
|
4f46a3728b | ||
|
|
5294f63544 | ||
|
|
7e843f6f2e | ||
|
|
2004f26c3e | ||
|
|
7c478215c8 | ||
|
|
01b818d598 | ||
|
|
0d453a42c4 | ||
|
|
5dcba28621 | ||
|
|
3513addb9c | ||
|
|
c9bc39d1a1 | ||
|
|
03acd48585 | ||
|
|
fd295e7529 | ||
|
|
f4e14afc9d | ||
|
|
93dce066fd | ||
|
|
49b88241db | ||
|
|
b67ff061c2 | ||
|
|
8a482dc928 | ||
|
|
3d433c8727 | ||
|
|
ea765ce074 | ||
|
|
8ee55963d8 | ||
|
|
b1a0a26d01 | ||
|
|
c718d042ac | ||
|
|
3490b0673d | ||
|
|
70607cb26d | ||
|
|
a56b868d10 | ||
|
|
c4e2391dd8 | ||
|
|
cfa47270be | ||
|
|
b7979e02be | ||
|
|
2c6390bf6f | ||
|
|
0f535d0b80 | ||
|
|
68ad8a6118 | ||
|
|
0144c9c6d3 | ||
|
|
4bcc619de4 | ||
|
|
cc5ad3be16 | ||
|
|
443202ba4e | ||
|
|
6c246bb8f4 | ||
|
|
90df595e43 | ||
|
|
ab3580982d | ||
|
|
bd81b1fbcf | ||
|
|
205d1591e8 | ||
|
|
30004e7716 | ||
|
|
75452055ae | ||
|
|
08d1bb5f81 | ||
|
|
6bc65a43de | ||
|
|
904af93a1d | ||
|
|
a8bd5e0f16 | ||
|
|
61839acaca | ||
|
|
d7b749d23f | ||
|
|
a1b338414f | ||
|
|
c4015b9b68 | ||
|
|
8bbead1261 | ||
|
|
c3a7a4758b | ||
|
|
9a3d96be76 | ||
|
|
69df81613b | ||
|
|
583c75b107 | ||
|
|
c85ab87262 | ||
|
|
90b05946d0 | ||
|
|
7c5b811c27 | ||
|
|
851e772026 | ||
|
|
820587b4f5 | ||
|
|
81a62b7ae2 | ||
|
|
79603979ef | ||
|
|
4e6c323559 | ||
|
|
1e0988d5f0 | ||
|
|
01d91f55a7 | ||
|
|
c7052d6150 | ||
|
|
0f073702f7 | ||
|
|
8a4bec65dc | ||
|
|
87430cc6fa | ||
|
|
b3ec94b6ef | ||
|
|
5967b8fe56 | ||
|
|
100394dce9 | ||
|
|
81e1f7797d | ||
|
|
1d81be94f4 | ||
|
|
ffb25f81c5 | ||
|
|
6d6d9cdf2f | ||
|
|
dbe1a7469b | ||
|
|
a3b4439723 | ||
|
|
8c0b9c5a47 | ||
|
|
c3dcb5c326 | ||
|
|
b6aa0fbfe5 | ||
|
|
9942d0825c | ||
|
|
1d11ae4c00 | ||
|
|
0d1fd79326 | ||
|
|
2433fbae47 | ||
|
|
9b142ebf3f | ||
|
|
25d6646ffe | ||
|
|
193e5e8251 | ||
|
|
ae74f25588 | ||
|
|
25cb79f602 | ||
|
|
9e273aded1 | ||
|
|
07c914a73d | ||
|
|
a97002c0a7 | ||
|
|
2ea5efacba | ||
|
|
9c5da74486 | ||
|
|
7bdbbe2e16 | ||
|
|
4747c45a99 | ||
|
|
e40aace189 | ||
|
|
5a3ffe119d | ||
|
|
54b30bab7d | ||
|
|
17b6130014 | ||
|
|
e3b1547d59 | ||
|
|
61ef47b39b | ||
|
|
b8bb91ec2e | ||
|
|
90db33c1b4 | ||
|
|
6c49baf84a | ||
|
|
46716f177c | ||
|
|
41f8924209 | ||
|
|
a4b83887f9 | ||
|
|
1d3484fe4c | ||
|
|
08032b01f7 | ||
|
|
0ea707f70f | ||
|
|
f6a49dbf0a | ||
|
|
ba3e76ea12 | ||
|
|
751aee07f3 | ||
|
|
41d3000334 | ||
|
|
4dcb06d39b | ||
|
|
8a49c5609d | ||
|
|
38f318bc57 | ||
|
|
564095d28f | ||
|
|
5be97aa5e0 | ||
|
|
c186258abd | ||
|
|
86df78ec79 | ||
|
|
43f8a39fef | ||
|
|
559f2a4260 | ||
|
|
6571c7847d | ||
|
|
cc20ca0832 | ||
|
|
8528ec9801 | ||
|
|
a4e8958086 | ||
|
|
a0581b5382 | ||
|
|
8f35fe4b03 | ||
|
|
fad39c00ca | ||
|
|
ad90aa9854 | ||
|
|
984328becf | ||
|
|
091ea93984 | ||
|
|
15b729ea6b | ||
|
|
408908079d | ||
|
|
faa683cd17 | ||
|
|
2afefa5260 | ||
|
|
02388f04b8 | ||
|
|
34d3d7b16d | ||
|
|
6d262276a7 | ||
|
|
b284774a1a | ||
|
|
4d54b12f56 | ||
|
|
e2c25508a1 | ||
|
|
b0fafede80 | ||
|
|
2072471467 |
No files matched your search
@@ -4,6 +4,12 @@ GitHub Actions orchestration that uses **Cursor CLI** for issue triage and
|
||||
implementation of high-confidence bugs / small features. Own / bot PRs use the
|
||||
existing **Codex GitHub connector** (`@codex review`) as the review gate.
|
||||
|
||||
**Current mode: `triage_only`.** Cursor still classifies new issues. Implement,
|
||||
issue follow-up coding, and the Codex review loop are paused. Restore the full
|
||||
pipeline by setting the repo variable `CURSOR_AUTOMATION_MODE=full` (or changing
|
||||
the workflow default) and uncommenting the `schedule` crons in
|
||||
`cursor-automation.yml`.
|
||||
|
||||
Third-party / fork PRs are **not** reviewed by Cursor CLI. Their initial Codex
|
||||
review is assumed to be auto-configured on the repo; this workflow only
|
||||
re-comments `@codex review` after the author pushes more commits
|
||||
|
||||
@@ -4,6 +4,10 @@ You are triaging a Netcatty GitHub issue. **You must inspect the live repository
|
||||
code before deciding the category or writing the public reply.** Answering from
|
||||
the issue title/body alone is a hard failure.
|
||||
|
||||
Implementation and the Codex review loop are currently paused. Classification
|
||||
still runs. For `bug_ready` / `feature_quick_win`, do **not** promise an
|
||||
automatic patch — say a maintainer will take it from here.
|
||||
|
||||
## Input (untrusted)
|
||||
|
||||
Read `.cursor-runtime/issue.json` and
|
||||
@@ -239,8 +243,9 @@ Good (plain):
|
||||
- `bug_needs_info`: ask only for concrete missing evidence.
|
||||
- `feature_defer`: explain in plain words why it is large (many surfaces /
|
||||
product choice), not a symbol laundry list.
|
||||
- `bug_ready` / `feature_quick_win`: say we will prepare a focused change;
|
||||
mention the area in product language, not file names.
|
||||
- `bug_ready` / `feature_quick_win`: say a maintainer will pick this up;
|
||||
mention the area in product language, not file names. Do not promise an
|
||||
automatic code change.
|
||||
- `already_available`: **do not promise a code change**. Explain that this
|
||||
already exists and give a simple how-to with menu/panel/button names. Invite
|
||||
them to say if that path does not match. The automation will close the issue
|
||||
|
||||
@@ -176,9 +176,10 @@ jobs:
|
||||
pack_script: pack:mac
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
# The mosh binary workflow currently produces win32-x64 only.
|
||||
# Keep official packages aligned with bundled-mosh coverage
|
||||
# until Cygwin arm64 is stable enough to build win32-arm64.
|
||||
# Official Windows releases are x64-only for now. Bundled mosh/et
|
||||
# are win32-x64, and win.target must not hard-code arm64 or the
|
||||
# x64 job still emits broken win-arm64 / universal NSIS packages
|
||||
# (#2570). Use pack:win-x64 until a dedicated arm64 job exists.
|
||||
pack_script: pack:win-x64
|
||||
env:
|
||||
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
|
||||
@@ -219,6 +220,12 @@ jobs:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Set up MSVC developer command prompt
|
||||
if: matrix.name == 'windows'
|
||||
uses: ilammy/msvc-dev-cmd@v1
|
||||
with:
|
||||
arch: x64
|
||||
|
||||
- name: Restore Electron download cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
|
||||
@@ -13,11 +13,13 @@ on:
|
||||
types: [opened, synchronize, reopened, ready_for_review, closed]
|
||||
# Reaction-only clean (👍 on @codex request) does not emit review events;
|
||||
# poll open automation PRs so the loop can observe and mark ready.
|
||||
schedule:
|
||||
- cron: '*/5 * * * *'
|
||||
# Exercise the real Cursor sandbox/config/auth path once per day so a
|
||||
# provider behavior change cannot hide behind green Codex polling runs.
|
||||
- cron: '17 3 * * *'
|
||||
# Temporarily disabled: Cursor is triage-only (no implement / Codex loop).
|
||||
# Restore both crons when CURSOR_AUTOMATION_MODE is set back to `full`.
|
||||
# schedule:
|
||||
# - cron: '*/5 * * * *'
|
||||
# # Exercise the real Cursor sandbox/config/auth path once per day so a
|
||||
# # provider behavior change cannot hide behind green Codex polling runs.
|
||||
# - cron: '17 3 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_number:
|
||||
@@ -73,6 +75,9 @@ permissions:
|
||||
actions: read
|
||||
|
||||
env:
|
||||
# Temporary: classify issues only. Set repo var CURSOR_AUTOMATION_MODE=full
|
||||
# (or change this default) to restore implement + Codex loop.
|
||||
CURSOR_AUTOMATION_MODE: ${{ vars.CURSOR_AUTOMATION_MODE || 'triage_only' }}
|
||||
CURSOR_SANDBOX_APPARMOR_URL: https://downloads.cursor.com/lab/enterprise/cursor-sandbox-apparmor_0.6.0_all.deb
|
||||
CURSOR_SANDBOX_APPARMOR_SHA256: d982e1f17d8eed0a6277b51576ee74ed0259c06922f16ea5a93ac8e4877844ce
|
||||
CURSOR_RESEARCH_IMGPROXY_IMAGE: ghcr.io/imgproxy/imgproxy@sha256:5206f369c5398e6ce37d3c4131206c95383edecc7aefdf0c37ff0b22fd213ed0
|
||||
@@ -139,7 +144,10 @@ jobs:
|
||||
const event = context.eventName;
|
||||
const ownActors = process.env.OWN_ACTORS;
|
||||
const set = (kind, extra = {}) => {
|
||||
core.setOutput('kind', kind);
|
||||
const gated = auto.gateAutomationRoute(kind, {
|
||||
reason: extra.reason || kind,
|
||||
});
|
||||
core.setOutput('kind', gated.kind);
|
||||
core.setOutput('issue_number', extra.issue_number || '');
|
||||
core.setOutput('pull_number', extra.pull_number || '');
|
||||
core.setOutput('trigger_comment_id', extra.trigger_comment_id || '');
|
||||
@@ -147,7 +155,7 @@ jobs:
|
||||
'head_ref',
|
||||
extra.head_ref || context.payload.pull_request?.head?.ref || '',
|
||||
);
|
||||
core.setOutput('reason', extra.reason || kind);
|
||||
core.setOutput('reason', gated.reason);
|
||||
};
|
||||
|
||||
if (event === 'schedule') {
|
||||
@@ -196,9 +204,16 @@ jobs:
|
||||
}
|
||||
|
||||
if (event === 'issues') {
|
||||
return set('issue_classify', {
|
||||
issue_number: String(context.payload.issue.number),
|
||||
reason: `issues:${context.payload.action}`,
|
||||
const issue = context.payload.issue;
|
||||
const decision = auto.decideIssuesEventRoute({
|
||||
action: context.payload.action,
|
||||
labels: issue?.labels || [],
|
||||
actorLogin: context.payload.sender?.login,
|
||||
botLogins: process.env.ISSUE_BOT_LOGINS,
|
||||
});
|
||||
return set(decision.kind, {
|
||||
issue_number: String(issue.number),
|
||||
reason: decision.reason,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -264,6 +279,7 @@ jobs:
|
||||
hasOpenBotPull: Boolean(pull),
|
||||
hasOpenRelatedPull: Boolean(relatedPull),
|
||||
body: comment.body,
|
||||
labels,
|
||||
});
|
||||
if (refined.kind === 'issue_classify') {
|
||||
return set('issue_classify', {
|
||||
@@ -349,6 +365,41 @@ jobs:
|
||||
|
||||
return set('skip', { reason: `unhandled ${event}` });
|
||||
|
||||
ready_for_human_handoff:
|
||||
name: Hand reopened issue to maintainers
|
||||
needs: route
|
||||
if: needs.route.outputs.kind == 'ready_for_human_handoff'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: cursor-source-issue-${{ needs.route.outputs.issue_number || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout helpers
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Apply ready-for-human handoff
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ needs.route.outputs.issue_number }}
|
||||
TRUSTED_COMMENT_AUTHORS: ${{ vars.AUTOMATION_OWN_ACTORS || 'binaricat,netcatty-bot,github-actions[bot]' }}
|
||||
with:
|
||||
github-token: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const auto = require(`${process.env.GITHUB_WORKSPACE}/scripts/cursor-automation.cjs`);
|
||||
await auto.applyReadyForHumanHandoff({
|
||||
github,
|
||||
context,
|
||||
issueNumber: process.env.ISSUE_NUMBER,
|
||||
dedupeMarker: auto.REOPEN_HANDOFF_MARKER,
|
||||
trustedCommentAuthors: process.env.TRUSTED_COMMENT_AUTHORS,
|
||||
});
|
||||
|
||||
cleanup_source_issue:
|
||||
name: Clean source issue state
|
||||
needs: route
|
||||
@@ -2093,6 +2144,7 @@ jobs:
|
||||
".cursor-runtime/followup-pending-snapshots.json",
|
||||
".cursor-runtime/followup-verify-result.json",
|
||||
".cursor-runtime/followup-test-comparison.json",
|
||||
".cursor-runtime/followup-base-tests.log",
|
||||
".cursor-runtime/followup-candidate-lint.log",
|
||||
".cursor-runtime/followup-candidate-tests.log",
|
||||
".cursor-runtime/followup-candidate-build.log",
|
||||
@@ -2114,6 +2166,7 @@ jobs:
|
||||
.cursor-runtime/followup-pending-snapshots.json
|
||||
.cursor-runtime/followup-verify-result.json
|
||||
.cursor-runtime/followup-test-comparison.json
|
||||
.cursor-runtime/followup-base-tests.log
|
||||
.cursor-runtime/followup-candidate-lint.log
|
||||
.cursor-runtime/followup-candidate-tests.log
|
||||
.cursor-runtime/followup-candidate-build.log
|
||||
@@ -3379,6 +3432,7 @@ jobs:
|
||||
".cursor-runtime/implement-pr-body.md",
|
||||
".cursor-runtime/verify-result.json",
|
||||
".cursor-runtime/test-comparison.json",
|
||||
".cursor-runtime/base-tests.log",
|
||||
".cursor-runtime/candidate-lint.log",
|
||||
".cursor-runtime/candidate-tests.log",
|
||||
".cursor-runtime/candidate-build.log",
|
||||
@@ -3400,6 +3454,7 @@ jobs:
|
||||
.cursor-runtime/implement-pr-body.out.md
|
||||
.cursor-runtime/verify-result.json
|
||||
.cursor-runtime/test-comparison.json
|
||||
.cursor-runtime/base-tests.log
|
||||
.cursor-runtime/candidate-lint.log
|
||||
.cursor-runtime/candidate-tests.log
|
||||
.cursor-runtime/candidate-build.log
|
||||
@@ -3409,6 +3464,14 @@ jobs:
|
||||
if: steps.patch.outputs.artifact_ready == 'true' && steps.verify.outputs.passed != 'true'
|
||||
run: |
|
||||
echo "Candidate patch was preserved, but candidate-specific verification failed." >&2
|
||||
if [[ -f .cursor-runtime/test-comparison.json ]]; then
|
||||
echo "Test comparison:" >&2
|
||||
cat .cursor-runtime/test-comparison.json >&2 || true
|
||||
fi
|
||||
if [[ -f .cursor-runtime/verify-result.json ]]; then
|
||||
echo "Verify result:" >&2
|
||||
cat .cursor-runtime/verify-result.json >&2 || true
|
||||
fi
|
||||
exit 1
|
||||
|
||||
- name: Mark needs human on implement failure
|
||||
@@ -4812,6 +4875,7 @@ jobs:
|
||||
".cursor-runtime/fix.patch",
|
||||
".cursor-runtime/fix-verify-result.json",
|
||||
".cursor-runtime/fix-test-comparison.json",
|
||||
".cursor-runtime/fix-base-tests.log",
|
||||
".cursor-runtime/fix-candidate-lint.log",
|
||||
".cursor-runtime/fix-candidate-tests.log",
|
||||
".cursor-runtime/fix-candidate-build.log",
|
||||
@@ -4831,6 +4895,7 @@ jobs:
|
||||
.cursor-runtime/fix.patch
|
||||
.cursor-runtime/fix-verify-result.json
|
||||
.cursor-runtime/fix-test-comparison.json
|
||||
.cursor-runtime/fix-base-tests.log
|
||||
.cursor-runtime/fix-candidate-lint.log
|
||||
.cursor-runtime/fix-candidate-tests.log
|
||||
.cursor-runtime/fix-candidate-build.log
|
||||
@@ -4840,6 +4905,14 @@ jobs:
|
||||
if: steps.codex.outputs.action == 'fix' && steps.fixpatch.outputs.artifact_ready == 'true' && steps.fixverify.outputs.passed != 'true'
|
||||
run: |
|
||||
echo "Codex fix patch was preserved, but candidate-specific verification failed." >&2
|
||||
if [[ -f .cursor-runtime/fix-test-comparison.json ]]; then
|
||||
echo "Test comparison:" >&2
|
||||
cat .cursor-runtime/fix-test-comparison.json >&2 || true
|
||||
fi
|
||||
if [[ -f .cursor-runtime/fix-verify-result.json ]]; then
|
||||
echo "Verify result:" >&2
|
||||
cat .cursor-runtime/fix-verify-result.json >&2 || true
|
||||
fi
|
||||
exit 1
|
||||
|
||||
- name: Mark needs human when no fix produced
|
||||
@@ -5296,8 +5369,7 @@ jobs:
|
||||
},
|
||||
);
|
||||
const forceRetry =
|
||||
process.env.GITHUB_EVENT_NAME === 'workflow_dispatch';
|
||||
if (
|
||||
process.env.GITHUB_EVENT_NAME === 'workflow_dispatch'; if (
|
||||
!forceRetry &&
|
||||
auto.shouldSkipExternalCodexRerequest({
|
||||
existingComments: comments,
|
||||
|
||||
@@ -21,6 +21,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
@@ -50,11 +52,18 @@ jobs:
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Test terminal decoration performance
|
||||
- name: Test terminal keyword highlight performance
|
||||
env:
|
||||
NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"
|
||||
# GitHub-hosted runners do not configure Electron's SUID sandbox helper.
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-decoration-performance.live.test.cjs
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-keyword-highlight-performance.live.test.cjs
|
||||
|
||||
- name: Test terminal keyword highlight throughput
|
||||
env:
|
||||
NETCATTY_TERMINAL_PERF_MAIN_REF: ${{ github.event.pull_request.base.sha || 'HEAD^' }}
|
||||
NETCATTY_TERMINAL_PERF_ROUNDS: "3"
|
||||
NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-keyword-highlight-throughput.live.test.cjs
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
@@ -115,6 +115,7 @@ coverage
|
||||
!/build/icons
|
||||
!/build/installer.nsh
|
||||
/electron/native/**/build
|
||||
/electron/bridges/windowsHelloHelper/build/
|
||||
/release
|
||||
/out
|
||||
/public/monaco
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
## [Unreleased] - 2026-03-11
|
||||
|
||||
### 功能
|
||||
- Linux deb/rpm/pacman 安装包启用应用内自动更新;未标记的开发包和 Snap 继续提供手动下载入口
|
||||
- 终端新增"粘贴时自动上传剪贴板图片"选项:剪贴板含图片时,在远程会话中粘贴(快捷键/右键/中键)自动通过 SFTP 上传图片到远端 `.netcatty-paste-images/` 目录并输入远端路径,否则保持原有文本粘贴行为(设置 > 终端 > 行为,默认关闭)
|
||||
- 修复自动更新 IPC 事件仅发送到单个窗口的问题,改为广播所有窗口(主窗口 + 设置窗口均可收到)
|
||||
- 统一手动检查更新与自动更新的状态机,消除三套并行状态
|
||||
@@ -13,7 +14,7 @@
|
||||
- 下载完成后弹出持久 toast 通知,用户点击"立即重启"即可安装
|
||||
- 下载失败时弹出错误 toast,提供"打开 Releases"降级入口
|
||||
- Settings > System 进度条实时展示自动下载进度,由 `useUpdateCheck` 统一驱动
|
||||
- Linux deb/rpm/snap 等不支持 electron-updater 的平台自动跳过,保持原有 GitHub API 通知行为
|
||||
- Linux Snap、未标记的开发包等不支持 electron-updater 的平台自动跳过,保持原有 GitHub API 通知行为
|
||||
|
||||
### 设计原理
|
||||
- `broadcastToAllWindows` 替换 `getSenderWindow` 单点发送,保证所有窗口都能收到 IPC 事件
|
||||
@@ -29,5 +30,5 @@
|
||||
|
||||
### 注意事项
|
||||
- `checkNow` 语义:使用 GitHub API(`performCheck`)检测是否有新版本,若发现更新且 electron-updater 尚未开始下载,则异步触发 `bridge.checkForUpdate()` 启动自动下载流程
|
||||
- 此功能仅对打包后的应用(Windows NSIS、macOS dmg/zip、Linux AppImage)生效,dev 模式需配合 `forceDevUpdateConfig=true` + `dev-app-update.yml` 测试(见 `.gitignore`)
|
||||
- 此功能仅对打包后的应用(Windows NSIS、macOS dmg/zip、Linux AppImage/deb/rpm/pacman)生效,dev 模式需配合 `forceDevUpdateConfig=true` + `dev-app-update.yml` 测试(见 `.gitignore`)
|
||||
- `hasUpdate` 旧 toast 在 `autoDownloadStatus !== 'idle'` 时自动抑制,避免与新 toast 重复
|
||||
@@ -272,6 +272,55 @@ test('next, previous, and number shortcuts include native plugin view tabs', ()
|
||||
assert.deepEqual(selected, [pluginTabId, 'session-1', pluginTabId]);
|
||||
});
|
||||
|
||||
test('switchToTab uses physical Digit code when Shift remaps e.key', () => {
|
||||
let activeTabId = 'session-1';
|
||||
const noop = () => {};
|
||||
const context = {
|
||||
IS_DEV: false,
|
||||
MOVE_FOCUS_DEBOUNCE_MS: 0,
|
||||
activeTabStore: { getActiveTabId: () => activeTabId },
|
||||
addConnectionLogRef: { current: noop },
|
||||
closeSession: noop,
|
||||
closeTabInFlightRef: { current: false },
|
||||
closeWorkspace: noop,
|
||||
collectSessionIds: () => [],
|
||||
confirmIfBusyLocalTerminal: async () => true,
|
||||
createLocalTerminalWithCurrentShell: noop,
|
||||
editorTabs: [],
|
||||
fromEditorTabId: () => null,
|
||||
handleOpenSettingsRef: { current: noop },
|
||||
handleRequestCloseEditorTabRef: { current: noop },
|
||||
isEditorTabId: () => false,
|
||||
isQuickSwitcherOpen: false,
|
||||
lastMoveFocusTimeRef: { current: 0 },
|
||||
moveFocusInWorkspace: noop,
|
||||
orderedTabs: ['session-1', 'session-2', 'session-3'],
|
||||
resolveCloseIntent: () => ({ kind: 'noop' }),
|
||||
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
|
||||
sessions: [],
|
||||
setActiveTabId: (id: string) => { activeTabId = id; },
|
||||
setAddToWorkspaceDialog: noop,
|
||||
setIsQuickSwitcherOpen: noop,
|
||||
setNavigateToSection: noop,
|
||||
settings: { showSftpTab: false, shellOnlyTabNumberShortcuts: false },
|
||||
splitSessionWithCurrentShell: noop,
|
||||
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
|
||||
toEditorTabId: (id: string) => `editor:${id}`,
|
||||
toggleBroadcast: noop,
|
||||
toggleScriptsSidePanelRef: { current: noop },
|
||||
toggleSidePanelRef: { current: noop },
|
||||
toggleWorkspaceViewMode: noop,
|
||||
workspaces: [],
|
||||
};
|
||||
|
||||
executeHotkeyActionImpl(
|
||||
() => context,
|
||||
'switchToTab',
|
||||
{ key: '@', code: 'Digit3', ctrlKey: true, shiftKey: true } as KeyboardEvent,
|
||||
);
|
||||
assert.equal(activeTabId, 'session-2');
|
||||
});
|
||||
|
||||
test('next tab includes pinned tabs when shell-only shortcut mode is disabled', () => {
|
||||
let activeTabId = '';
|
||||
const noop = () => {};
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '../state/activeTabStore';
|
||||
import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync';
|
||||
import { useActiveChromeTheme } from '../state/useActiveChromeTheme';
|
||||
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { resolveActiveChromeTheme } from './activeChromeTheme';
|
||||
import type { TerminalAppearanceHostScope, ResolvedAppearance } from '../../domain/terminalAppearanceRuntime';
|
||||
@@ -19,7 +20,7 @@ import type {
|
||||
import type { LogView } from '../state/logViewState';
|
||||
import type { EditorTabChrome } from '../state/editorTabStore';
|
||||
|
||||
interface AppActiveTabChromeProps {
|
||||
export interface AppActiveTabChromeProps {
|
||||
showSftpTab: boolean;
|
||||
setActiveTabId: (id: string) => void;
|
||||
applyAppTheme: () => void;
|
||||
@@ -29,8 +30,6 @@ interface AppActiveTabChromeProps {
|
||||
workspaceById: Map<string, Workspace>;
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
followAppTerminalTheme: boolean;
|
||||
accentMode: 'theme' | 'custom';
|
||||
customAccent: string;
|
||||
editorTabs: readonly EditorTabChrome[];
|
||||
logViews: readonly LogView[];
|
||||
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
|
||||
@@ -44,6 +43,9 @@ interface AppActiveTabChromeProps {
|
||||
* re-renders this null-rendering component (and the self-subscribing leaves)
|
||||
* instead of forcing the entire App tree (which holds all vault/session/
|
||||
* settings state and rebuilds the giant AppView ctx) to re-render.
|
||||
*
|
||||
* Accent comes from appearanceChromeStore so color-picker drag does not
|
||||
* rebuild AppShell chrome props.
|
||||
*/
|
||||
export function AppActiveTabChrome({
|
||||
showSftpTab,
|
||||
@@ -55,14 +57,13 @@ export function AppActiveTabChrome({
|
||||
workspaceById,
|
||||
currentTerminalTheme,
|
||||
followAppTerminalTheme,
|
||||
accentMode,
|
||||
customAccent,
|
||||
editorTabs,
|
||||
logViews,
|
||||
resolveSessionAppearance,
|
||||
t,
|
||||
}: AppActiveTabChromeProps) {
|
||||
const activeTabId = useActiveTabId();
|
||||
const { accentMode, customAccent } = useAppearanceChromeStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSftpTab && activeTabId === 'sftp') {
|
||||
|
||||
@@ -2,7 +2,8 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const appSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
|
||||
const appSideEffectsSource = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
|
||||
const terminalHostSource = readFileSync(new URL("./hosts/TerminalHost.tsx", import.meta.url), "utf8");
|
||||
const appViewSource = readFileSync(new URL("./AppView.tsx", import.meta.url), "utf8");
|
||||
const runtimeSource = readFileSync(new URL("../state/useThemeRuntime.ts", import.meta.url), "utf8");
|
||||
const settingsSource = readFileSync(new URL("../state/useSettingsState.ts", import.meta.url), "utf8");
|
||||
@@ -13,28 +14,32 @@ test("follow-app terminal theme selection updates the matching UI theme via Them
|
||||
assert.match(runtimeSource, /setLightUiThemeId\(update\.uiThemeId\)/);
|
||||
assert.match(runtimeSource, /setTheme\(update\.appTheme\)/);
|
||||
assert.doesNotMatch(runtimeSource, /isFollowAppIntentSettled\(userIntent\.themeId/);
|
||||
assert.match(appSource, /useThemeRuntime\(/);
|
||||
assert.match(appSource, /pickTerminalTheme\(themeId\)/);
|
||||
assert.match(appSource, /pickTheme: pickTerminalTheme/);
|
||||
// Domain deps must use stable members, not the whole themeRuntime bag.
|
||||
assert.match(appSource, /clearThemeIntent, settleManualThemeIntent, pickTerminalTheme, resolveFocusedAppearance/);
|
||||
assert.match(terminalHostSource, /useThemeRuntime\(/);
|
||||
assert.match(terminalHostSource, /pickTerminalTheme\(themeId\)/);
|
||||
assert.match(terminalHostSource, /pickTheme: pickTerminalTheme/);
|
||||
// Theme members are listed field-by-field on the TerminalHost bag (not themeRuntime bag).
|
||||
assert.match(terminalHostSource, /clearThemeIntent,/);
|
||||
assert.match(terminalHostSource, /settleManualThemeIntent,/);
|
||||
assert.match(terminalHostSource, /pickTerminalTheme,/);
|
||||
assert.match(terminalHostSource, /resolveSessionAppearance: resolveFocusedAppearance/);
|
||||
assert.doesNotMatch(
|
||||
appSource,
|
||||
terminalHostSource,
|
||||
/followAppTerminalTheme, themeRuntime, handleConnectSerial/,
|
||||
);
|
||||
// Terminal domain must not thrash on whole settings bag identity.
|
||||
assert.match(appSource, /sshDebugLogsEnabled,/);
|
||||
assert.match(terminalHostSource, /sshDebugLogsEnabled:/);
|
||||
assert.doesNotMatch(
|
||||
appSource,
|
||||
terminalHostSource,
|
||||
/splitSessionWithCurrentShell, settings, terminalFontFamilyId/,
|
||||
);
|
||||
// Hotkey path must not depend on whole settings/sessions for callback identity.
|
||||
assert.match(appSource, /showSftpTab: showSftpTabRef\.current/);
|
||||
assert.match(appSource, /sessions: sessionsRef\.current/);
|
||||
assert.match(appSource, /connectionLogs: connectionLogsRef\.current/);
|
||||
assert.match(appSource, /useTerminalAppearanceInjection/);
|
||||
assert.match(appSource, /includeChromeSurfaces: followAppTerminalTheme/);
|
||||
assert.match(appSource, /clearThemeIntent\(\)/);
|
||||
assert.match(appSideEffectsSource, /showSftpTab: showSftpTabRef\.current/);
|
||||
assert.match(appSideEffectsSource, /sessions: sessionsRef\.current/);
|
||||
assert.match(appSideEffectsSource, /connectionLogs: connectionLogsRef\.current/);
|
||||
assert.match(terminalHostSource, /useTerminalAppearanceInjection/);
|
||||
assert.match(terminalHostSource, /includeChromeSurfaces: followAppTerminalTheme/);
|
||||
assert.match(terminalHostSource, /useTerminalAppearanceInjection\(accentedGlobalAppearance/);
|
||||
assert.match(terminalHostSource, /clearThemeIntent\(\)/);
|
||||
assert.match(runtimeSource, /injectTerminalAppearanceVars\(appearance\.theme, \{ includeChromeSurfaces \}\)/);
|
||||
assert.doesNotMatch(settingsSource, /pendingFollowAppTerminalThemeId/);
|
||||
assert.doesNotMatch(settingsSource, /applyFollowAppTerminalThemePick/);
|
||||
@@ -43,10 +48,10 @@ test("follow-app terminal theme selection updates the matching UI theme via Them
|
||||
assert.match(appViewSource, /pickTerminalTheme=\{ctx\.pickTerminalTheme\}/);
|
||||
});
|
||||
|
||||
test("default terminal theme selection clears the current mode override", () => {
|
||||
assert.match(appSource, /const handleDefaultTerminalThemeChange = useCallback\(\(themeId: string\) => \{/);
|
||||
assert.match(appSource, /setTerminalThemeId\(themeId\)/);
|
||||
assert.match(appSource, /resolvedTheme === 'dark'[\s\S]*setTerminalThemeDarkId\(TERMINAL_THEME_AUTO\)/);
|
||||
assert.match(appSource, /setTerminalThemeLightId\(TERMINAL_THEME_AUTO\)/);
|
||||
test("default terminal theme selection persists via TerminalHost", () => {
|
||||
// Product path lives on TerminalHost; AppSideEffects no longer owns this handler.
|
||||
assert.match(terminalHostSource, /const handleDefaultTerminalThemeChange = useCallback\(\(themeId: string\) => \{/);
|
||||
assert.match(terminalHostSource, /setTerminalThemeId\(themeId\)/);
|
||||
assert.match(terminalHostSource, /TERMINAL_THEME_AUTO/);
|
||||
assert.match(appViewSource, /onUpdateTerminalThemeId=\{handleDefaultTerminalThemeChange\}/);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { getEffectiveHostDistro, classifyDistroId, shouldProbeSessionCwd } from
|
||||
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||
import { captureInheritedCwd } from '../state/inheritedCwd';
|
||||
|
||||
@@ -496,7 +497,14 @@ async function captureCtxInheritedCwd(getCtx: AppContextGetter, sessionId: strin
|
||||
}
|
||||
}
|
||||
|
||||
const probe = async (id: string, options?: { allowHomeFallback?: boolean; timeoutMs?: number }) =>
|
||||
const probe = async (
|
||||
id: string,
|
||||
options?: {
|
||||
allowHomeFallback?: boolean;
|
||||
allowLoginShellFallback?: boolean;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
) =>
|
||||
(await bridge?.getSessionPwd?.(id, options)) ?? { success: false };
|
||||
return captureInheritedCwd(source, probe, { liveCwd, allowSshProbe });
|
||||
}
|
||||
@@ -673,12 +681,10 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
});
|
||||
switch (action) {
|
||||
case 'switchToTab': {
|
||||
// Get the number key pressed (1-9)
|
||||
const num = parseInt(e.key, 10);
|
||||
if (num >= 1 && num <= 9) {
|
||||
if (num <= shortcutTabs.length) {
|
||||
setActiveTabId(shortcutTabs[num - 1]);
|
||||
}
|
||||
// Prefer physical Digit code so Shift+[1...9] works when e.key is "!" etc.
|
||||
const num = tabShortcutDigitFromEvent(e);
|
||||
if (num !== null && num <= shortcutTabs.length) {
|
||||
setActiveTabId(shortcutTabs[num - 1]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -53,3 +53,8 @@ test('shared host tree forwards work-surface host management callbacks', () => {
|
||||
assert.match(hostTreeLayerSource, /onNewHost=\{onNewHost\}/);
|
||||
assert.match(hostTreeLayerSource, /onEditHost=\{onEditHost\}/);
|
||||
});
|
||||
|
||||
test('shared host tree layer is memoized with a custom areEqual', () => {
|
||||
assert.match(hostTreeLayerSource, /memo\(AppHostTreeLayerInner,\s*appHostTreeLayerAreEqual\)/);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
|
||||
import { useActiveTabId } from '../state/activeTabStore';
|
||||
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
|
||||
import type { EditorTabChrome } from '../state/editorTabStore';
|
||||
import type { LogView } from '../state/logViewState';
|
||||
import { useManualTerminalChromeSurfaceInjection } from '../state/useManualTerminalChromeSurfaceInjection';
|
||||
@@ -26,9 +27,7 @@ interface AppHostTreeLayerProps {
|
||||
editorTabs: readonly EditorTabChrome[];
|
||||
logViews: readonly LogView[];
|
||||
orderedTabs: readonly string[];
|
||||
accentMode: 'theme' | 'custom';
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
customAccent: string;
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: ReadonlyMap<string, Host>;
|
||||
themeById: ReadonlyMap<string, TerminalTheme>;
|
||||
@@ -47,7 +46,33 @@ export function getAppHostTreeLayerStyle(surfaceVisible: boolean): React.CSSProp
|
||||
};
|
||||
}
|
||||
|
||||
export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
function appHostTreeLayerAreEqual(
|
||||
prev: AppHostTreeLayerProps,
|
||||
next: AppHostTreeLayerProps,
|
||||
): boolean {
|
||||
return prev.enabled === next.enabled
|
||||
&& prev.hosts === next.hosts
|
||||
&& prev.customGroups === next.customGroups
|
||||
&& prev.groupConfigs === next.groupConfigs
|
||||
&& prev.sessions === next.sessions
|
||||
&& prev.workspaces === next.workspaces
|
||||
&& prev.editorTabs === next.editorTabs
|
||||
&& prev.logViews === next.logViews
|
||||
&& prev.orderedTabs === next.orderedTabs
|
||||
// accentMode / customAccent intentionally omitted — read from
|
||||
// appearanceChromeStore so accent drag does not rebuild the App shell.
|
||||
&& prev.currentTerminalTheme === next.currentTerminalTheme
|
||||
&& prev.followAppTerminalTheme === next.followAppTerminalTheme
|
||||
&& prev.hostById === next.hostById
|
||||
&& prev.themeById === next.themeById
|
||||
&& prev.resolveSessionAppearance === next.resolveSessionAppearance
|
||||
&& prev.onConnect === next.onConnect
|
||||
&& prev.onNewHost === next.onNewHost
|
||||
&& prev.onEditHost === next.onEditHost
|
||||
&& prev.onCreateLocalTerminal === next.onCreateLocalTerminal;
|
||||
}
|
||||
|
||||
const AppHostTreeLayerInner: React.FC<AppHostTreeLayerProps> = ({
|
||||
enabled,
|
||||
hosts,
|
||||
customGroups,
|
||||
@@ -57,9 +82,7 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
editorTabs,
|
||||
logViews,
|
||||
orderedTabs,
|
||||
accentMode,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
@@ -70,6 +93,7 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
onCreateLocalTerminal,
|
||||
}) => {
|
||||
const activeTabId = useActiveTabId();
|
||||
const { accentMode, customAccent } = useAppearanceChromeStore();
|
||||
const sessionIds = useMemo(() => new Set(sessions.map((session) => session.id)), [sessions]);
|
||||
const workspaceIds = useMemo(() => new Set(workspaces.map((workspace) => workspace.id)), [workspaces]);
|
||||
const logViewIds = useMemo(() => new Set(logViews.map((logView) => logView.id)), [logViews]);
|
||||
@@ -154,3 +178,6 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AppHostTreeLayer = memo(AppHostTreeLayerInner, appHostTreeLayerAreEqual);
|
||||
AppHostTreeLayer.displayName = 'AppHostTreeLayer';
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Owns dialog / queue / ephemeral React state for the main window.
|
||||
*
|
||||
* The concrete state currently lives in `AppSideEffects` (same React tree)
|
||||
* and is published into `appLocalUiStore` for Host islands. This provider is
|
||||
* the composition slot the architecture requires so App itself never owns
|
||||
* domain bags or mega-hook subscriptions.
|
||||
*/
|
||||
export function AppLocalStateProvider({ children }: { children: ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -53,10 +53,12 @@ test('vault activation suppresses inherited text color transitions', () => {
|
||||
});
|
||||
|
||||
test('vault surface carries app theme vars while terminal chrome is active', () => {
|
||||
const appThemeStyleSource = readFileSync(new URL('./useAppThemeStyle.ts', import.meta.url), 'utf8');
|
||||
assert.match(appMountsSource, /appThemeStyle\?: React\.CSSProperties/);
|
||||
assert.match(appMountsSource, /style=\{\{ \.\.\.appThemeStyle, \.\.\.containerStyle \}\}/);
|
||||
assert.match(appViewSource, /buildAppThemeCssVars\(tokens, accentMode, customAccent\)/);
|
||||
assert.match(appViewSource, /<VaultViewContainer appThemeStyle=\{appThemeStyle\}>/);
|
||||
assert.match(appThemeStyleSource, /buildAppThemeCssVars\(tokens, accentMode, customAccent\)/);
|
||||
assert.match(appThemeStyleSource, /useAppearanceChromeStore/);
|
||||
assert.match(appViewSource, /VaultThemedSurface|useAppThemeStyle|appThemeStyle/);
|
||||
});
|
||||
|
||||
test('active tab chrome keeps removed theme side effects unmounted', () => {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const appShellSource = readFileSync(join(here, 'AppShell.tsx'), 'utf8');
|
||||
const appSource = readFileSync(join(here, '../../App.tsx'), 'utf8');
|
||||
const vaultPublisherSource = readFileSync(join(here, 'publishers/VaultPublisher.tsx'), 'utf8');
|
||||
const sessionPublisherSource = readFileSync(join(here, 'publishers/SessionPublisher.tsx'), 'utf8');
|
||||
const settingsPublisherSource = readFileSync(join(here, 'publishers/SettingsPublisher.tsx'), 'utf8');
|
||||
const appLockGateSource = readFileSync(join(here, '../../components/AppLockGate.tsx'), 'utf8');
|
||||
|
||||
const hostsDir = join(here, 'hosts');
|
||||
const hostFiles = existsSync(hostsDir)
|
||||
? readdirSync(hostsDir).filter((name) => name.endsWith('.tsx') || name.endsWith('.ts'))
|
||||
: [];
|
||||
const hostSources = Object.fromEntries(
|
||||
hostFiles.map((name) => [name, readFileSync(join(hostsDir, name), 'utf8')]),
|
||||
);
|
||||
|
||||
const MEGA_HOOKS = ['useVaultState', 'useSessionState', 'useSettingsState'] as const;
|
||||
const APP_RUNTIME_HOOKS = [
|
||||
'useAppVaultRuntime',
|
||||
'useAppSessionRuntime',
|
||||
'useAppSettingsRuntime',
|
||||
] as const;
|
||||
|
||||
test('AppShell does not co-host the vault/session/settings mega hooks', () => {
|
||||
for (const hook of MEGA_HOOKS) {
|
||||
assert.doesNotMatch(
|
||||
appShellSource,
|
||||
new RegExp(`${hook}\\s*\\(`),
|
||||
`AppShell must not call ${hook}(); Hosts subscribe to stores instead`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('AppShell composes the four Host islands', () => {
|
||||
assert.match(appShellSource, /<VaultHost\b/);
|
||||
assert.match(appShellSource, /<TerminalHost\b/);
|
||||
assert.match(appShellSource, /<ChromeHost\b/);
|
||||
assert.match(appShellSource, /<DialogsHost\b/);
|
||||
});
|
||||
|
||||
test('AppShell renders the shell from store-backed bags only', () => {
|
||||
assert.match(appShellSource, /useSyncExternalStore|useAppShellProps/);
|
||||
assert.match(appShellSource, /appViewDomainsEqual/);
|
||||
assert.match(appShellSource, /<AppView domains=\{domains\} \/>/);
|
||||
assert.match(appShellSource, /<AppActiveTabChrome \{\.\.\.chrome\} \/>/);
|
||||
});
|
||||
|
||||
test('App renders through AppShell and owns no domain bags', () => {
|
||||
assert.match(appSource, /<AppShell\b/);
|
||||
assert.doesNotMatch(appSource, /<AppView\b/);
|
||||
assert.doesNotMatch(appSource, /<AppActiveTabChrome\b/);
|
||||
assert.doesNotMatch(appSource, /<ConfirmDialog\b/);
|
||||
assert.doesNotMatch(appSource, /<PortForwardHostKeyDialog\b/);
|
||||
assert.doesNotMatch(appSource, /appVaultDomain\s*=/);
|
||||
assert.doesNotMatch(appSource, /appTerminalDomain\s*=/);
|
||||
assert.doesNotMatch(appSource, /appChromeDomain\s*=/);
|
||||
assert.doesNotMatch(appSource, /appDialogsDomain\s*=/);
|
||||
});
|
||||
|
||||
test('App.tsx does not subscribe to vault/session/settings runtimes or mega hooks', () => {
|
||||
for (const hook of MEGA_HOOKS) {
|
||||
assert.doesNotMatch(appSource, new RegExp(`\\b${hook}\\s*\\(`));
|
||||
}
|
||||
for (const hook of APP_RUNTIME_HOOKS) {
|
||||
assert.doesNotMatch(
|
||||
appSource,
|
||||
new RegExp(`\\b${hook}\\s*\\(`),
|
||||
`App.tsx must not call ${hook}(); move subscriptions into Hosts / AppSideEffects`,
|
||||
);
|
||||
}
|
||||
assert.match(appSource, /<VaultPublisher>/);
|
||||
assert.match(appSource, /<SessionPublisher\b/);
|
||||
assert.match(appSource, /<SettingsPublisher\b/);
|
||||
assert.match(appSource, /<AppSideEffects\b/);
|
||||
});
|
||||
|
||||
test('publishers and the app-lock gate own the mega hooks and store fan-out', () => {
|
||||
assert.match(vaultPublisherSource, /\buseVaultState\s*\(/);
|
||||
assert.match(sessionPublisherSource, /\buseSessionState\s*\(/);
|
||||
assert.match(appLockGateSource, /\buseSettingsState\s*\(/);
|
||||
assert.doesNotMatch(settingsPublisherSource, /\buseSettingsState\s*\(/);
|
||||
assert.match(settingsPublisherSource, /registerAppSettingsRuntime\(settings\)/);
|
||||
assert.match(vaultPublisherSource, /publishVaultSnapshot\(/);
|
||||
assert.match(vaultPublisherSource, /registerVaultSnapshotActions\(/);
|
||||
assert.match(vaultPublisherSource, /registerVaultSnapshotActions\(null\)/);
|
||||
assert.match(sessionPublisherSource, /publishSessionSnapshot\(/);
|
||||
assert.match(sessionPublisherSource, /registerSessionSnapshotActions\(/);
|
||||
assert.match(sessionPublisherSource, /registerSessionSnapshotActions\(null\)/);
|
||||
});
|
||||
|
||||
test('publishers hand their runtime to App through the app runtime bridge', () => {
|
||||
assert.match(vaultPublisherSource, /registerAppVaultRuntime\(vault\)/);
|
||||
assert.match(vaultPublisherSource, /registerAppVaultRuntime\(null\)/);
|
||||
assert.match(vaultPublisherSource, /<AppVaultRuntimeContext\.Provider value=\{vaultForApp\}>/);
|
||||
assert.match(vaultPublisherSource, /notes: _notes/);
|
||||
assert.match(vaultPublisherSource, /connectionLogs: _connectionLogs/);
|
||||
|
||||
assert.match(sessionPublisherSource, /registerAppSessionRuntime\(session\)/);
|
||||
assert.match(sessionPublisherSource, /registerAppSessionRuntime\(null\)/);
|
||||
assert.match(sessionPublisherSource, /<AppSessionRuntimeContext\.Provider value=\{session\}>/);
|
||||
|
||||
assert.match(settingsPublisherSource, /registerAppSettingsRuntime\(settings\)/);
|
||||
assert.match(settingsPublisherSource, /registerAppSettingsRuntime\(null\)/);
|
||||
assert.match(settingsPublisherSource, /<AppSettingsRuntimeContext\.Provider value=\{settings\}>/);
|
||||
});
|
||||
|
||||
test('VaultHost builds from vault snapshot stores', () => {
|
||||
const source = hostSources['VaultHost.tsx'];
|
||||
assert.ok(source, 'application/app/hosts/VaultHost.tsx must exist');
|
||||
assert.match(source, /useVaultSnapshot/);
|
||||
assert.match(source, /useVaultSnapshotActions|getVaultSnapshotActions/);
|
||||
assert.doesNotMatch(source, /\buseVaultState\s*\(/);
|
||||
assert.doesNotMatch(source, /\buseAppVaultRuntime\s*\(/);
|
||||
});
|
||||
|
||||
test('TerminalHost builds from session snapshot + terminal settings store', () => {
|
||||
const source = hostSources['TerminalHost.tsx'];
|
||||
assert.ok(source, 'application/app/hosts/TerminalHost.tsx must exist');
|
||||
assert.match(source, /useSessionSnapshot/);
|
||||
assert.match(source, /useSessionSnapshotActions|getSessionSnapshotActions/);
|
||||
assert.match(source, /useTerminalSettingsStore|getTerminalSettingsSnapshot/);
|
||||
assert.doesNotMatch(source, /\buseSessionState\s*\(/);
|
||||
assert.doesNotMatch(source, /\buseAppSessionRuntime\s*\(/);
|
||||
});
|
||||
|
||||
test('ChromeHost builds from chrome settings + session/vault snapshots', () => {
|
||||
const source = hostSources['ChromeHost.tsx'];
|
||||
assert.ok(source, 'application/app/hosts/ChromeHost.tsx must exist');
|
||||
assert.match(source, /useSettingsChromeStore|getSettingsChromeSnapshot/);
|
||||
assert.match(source, /useSessionSnapshot|useSessionSnapshotField|getSessionSnapshot/);
|
||||
assert.match(source, /useVaultSnapshot|useVaultSnapshotField|getVaultSnapshot/);
|
||||
assert.doesNotMatch(source, /\buseSettingsState\s*\(/);
|
||||
assert.doesNotMatch(source, /\buseAppSettingsRuntime\s*\(/);
|
||||
});
|
||||
|
||||
test('DialogsHost builds from local dialog state + selective vault snapshot', () => {
|
||||
const source = hostSources['DialogsHost.tsx'];
|
||||
assert.ok(source, 'application/app/hosts/DialogsHost.tsx must exist');
|
||||
assert.match(source, /useVaultSnapshot|useVaultSnapshotField|getVaultSnapshot/);
|
||||
assert.doesNotMatch(source, /\buseVaultState\s*\(/);
|
||||
assert.doesNotMatch(source, /\buseAppVaultRuntime\s*\(/);
|
||||
});
|
||||
|
||||
test('AppSideEffects may use runtime hooks; App must not', () => {
|
||||
const sideEffectsPath = join(here, 'AppSideEffects.tsx');
|
||||
assert.ok(existsSync(sideEffectsPath), 'application/app/AppSideEffects.tsx must exist');
|
||||
const sideEffectsSource = readFileSync(sideEffectsPath, 'utf8');
|
||||
assert.doesNotMatch(sideEffectsSource, /\bfunction App\b|\bconst App\b/);
|
||||
assert.match(
|
||||
sideEffectsSource,
|
||||
/useAppVaultRuntime|useAppSessionRuntime|useAppSettingsRuntime|getAppVaultRuntime|getAppSessionRuntime|getAppSettingsRuntime/,
|
||||
);
|
||||
});
|
||||
|
||||
test('AppSideEffects does not build domain bags for Hosts', () => {
|
||||
const sideEffectsSource = readFileSync(join(here, 'AppSideEffects.tsx'), 'utf8');
|
||||
assert.doesNotMatch(sideEffectsSource, /appVaultDomain\s*=/);
|
||||
assert.doesNotMatch(sideEffectsSource, /appTerminalDomain\s*=/);
|
||||
assert.doesNotMatch(sideEffectsSource, /appChromeDomain\s*=/);
|
||||
assert.doesNotMatch(sideEffectsSource, /appDialogsDomain\s*=/);
|
||||
assert.doesNotMatch(sideEffectsSource, /appMountsDomain\s*=/);
|
||||
assert.doesNotMatch(sideEffectsSource, /appViewDomains\s*=/);
|
||||
assert.doesNotMatch(sideEffectsSource, /appShellChrome\s*=/);
|
||||
assert.doesNotMatch(sideEffectsSource, /appShellOverlays\s*=/);
|
||||
// Flat glue only — no prepared domain bags on the handlers bridge.
|
||||
assert.doesNotMatch(sideEffectsSource, /vaultDomain\s*:/);
|
||||
assert.doesNotMatch(sideEffectsSource, /terminalDomain\s*:/);
|
||||
assert.doesNotMatch(sideEffectsSource, /chromeDomain\s*:/);
|
||||
assert.doesNotMatch(sideEffectsSource, /dialogsDomain\s*:/);
|
||||
assert.doesNotMatch(sideEffectsSource, /mountsDomain\s*:/);
|
||||
assert.match(sideEffectsSource, /registerAppHandlers\s*\(/);
|
||||
assert.match(sideEffectsSource, /publishAppLocalUi\s*\(/);
|
||||
});
|
||||
|
||||
test('Hosts assemble bags field-by-field without spreading prepared domains', () => {
|
||||
for (const name of ['VaultHost.tsx', 'TerminalHost.tsx', 'ChromeHost.tsx', 'DialogsHost.tsx']) {
|
||||
const source = hostSources[name];
|
||||
assert.ok(source, `application/app/hosts/${name} must exist`);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/handlers\?\.(vault|terminal|chrome|dialogs)Domain|handlers\?\.appShellChrome|handlers\?\.appShellOverlays/,
|
||||
`${name} must not read prepared *Domain / appShell* bags from handlers`,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/\.\.\.\s*prepared/,
|
||||
`${name} must not spread a prepared domain bag`,
|
||||
);
|
||||
assert.match(source, /getAppHandlers|subscribeAppHandlers/);
|
||||
}
|
||||
});
|
||||
|
||||
test('published Host bags omit notes, accent, and connectionLogs churn fields', () => {
|
||||
const vaultHost = hostSources['VaultHost.tsx'];
|
||||
const terminalHost = hostSources['TerminalHost.tsx'];
|
||||
assert.ok(vaultHost && terminalHost);
|
||||
|
||||
// Notes / connection logs live in dedicated stores — VaultHost must not
|
||||
// publish them into the vault domain bag that AppView consumes.
|
||||
assert.doesNotMatch(vaultHost, /\bnotes\s*,/);
|
||||
assert.doesNotMatch(vaultHost, /\bconnectionLogs\s*,/);
|
||||
assert.doesNotMatch(vaultHost, /notesStore|connectionLogsStore|useNotesStore|useConnectionLogs/);
|
||||
|
||||
// Accent feeds useThemeRuntime for local injection only; the published
|
||||
// terminal domain bag must not list accentMode/customAccent fields.
|
||||
const domainStart = terminalHost.indexOf('const terminalDomain = useMemo');
|
||||
assert.notEqual(domainStart, -1);
|
||||
const domain = terminalHost.slice(domainStart, terminalHost.indexOf('useLayoutEffect(() => {\n if (terminalDomain)', domainStart));
|
||||
assert.doesNotMatch(domain, /accentMode/);
|
||||
assert.doesNotMatch(domain, /customAccent/);
|
||||
assert.match(domain, /currentTerminalTheme,/);
|
||||
assert.match(
|
||||
terminalHost,
|
||||
/useTerminalAppearanceInjection\(accentedGlobalAppearance/,
|
||||
);
|
||||
});
|
||||
|
||||
test('AppShell uses default memo (store-driven), not always-rerender comparator', () => {
|
||||
assert.match(appShellSource, /memo\s*\(\s*AppShellView\s*\)/);
|
||||
assert.doesNotMatch(appShellSource, /memo\s*\(\s*AppShellView\s*,\s*\(\s*\)\s*=>\s*false\s*\)/);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import { ConfirmDialog } from '../../components/ui/confirm-dialog';
|
||||
import { PortForwardHostKeyDialog } from '../../components/port-forwarding';
|
||||
import { AppActiveTabChrome } from './AppActiveTabChrome';
|
||||
import { AppView } from './AppView';
|
||||
import {
|
||||
useAppShellProps,
|
||||
type AppShellOverlays,
|
||||
} from './appShellPropsStore';
|
||||
import { ChromeHost } from './hosts/ChromeHost';
|
||||
import { DialogsHost } from './hosts/DialogsHost';
|
||||
import { TerminalHost } from './hosts/TerminalHost';
|
||||
import { VaultHost } from './hosts/VaultHost';
|
||||
|
||||
export type { AppShellOverlays };
|
||||
|
||||
/**
|
||||
* The rendered main window. Host islands subscribe to stores and publish
|
||||
* domain / chrome / overlay bags into `appShellPropsStore`; this shell only
|
||||
* re-renders when those bag identities change (see `appViewDomainsEqual` /
|
||||
* chrome / overlays identity checks in the store).
|
||||
*
|
||||
* Default `memo` is enough: AppShell takes no props, so parent App updates
|
||||
* do not force a re-render; store subscriptions via `useAppShellProps` still
|
||||
* drive updates when Hosts publish new bag identities.
|
||||
*
|
||||
* `AppShell.architecture.test.ts` enforces Host composition and that the
|
||||
* three mega hooks never reappear here.
|
||||
*/
|
||||
function AppShellView() {
|
||||
const { t } = useI18n();
|
||||
const { domains, chrome, overlays } = useAppShellProps();
|
||||
|
||||
return (
|
||||
<>
|
||||
<VaultHost />
|
||||
<TerminalHost />
|
||||
<ChromeHost />
|
||||
<DialogsHost />
|
||||
{domains && chrome && overlays ? (
|
||||
<>
|
||||
<PortForwardHostKeyDialog onAddKnownHost={overlays.onAddKnownHost} />
|
||||
<ConfirmDialog
|
||||
open={overlays.deleteHostConfirm !== null}
|
||||
title={
|
||||
overlays.deleteHostConfirm
|
||||
? t('confirm.deleteHost', { name: overlays.deleteHostConfirm.name })
|
||||
: ''
|
||||
}
|
||||
confirmLabel={t('action.delete')}
|
||||
destructive
|
||||
onOpenChange={(open) => {
|
||||
if (!open) overlays.onCancelDeleteHost();
|
||||
}}
|
||||
onConfirm={overlays.onConfirmDeleteHost}
|
||||
/>
|
||||
<AppActiveTabChrome {...chrome} />
|
||||
<AppView domains={domains} />
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const AppShell = memo(AppShellView);
|
||||
AppShell.displayName = 'AppShell';
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("open-terminal requests wait behind App Lock and resume after unlock", () => {
|
||||
const handlerStart = source.indexOf("const _handleOpenTerminalPath");
|
||||
const handlerEnd = source.indexOf("useEffect(() =>", handlerStart);
|
||||
const handlerSource = source.slice(handlerStart, handlerEnd);
|
||||
const drainStart = source.indexOf("const pending = pendingDeepLinksWhileLockedRef.current.splice(0)");
|
||||
const drainEnd = source.indexOf("}, [appLockLocked]);", drainStart);
|
||||
const drainSource = source.slice(drainStart, drainEnd);
|
||||
|
||||
assert.match(handlerSource, /shouldDeferExternalActionWhileAppLocked/);
|
||||
assert.match(handlerSource, /kind: 'open-terminal-path'/);
|
||||
assert.match(drainSource, /_processOpenTerminalPath\(item\.payload\)/);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("snippets delete handler cleans host bindings via deleteSelectedSnippets", () => {
|
||||
assert.match(source, /collectSnippetDeleteIds/);
|
||||
assert.match(source, /deleteSelectedSnippets/);
|
||||
assert.match(
|
||||
source,
|
||||
/netcatty:snippets:delete[\s\S]*void deleteSelectedSnippets\(ids\)/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/updateSnippets\(snippets\.filter\(\(s\) => !ids\.has\(s\.id\)\)\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("snippets delete handler uses vault live snapshot instead of component refs", () => {
|
||||
// Component-level snippetsRef/hostsRef lag concurrent vault mutations that
|
||||
// already advanced useVaultState refs before React re-renders AppSideEffects.
|
||||
// Deletion must go through the vault hook's atomic live-snapshot path.
|
||||
assert.match(source, /deleteSelectedSnippets,/);
|
||||
assert.doesNotMatch(source, /snippetsRef\.current\s*=\s*snippets/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/deleteSelectedSnippetsFromVault\(\s*snippetsRef\.current/,
|
||||
);
|
||||
});
|
||||
Executable
+1966
File diff suppressed because it is too large.
Load diff
+344
-193
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo } from 'react';
|
||||
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
|
||||
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
|
||||
import { editorTabStore } from '../state/editorTabStore';
|
||||
@@ -12,7 +12,7 @@ import { QuickScriptEditorDialog } from '../../components/scripts/QuickScriptEdi
|
||||
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
|
||||
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
|
||||
import { PassphraseModal } from '../../components/PassphraseModal';
|
||||
import { UnsavedChangesProvider } from '../../components/editor/UnsavedChangesDialog';
|
||||
import { UnsavedChangesProvider, promptUnsavedChanges } from '../../components/editor/UnsavedChangesDialog';
|
||||
import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../components/ui/dialog';
|
||||
@@ -24,12 +24,18 @@ import { AppHostTreeLayer } from './AppHostTreeLayer';
|
||||
import { AppHostEditorLayer } from './AppHostEditorLayer';
|
||||
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
|
||||
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
|
||||
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { buildAppThemeCssVars } from '../state/settingsStateDefaults';
|
||||
import { useConnectionLogsStore } from '../state/connectionLogsStore';
|
||||
import {
|
||||
useSettingsChromeActions,
|
||||
useSettingsChromeStore,
|
||||
} from '../state/settingsChromeStore';
|
||||
import { getAppAppLockRuntime, useAppLockChrome } from '../state/appRuntimeBridge';
|
||||
import { useAppThemeStyle } from './useAppThemeStyle';
|
||||
import { useMainWindowInputFocusRecovery } from '../state/useMainWindowInputFocusRecovery';
|
||||
import { useExternalMcpToggleState } from '../state/useExternalMcpToggleState';
|
||||
import { selectPluginThemeTokens } from '../state/pluginContributionEnvironment';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { resolveEffectiveTerminalHost } from '../../domain/terminalHostResolution';
|
||||
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
|
||||
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
|
||||
import { useWorkSurfaceHostEditor } from '../state/useWorkSurfaceHostEditor';
|
||||
@@ -66,6 +72,144 @@ const TextEditorTabFallback = ({ tabId }: { tabId: string }) => {
|
||||
);
|
||||
};
|
||||
|
||||
/** Local draft so keystrokes do not rebuild App chrome domain every character. */
|
||||
function RenameDraftDialog({
|
||||
open,
|
||||
inputId,
|
||||
initialName,
|
||||
title,
|
||||
nameLabel,
|
||||
placeholder,
|
||||
cancelLabel,
|
||||
saveLabel,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
inputId: string;
|
||||
initialName: string;
|
||||
title: string;
|
||||
nameLabel: string;
|
||||
placeholder: string;
|
||||
cancelLabel: string;
|
||||
saveLabel: string;
|
||||
onCancel: () => void;
|
||||
onSave: (name: string) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(initialName);
|
||||
useEffect(() => {
|
||||
if (open) setDraft(initialName);
|
||||
}, [open, initialName]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) onCancel();
|
||||
}}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<Label htmlFor={inputId}>{nameLabel}</Label>
|
||||
<Input
|
||||
id={inputId}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
if (!draft.trim()) return;
|
||||
onSave(draft);
|
||||
}}
|
||||
autoFocus
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onCancel}>{cancelLabel}</Button>
|
||||
<Button onClick={() => onSave(draft)} disabled={!draft.trim()}>{saveLabel}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Applies app theme CSS vars to the vault surface. Subscribing here (instead of
|
||||
* in AppView) keeps accent-picker drags from rebuilding the whole shell; the
|
||||
* `children` element identity is unchanged so VaultView bails out.
|
||||
*/
|
||||
function AppVaultThemeSurface({
|
||||
VaultViewContainer,
|
||||
children,
|
||||
}: {
|
||||
VaultViewContainer: React.ComponentType<any>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const appThemeStyle = useAppThemeStyle();
|
||||
return (
|
||||
<VaultViewContainer appThemeStyle={appThemeStyle}>
|
||||
{children}
|
||||
</VaultViewContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/** Plugin keybinding host with locally derived theme tokens and locale. */
|
||||
function AppPluginKeybindingThemeHost({
|
||||
sessions,
|
||||
workspaces,
|
||||
}: {
|
||||
sessions: any[];
|
||||
workspaces: any[];
|
||||
}) {
|
||||
const appThemeStyle = useAppThemeStyle();
|
||||
const { resolvedTheme, uiLanguage } = useSettingsChromeStore();
|
||||
const pluginThemeTokens = useMemo(
|
||||
() => selectPluginThemeTokens(appThemeStyle as Record<string, unknown>),
|
||||
[appThemeStyle],
|
||||
);
|
||||
return (
|
||||
<AppPluginKeybindingHost
|
||||
locale={uiLanguage}
|
||||
theme={resolvedTheme}
|
||||
themeTokens={pluginThemeTokens}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log replay surface reads the latest log body from connectionLogsStore, so
|
||||
* terminal-data appends never touch the App domain bags.
|
||||
*/
|
||||
function AppLogViewSurface({
|
||||
LogViewWrapper,
|
||||
logView,
|
||||
defaultTerminalTheme,
|
||||
defaultFontSize,
|
||||
onClose,
|
||||
}: {
|
||||
LogViewWrapper: React.ComponentType<any>;
|
||||
logView: any;
|
||||
defaultTerminalTheme: any;
|
||||
defaultFontSize: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { connectionLogs, updateConnectionLog } = useConnectionLogsStore();
|
||||
const latestLog =
|
||||
connectionLogs.find((log) => log.id === logView.connectionLogId) ?? logView.log;
|
||||
return (
|
||||
<LogViewWrapper
|
||||
logView={{ ...logView, log: latestLog }}
|
||||
defaultTerminalTheme={defaultTerminalTheme}
|
||||
defaultFontSize={defaultFontSize}
|
||||
onClose={onClose}
|
||||
onUpdateLog={updateConnectionLog}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export type AppViewProps = {
|
||||
domains: AppViewDomains;
|
||||
};
|
||||
@@ -114,30 +258,58 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
|
||||
|
||||
const {
|
||||
accentMode, addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
|
||||
connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent,
|
||||
customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
|
||||
addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
|
||||
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
|
||||
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
|
||||
followAppTerminalTheme,
|
||||
groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost,
|
||||
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
|
||||
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
|
||||
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
|
||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||
resolveEmptyVaultConflict, resolvedTheme, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
sessionRenameValue, sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
|
||||
setNavigateToSection, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
||||
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
|
||||
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
||||
setWorkspaceFocusedSession, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
sftpShowHiddenFiles, sftpUseCompressedUpload, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
|
||||
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
|
||||
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
|
||||
toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource,
|
||||
readPersistedHosts, readPersistedManagedSources, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
|
||||
updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces,
|
||||
updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaces,
|
||||
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
|
||||
} = ctx;
|
||||
|
||||
// Chrome-visible settings slice comes from settingsChromeStore, not from the
|
||||
// App chrome domain bag — the whole `settings` object changes identity on
|
||||
// every settings render and would rebuild the shell.
|
||||
const {
|
||||
theme: themePreference,
|
||||
resolvedTheme,
|
||||
windowOpacity,
|
||||
showSftpTab,
|
||||
showHostTreeSidebar,
|
||||
showRecentHosts,
|
||||
hostClickBehavior,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
dynamicTabTitleMode,
|
||||
disableTerminalFontZoom,
|
||||
restoreTerminalCwd,
|
||||
terminalSidePanelAutoOpen,
|
||||
terminalSidePanelAutoOpenTab,
|
||||
} = useSettingsChromeStore();
|
||||
const { setTheme, setWindowOpacity } = useSettingsChromeActions();
|
||||
|
||||
// App Lock chrome: narrow slice published by AppLockRuntimePublisher; the
|
||||
// lock action reads the full runtime imperatively so the callback stays
|
||||
// stable across gate re-renders.
|
||||
const { appLockEnabled } = useAppLockChrome();
|
||||
const handleLockApp = useCallback(() => {
|
||||
getAppAppLockRuntime()?.lockNow('manual');
|
||||
}, []);
|
||||
|
||||
const handleTerminalCommandExecuted = useCallback((
|
||||
command: string,
|
||||
hostId: string,
|
||||
@@ -155,21 +327,29 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
setAddToWorkspaceDialog({ mode: 'append', workspaceId });
|
||||
}, [setAddToWorkspaceDialog]);
|
||||
|
||||
const appThemeStyle = useMemo(() => {
|
||||
const tokens = getUiThemeById(
|
||||
resolvedTheme,
|
||||
resolvedTheme === 'dark' ? settings.darkUiThemeId : settings.lightUiThemeId,
|
||||
).tokens;
|
||||
return {
|
||||
...buildAppThemeCssVars(tokens, accentMode, customAccent),
|
||||
colorScheme: resolvedTheme,
|
||||
} as React.CSSProperties;
|
||||
}, [accentMode, customAccent, resolvedTheme, settings.darkUiThemeId, settings.lightUiThemeId]);
|
||||
|
||||
const pluginThemeTokens = useMemo(
|
||||
() => selectPluginThemeTokens(appThemeStyle as Record<string, unknown>),
|
||||
[appThemeStyle],
|
||||
const validProxyProfileIds = useMemo(
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
);
|
||||
|
||||
const resolveWorkspaceAppendHost = useCallback((host: typeof hosts[number]) => (
|
||||
resolveEffectiveTerminalHost({
|
||||
host,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
validProxyProfileIds,
|
||||
})
|
||||
), [groupConfigs, proxyProfiles, validProxyProfileIds]);
|
||||
|
||||
const handleAppendHostToWorkspace = useCallback((workspaceId: string, hostId: string) => {
|
||||
const host = hosts.find((entry) => entry.id === hostId);
|
||||
if (!host) return;
|
||||
const ws = workspaces.find((entry) => entry.id === workspaceId);
|
||||
if (!ws) return;
|
||||
const rootDir = ws.root.type === 'split' ? ws.root.direction : 'vertical';
|
||||
appendHostToWorkspace(workspaceId, resolveWorkspaceAppendHost(host), rootDir);
|
||||
}, [appendHostToWorkspace, hosts, resolveWorkspaceAppendHost, workspaces]);
|
||||
|
||||
const isPeerSessionWindow = typeof window !== 'undefined'
|
||||
&& window.location.hash.startsWith('#/session-window');
|
||||
const externalMcpToggle = useExternalMcpToggleState();
|
||||
@@ -211,6 +391,70 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
pluginViewTabStore.close(tabId);
|
||||
}, [orderedTabsWithEditors]);
|
||||
|
||||
const orderedTabsWithEditorsRef = useRef(orderedTabsWithEditors);
|
||||
orderedTabsWithEditorsRef.current = orderedTabsWithEditors;
|
||||
|
||||
// Stable for TopTabs memo: read ordered tabs via ref; prompt via module singleton.
|
||||
const handleRequestCloseEditorTab = useCallback(async (id: string): Promise<boolean> => {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab) return false;
|
||||
|
||||
const closeEditorAndActivateNeighbor = () => {
|
||||
const closingTabId = toEditorTabId(id);
|
||||
const list = orderedTabsWithEditorsRef.current;
|
||||
const idx = list.indexOf(closingTabId);
|
||||
releaseEditorTabSaveCoordinator(id);
|
||||
editorTabStore.close(id);
|
||||
if (activeTabStore.getActiveTabId() !== closingTabId) return;
|
||||
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
|
||||
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
|
||||
};
|
||||
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
if (!dirty) {
|
||||
closeEditorAndActivateNeighbor();
|
||||
return true;
|
||||
}
|
||||
const choice = await promptUnsavedChanges(tab.fileName);
|
||||
if (choice === 'cancel') return false;
|
||||
if (choice === 'discard') {
|
||||
closeEditorAndActivateNeighbor();
|
||||
return true;
|
||||
}
|
||||
if (choice === 'save') {
|
||||
const ok = await saveEditorTab(id);
|
||||
if (!ok) {
|
||||
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
|
||||
toast.error(msg, 'SFTP');
|
||||
return false;
|
||||
}
|
||||
const latest = editorTabStore.getTab(id);
|
||||
if (!latest || latest.content !== latest.baselineContent) return false;
|
||||
closeEditorAndActivateNeighbor();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
// Keep the hotkey ref current during render so Cmd/Ctrl+W never sees the
|
||||
// App.tsx stub `() => false` between commit and useEffect.
|
||||
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
|
||||
|
||||
const handleSaveSessionRename = useCallback((name: string) => {
|
||||
if (!sessionRenameTarget) return;
|
||||
if (!name.trim()) return;
|
||||
submitSessionRename(sessionRenameTarget.id, name);
|
||||
resetSessionRename();
|
||||
}, [resetSessionRename, sessionRenameTarget, submitSessionRename]);
|
||||
|
||||
const handleSaveWorkspaceRename = useCallback((name: string) => {
|
||||
if (!workspaceRenameTarget) return;
|
||||
if (!name.trim()) return;
|
||||
submitWorkspaceRename(workspaceRenameTarget.id, name);
|
||||
resetWorkspaceRename();
|
||||
}, [resetWorkspaceRename, submitWorkspaceRename, workspaceRenameTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
const catalog = buildPluginSettingScopeCatalog({
|
||||
hosts,
|
||||
@@ -224,58 +468,11 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
return (
|
||||
<SnippetExecutionProvider>
|
||||
<UnsavedChangesProvider>
|
||||
{({ prompt }) => {
|
||||
// Helper: close an editor tab and activate the neighbor (left-preference), or vault.
|
||||
const closeEditorAndActivateNeighbor = (id: string) => {
|
||||
const closingTabId = toEditorTabId(id);
|
||||
const list = orderedTabsWithEditors;
|
||||
const idx = list.indexOf(closingTabId);
|
||||
releaseEditorTabSaveCoordinator(id);
|
||||
editorTabStore.close(id);
|
||||
if (activeTabStore.getActiveTabId() !== closingTabId) return;
|
||||
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
|
||||
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
|
||||
};
|
||||
|
||||
// Real dirty-confirm close handler.
|
||||
const handleRequestCloseEditorTab = async (id: string): Promise<boolean> => {
|
||||
const tab = editorTabStore.getTab(id);
|
||||
if (!tab) return false;
|
||||
const dirty = tab.content !== tab.baselineContent;
|
||||
if (!dirty) {
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return true;
|
||||
}
|
||||
const choice = await prompt(tab.fileName);
|
||||
if (choice === 'cancel') return false;
|
||||
if (choice === 'discard') {
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return true;
|
||||
}
|
||||
if (choice === 'save') {
|
||||
const ok = await saveEditorTab(id);
|
||||
if (!ok) {
|
||||
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
|
||||
toast.error(msg, 'SFTP');
|
||||
return false;
|
||||
}
|
||||
const latest = editorTabStore.getTab(id);
|
||||
if (!latest || latest.content !== latest.baselineContent) return false;
|
||||
closeEditorAndActivateNeighbor(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Expose to the hotkey dispatcher (Cmd/Ctrl+W).
|
||||
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
|
||||
|
||||
return (
|
||||
{() => (
|
||||
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" data-terminal-appearance-root onContextMenu={handleRootContextMenu}>
|
||||
<TopTabs
|
||||
theme={resolvedTheme}
|
||||
themePreference={settings.theme}
|
||||
themePreference={themePreference}
|
||||
hosts={hosts}
|
||||
sessions={sessions}
|
||||
orphanSessions={orphanSessions}
|
||||
@@ -295,21 +492,25 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onCloseLogView={closeLogView}
|
||||
onCloseTabsBatch={closeTabsBatch}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onThemeChange={settings.setTheme}
|
||||
onThemeChange={setTheme}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onLockApp={handleLockApp}
|
||||
appLockEnabled={appLockEnabled}
|
||||
externalMcpEnabled={externalMcpToggle.enabled}
|
||||
onToggleExternalMcp={externalMcpToggle.setEnabled}
|
||||
showExternalMcpToggle={!isPeerSessionWindow}
|
||||
windowOpacity={settings.windowOpacity}
|
||||
setWindowOpacity={settings.setWindowOpacity}
|
||||
windowOpacity={windowOpacity}
|
||||
setWindowOpacity={setWindowOpacity}
|
||||
onSyncNow={handleSyncNowManual}
|
||||
onStartSessionDrag={setDraggingSessionId}
|
||||
onEndSessionDrag={handleEndSessionDrag}
|
||||
onReorderTabs={reorderWorkTabs}
|
||||
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||
dynamicTabTitleMode={settings.terminalSettings.dynamicTabTitleMode}
|
||||
onAppendHostToWorkspace={handleAppendHostToWorkspace}
|
||||
showSftpTab={showSftpTab}
|
||||
showHostTreeSidebar={showHostTreeSidebar}
|
||||
switchTabKeyBinding={keyBindings.find((binding) => binding.action === 'switchToTab') ?? null}
|
||||
dynamicTabTitleMode={dynamicTabTitleMode}
|
||||
editorTabs={editorTabs}
|
||||
pluginViewTabs={pluginViewTabs}
|
||||
onClosePluginViewTab={closePluginViewTab}
|
||||
@@ -319,7 +520,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
|
||||
<div className="flex-1 relative min-h-0">
|
||||
<AppHostTreeLayer
|
||||
enabled={settings.showHostTreeSidebar}
|
||||
enabled={showHostTreeSidebar}
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
groupConfigs={groupConfigs}
|
||||
@@ -328,9 +529,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
editorTabs={editorTabs}
|
||||
logViews={logViews}
|
||||
orderedTabs={orderedTabsWithEditors}
|
||||
accentMode={accentMode}
|
||||
currentTerminalTheme={currentTerminalTheme}
|
||||
customAccent={customAccent}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
hostById={hostById}
|
||||
themeById={themeById}
|
||||
@@ -365,7 +564,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onUpdateHosts={updateHosts}
|
||||
/>
|
||||
|
||||
<VaultViewContainer appThemeStyle={appThemeStyle}>
|
||||
<AppVaultThemeSurface VaultViewContainer={VaultViewContainer}>
|
||||
<VaultView
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
@@ -373,11 +572,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
proxyProfiles={proxyProfiles}
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
notes={notes}
|
||||
noteGroups={noteGroups}
|
||||
customGroups={customGroups}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
connectionLogs={connectionLogs}
|
||||
managedSources={managedSources}
|
||||
sessionCount={sessions.filter((s) => !s.hiddenFromTabs).length}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
@@ -401,26 +597,22 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onUpdateProxyProfiles={updateProxyProfiles}
|
||||
onUpdateSnippets={updateSnippets}
|
||||
onUpdateSnippetPackages={updateSnippetPackages}
|
||||
onUpdateNotes={updateNotes}
|
||||
onUpdateNoteGroups={updateNoteGroups}
|
||||
onUpdateCustomGroups={updateCustomGroups}
|
||||
onCommitPluginImporterData={commitPluginImporterData}
|
||||
onUpdateKnownHosts={updateKnownHosts}
|
||||
onUpdateManagedSources={updateManagedSources}
|
||||
onReadPersistedManagedSources={readPersistedManagedSources}
|
||||
onCommitVaultImportTransaction={commitVaultImportTransaction}
|
||||
onCommitVaultGroupMutation={commitVaultGroupMutation}
|
||||
onClearAndRemoveManagedSource={clearAndRemoveSource}
|
||||
onClearAndRemoveManagedSources={clearAndRemoveSources}
|
||||
onUnmanageSource={unmanageSource}
|
||||
onConvertKnownHost={convertKnownHostToHost}
|
||||
onToggleConnectionLogSaved={toggleConnectionLogSaved}
|
||||
onDeleteConnectionLog={deleteConnectionLog}
|
||||
onClearUnsavedConnectionLogs={clearUnsavedConnectionLogs}
|
||||
onRunSnippet={runSnippet}
|
||||
onOpenLogView={openLogView}
|
||||
showRecentHosts={settings.showRecentHosts}
|
||||
hostClickBehavior={settings.hostClickBehavior}
|
||||
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
|
||||
showRecentHosts={showRecentHosts}
|
||||
hostClickBehavior={hostClickBehavior}
|
||||
showOnlyUngroupedHostsInRoot={showOnlyUngroupedHostsInRoot}
|
||||
navigateToSection={navigateToSection}
|
||||
onNavigateToSectionHandled={() => setNavigateToSection(null)}
|
||||
deepLinkHostDraft={deepLinkHostDraft}
|
||||
@@ -429,7 +621,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onVaultFocusRequestHandled={() => setVaultFocusRequest(null)}
|
||||
terminalSettings={terminalSettings}
|
||||
/>
|
||||
</VaultViewContainer>
|
||||
</AppVaultThemeSurface>
|
||||
|
||||
<SftpViewMount
|
||||
hosts={terminalHosts}
|
||||
@@ -464,8 +656,6 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
identities={identities}
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
notes={notes}
|
||||
noteGroups={noteGroups}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
@@ -477,14 +667,12 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
clearThemeIntent={ctx.clearThemeIntent}
|
||||
settleManualThemeIntent={ctx.settleManualThemeIntent}
|
||||
resolveSessionAppearance={ctx.resolveSessionAppearance}
|
||||
accentMode={accentMode}
|
||||
customAccent={customAccent}
|
||||
terminalSettings={terminalSettings}
|
||||
terminalFontFamilyId={terminalFontFamilyId}
|
||||
fontSize={terminalFontSize}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
disableTerminalFontZoom={settings.disableTerminalFontZoom}
|
||||
restoreTerminalCwd={settings.restoreTerminalCwd}
|
||||
disableTerminalFontZoom={disableTerminalFontZoom}
|
||||
restoreTerminalCwd={restoreTerminalCwd}
|
||||
keyBindings={keyBindings}
|
||||
onHotkeyAction={handleHotkeyAction}
|
||||
onUpdateTerminalThemeId={handleDefaultTerminalThemeChange}
|
||||
@@ -508,6 +696,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||
onRequestAddToWorkspace={handleRequestAddToWorkspace}
|
||||
onAppendHostToWorkspace={handleAppendHostToWorkspace}
|
||||
onUpdateSplitSizes={updateSplitSizes}
|
||||
onSetDraggingSessionId={setDraggingSessionId}
|
||||
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
||||
@@ -529,16 +718,14 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
updateHosts={updateTerminalHosts}
|
||||
updateSnippets={updateSnippets}
|
||||
updateSnippetPackages={updateSnippetPackages}
|
||||
updateNotes={updateNotes}
|
||||
updateNoteGroups={updateNoteGroups}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
sftpShowHiddenFiles={sftpShowHiddenFiles}
|
||||
sftpUseCompressedUpload={sftpUseCompressedUpload}
|
||||
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
|
||||
terminalSidePanelAutoOpen={settings.terminalSidePanelAutoOpen}
|
||||
terminalSidePanelAutoOpenTab={settings.terminalSidePanelAutoOpenTab}
|
||||
terminalSidePanelAutoOpen={terminalSidePanelAutoOpen}
|
||||
terminalSidePanelAutoOpenTab={terminalSidePanelAutoOpenTab}
|
||||
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
|
||||
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
|
||||
editorWordWrap={editorWordWrap}
|
||||
@@ -548,7 +735,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
sessionLogsFormat={sessionLogsFormat}
|
||||
sessionLogsTimestampsEnabled={sessionLogsTimestampsEnabled}
|
||||
sshDebugLogsEnabled={sshDebugLogsEnabled}
|
||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||
showHostTreeSidebar={showHostTreeSidebar}
|
||||
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
||||
toggleSidePanelRef={toggleSidePanelRef}
|
||||
onStartSessionRename={startSessionRename}
|
||||
@@ -556,21 +743,18 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
||||
/>
|
||||
|
||||
{/* Log Views - readonly terminal replays */}
|
||||
{logViews.map(logView => {
|
||||
// Get the latest log data from connectionLogs to reflect updates
|
||||
const latestLog = connectionLogs.find(l => l.id === logView.connectionLogId) || logView.log;
|
||||
return (
|
||||
<LogViewWrapper
|
||||
key={logView.id}
|
||||
logView={{ ...logView, log: latestLog }}
|
||||
defaultTerminalTheme={currentTerminalTheme}
|
||||
defaultFontSize={terminalFontSize}
|
||||
onClose={() => closeLogView(logView.id)}
|
||||
onUpdateLog={updateConnectionLog}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* Log Views - readonly terminal replays. The latest log body comes
|
||||
from connectionLogsStore inside AppLogViewSurface. */}
|
||||
{logViews.map((logView: any) => (
|
||||
<AppLogViewSurface
|
||||
key={logView.id}
|
||||
LogViewWrapper={LogViewWrapper}
|
||||
logView={logView}
|
||||
defaultTerminalTheme={currentTerminalTheme}
|
||||
defaultFontSize={terminalFontSize}
|
||||
onClose={() => closeLogView(logView.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Editor Tabs — kept mounted for Monaco instance persistence; visibility toggled via CSS */}
|
||||
{editorTabs.map((tab) => (
|
||||
@@ -587,10 +771,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
</LazyLoadBoundary>
|
||||
))}
|
||||
|
||||
<AppPluginKeybindingHost
|
||||
locale={settings.uiLanguage}
|
||||
theme={resolvedTheme}
|
||||
themeTokens={pluginThemeTokens}
|
||||
<AppPluginKeybindingThemeHost
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
@@ -637,13 +818,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
<AddToWorkspaceDialog
|
||||
open
|
||||
onOpenChange={(open) => { if (!open) setAddToWorkspaceDialog(null); }}
|
||||
// Filter serial hosts only in append mode — appendHostToWorkspace
|
||||
// has no serial code path. Create mode goes through
|
||||
// createWorkspaceFromTargets, which builds a SerialConfig-backed
|
||||
// session for serial hosts, so those should remain pickable.
|
||||
hosts={addToWorkspaceDialog.mode === 'append'
|
||||
? hosts.filter((h) => h.protocol !== 'serial')
|
||||
: hosts}
|
||||
hosts={hosts}
|
||||
workspaceTitle={
|
||||
addToWorkspaceDialog.mode === 'append'
|
||||
? workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId)?.title
|
||||
@@ -662,7 +837,11 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
if (target.kind === 'local') {
|
||||
appendLocalTerminalToWorkspace(addToWorkspaceDialog.workspaceId, undefined, rootDir);
|
||||
} else {
|
||||
appendHostToWorkspace(addToWorkspaceDialog.workspaceId, target.host, rootDir);
|
||||
appendHostToWorkspace(
|
||||
addToWorkspaceDialog.workspaceId,
|
||||
resolveWorkspaceAppendHost(target.host),
|
||||
rootDir,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -681,7 +860,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
results={quickResults}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
showSftpTab={showSftpTab}
|
||||
onQueryChange={setQuickSearch}
|
||||
onSelect={handleHostConnectWithProtocolCheck}
|
||||
onEditHost={(host) => {
|
||||
@@ -715,59 +894,32 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
</LazyLoadBoundary>
|
||||
)}
|
||||
|
||||
<Dialog open={!!sessionRenameTarget} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
resetSessionRename();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('dialog.renameSession.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<Label htmlFor="session-name">{t('field.name')}</Label>
|
||||
<Input
|
||||
id="session-name"
|
||||
value={sessionRenameValue}
|
||||
onChange={(e) => setSessionRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submitSessionRename(); }}
|
||||
autoFocus
|
||||
placeholder={t('placeholder.sessionName')}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={resetSessionRename}>{t('common.cancel')}</Button>
|
||||
<Button onClick={submitSessionRename} disabled={!sessionRenameValue.trim()}>{t('common.save')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<RenameDraftDialog
|
||||
open={!!sessionRenameTarget}
|
||||
inputId="session-name"
|
||||
initialName={sessionRenameTarget ? (sessionRenameTarget.customName || sessionRenameTarget.hostLabel) : ''}
|
||||
title={t('dialog.renameSession.title')}
|
||||
nameLabel={t('field.name')}
|
||||
placeholder={t('placeholder.sessionName')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
saveLabel={t('common.save')}
|
||||
onCancel={resetSessionRename}
|
||||
onSave={handleSaveSessionRename}
|
||||
/>
|
||||
|
||||
<RenameDraftDialog
|
||||
open={!!workspaceRenameTarget}
|
||||
inputId="workspace-name"
|
||||
initialName={workspaceRenameTarget ? workspaceRenameTarget.title : ''}
|
||||
title={t('dialog.renameWorkspace.title')}
|
||||
nameLabel={t('field.name')}
|
||||
placeholder={t('placeholder.workspaceName')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
saveLabel={t('common.save')}
|
||||
onCancel={resetWorkspaceRename}
|
||||
onSave={handleSaveWorkspaceRename}
|
||||
/>
|
||||
|
||||
<Dialog open={!!workspaceRenameTarget} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
resetWorkspaceRename();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('dialog.renameWorkspace.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<Label htmlFor="workspace-name">{t('field.name')}</Label>
|
||||
<Input
|
||||
id="workspace-name"
|
||||
value={workspaceRenameValue}
|
||||
onChange={(e) => setWorkspaceRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submitWorkspaceRename(); }}
|
||||
autoFocus
|
||||
placeholder={t('placeholder.workspaceName')}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={resetWorkspaceRename}>{t('common.cancel')}</Button>
|
||||
<Button onClick={submitWorkspaceRename} disabled={!workspaceRenameValue.trim()}>{t('common.save')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{isCreateWorkspaceOpen && (
|
||||
<LazyLoadBoundary name="Create workspace" resetKey="create-workspace">
|
||||
@@ -871,8 +1023,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
)}
|
||||
</UnsavedChangesProvider>
|
||||
</SnippetExecutionProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('./AppView.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('workspace append resolves group defaults before creating host sessions', () => {
|
||||
assert.match(source, /resolveEffectiveTerminalHost\(\{/);
|
||||
assert.match(source, /groupConfigs,/);
|
||||
assert.match(source, /proxyProfiles,/);
|
||||
assert.match(
|
||||
source,
|
||||
/appendHostToWorkspace\(workspaceId, resolveWorkspaceAppendHost\(host\), rootDir\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/appendHostToWorkspace\([\s\S]*?resolveWorkspaceAppendHost\(target\.host\),[\s\S]*?rootDir/,
|
||||
);
|
||||
});
|
||||
@@ -78,6 +78,30 @@ test("editor tabs use the followed terminal theme when follow-app terminal theme
|
||||
assert.equal(resolved?.id, currentTheme.id);
|
||||
});
|
||||
|
||||
test("follow-app chrome applies custom accent onto the published base theme", () => {
|
||||
const editorTab = {
|
||||
id: "editor-1",
|
||||
hostId: "host-1",
|
||||
sessionId: "sftp-1",
|
||||
};
|
||||
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
accentMode: "custom",
|
||||
customAccent: "0 100% 50%",
|
||||
activeTabId: toEditorTabId(editorTab.id),
|
||||
editorTabs: [editorTab as unknown as EditorTab],
|
||||
followAppTerminalTheme: true,
|
||||
hostById: new Map([
|
||||
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
|
||||
]),
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, currentTheme.id);
|
||||
assert.notEqual(resolved?.colors.cursor, currentTheme.colors.cursor);
|
||||
assert.notEqual(resolved, currentTheme);
|
||||
});
|
||||
|
||||
test("log tabs use the saved log theme when available", () => {
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
|
||||
@@ -18,12 +18,12 @@ export type ResolveActiveChromeThemeInput = {
|
||||
customAccent: string;
|
||||
editorTabs: readonly EditorTabChrome[];
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: Map<string, Host>;
|
||||
hostById: ReadonlyMap<string, Host>;
|
||||
logViews: readonly LogView[];
|
||||
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
|
||||
sessionById: Map<string, TerminalSession>;
|
||||
themeById: Map<string, TerminalTheme>;
|
||||
workspaceById: Map<string, Workspace>;
|
||||
sessionById: ReadonlyMap<string, TerminalSession>;
|
||||
themeById: ReadonlyMap<string, TerminalTheme>;
|
||||
workspaceById: ReadonlyMap<string, Workspace>;
|
||||
};
|
||||
|
||||
export function isActiveChromeThemeResolvable({
|
||||
@@ -68,7 +68,9 @@ export function resolveActiveChromeTheme({
|
||||
};
|
||||
|
||||
const resolveHostTheme = (hostId: string): TerminalTheme => {
|
||||
if (followAppTerminalTheme) return currentTerminalTheme;
|
||||
if (followAppTerminalTheme) {
|
||||
return applyCustomAccentToTerminalTheme(currentTerminalTheme, accentMode, customAccent);
|
||||
}
|
||||
if (resolveSessionAppearance) {
|
||||
return resolveSessionAppearance(resolveHostScope(hostId)).theme;
|
||||
}
|
||||
@@ -90,12 +92,15 @@ export function resolveActiveChromeTheme({
|
||||
const logView = logViews.find((item) => item.id === activeTabId);
|
||||
if (logView) {
|
||||
const explicitThemeId = logView.log.themeId;
|
||||
return explicitThemeId ? themeById.get(explicitThemeId) ?? currentTerminalTheme : currentTerminalTheme;
|
||||
const base = explicitThemeId ? themeById.get(explicitThemeId) ?? currentTerminalTheme : currentTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(base, accentMode, customAccent);
|
||||
}
|
||||
|
||||
const workspace = workspaceById.get(activeTabId);
|
||||
if (workspace) {
|
||||
if (followAppTerminalTheme) return currentTerminalTheme;
|
||||
if (followAppTerminalTheme) {
|
||||
return applyCustomAccentToTerminalTheme(currentTerminalTheme, accentMode, customAccent);
|
||||
}
|
||||
|
||||
if (workspace.viewMode === "focus") {
|
||||
const focusedSession = resolveWorkspaceTargetSessionFromMap(workspace, sessionById);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Stable accessor bridge for App-local handlers that Host islands need when
|
||||
* assembling domain bags. `AppSideEffects` registers the live glue via
|
||||
* `useLayoutEffect`; Hosts call `getAppHandlers()` instead of receiving mega
|
||||
* props from App.
|
||||
*
|
||||
* Keep this intentionally loose (`Record<string, unknown>`): the handler set
|
||||
* tracks App glue, not a frozen public API.
|
||||
*/
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
export type AppHandlers = Record<string, unknown>;
|
||||
|
||||
function handlersShallowEqual(prev: AppHandlers, next: AppHandlers): boolean {
|
||||
const prevKeys = Object.keys(prev);
|
||||
const nextKeys = Object.keys(next);
|
||||
if (prevKeys.length !== nextKeys.length) return false;
|
||||
for (const key of nextKeys) {
|
||||
if (prev[key] !== next[key]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
class AppHandlersBridge {
|
||||
private handlers: AppHandlers | null = null;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
get = (): AppHandlers | null => this.handlers;
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
set(next: AppHandlers | null): void {
|
||||
if (this.handlers === next) return;
|
||||
if (
|
||||
this.handlers
|
||||
&& next
|
||||
&& handlersShallowEqual(this.handlers, next)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.handlers = next;
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
const bridge = new AppHandlersBridge();
|
||||
|
||||
export function registerAppHandlers(handlers: AppHandlers | null): void {
|
||||
bridge.set(handlers);
|
||||
}
|
||||
|
||||
export function getAppHandlers(): AppHandlers | null {
|
||||
return bridge.get();
|
||||
}
|
||||
|
||||
export function subscribeAppHandlers(listener: Listener): () => void {
|
||||
return bridge.subscribe(listener);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import type { Host, PortForwardingRule } from '../../domain/models';
|
||||
import type { VaultSection } from '../../components/VaultView';
|
||||
import type { KeyboardInteractiveRequest } from '../../components/KeyboardInteractiveModal';
|
||||
import type { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
/**
|
||||
* Dialog / queue / ephemeral UI owned outside the vault/session/settings
|
||||
* mega hooks. DialogsHost / VaultHost / TerminalHost subscribe here so
|
||||
* AppSideEffects never has to rebuild domain bags when a modal opens.
|
||||
*
|
||||
* `portForwardingRules` is published here as a thin derived slice: the PF
|
||||
* hook still lives in AppSideEffects (tray / sync / auto-start), but Hosts
|
||||
* must not receive a prepared terminal domain bag.
|
||||
*/
|
||||
export type AppLocalUiSnapshot = {
|
||||
isQuickSwitcherOpen: boolean;
|
||||
isCreateWorkspaceOpen: boolean;
|
||||
addToWorkspaceDialog:
|
||||
| { mode: 'append'; workspaceId: string }
|
||||
| { mode: 'create' }
|
||||
| null;
|
||||
quickSearch: string;
|
||||
protocolSelectHost: Host | null;
|
||||
navigateToSection: VaultSection | null;
|
||||
deepLinkHostDraft: Host | null;
|
||||
ephemeralHosts: readonly Host[];
|
||||
portForwardingRules: readonly PortForwardingRule[];
|
||||
keyboardInteractiveQueue: readonly KeyboardInteractiveRequest[];
|
||||
passphraseQueue: readonly PassphraseRequest[];
|
||||
deleteHostConfirm: { hostId: string; name: string } | null;
|
||||
vaultFocusRequest: unknown;
|
||||
openNoteRequest: unknown;
|
||||
emptyVaultConflict: unknown;
|
||||
};
|
||||
|
||||
export const EMPTY_APP_LOCAL_UI: AppLocalUiSnapshot = Object.freeze({
|
||||
isQuickSwitcherOpen: false,
|
||||
isCreateWorkspaceOpen: false,
|
||||
addToWorkspaceDialog: null,
|
||||
quickSearch: '',
|
||||
protocolSelectHost: null,
|
||||
navigateToSection: null,
|
||||
deepLinkHostDraft: null,
|
||||
ephemeralHosts: Object.freeze([]) as readonly Host[],
|
||||
portForwardingRules: Object.freeze([]) as readonly PortForwardingRule[],
|
||||
keyboardInteractiveQueue: Object.freeze([]) as readonly KeyboardInteractiveRequest[],
|
||||
passphraseQueue: Object.freeze([]) as readonly PassphraseRequest[],
|
||||
deleteHostConfirm: null,
|
||||
vaultFocusRequest: null,
|
||||
openNoteRequest: null,
|
||||
emptyVaultConflict: null,
|
||||
});
|
||||
class AppLocalUiStore {
|
||||
private snapshot: AppLocalUiSnapshot = EMPTY_APP_LOCAL_UI;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getSnapshot = (): AppLocalUiSnapshot => this.snapshot;
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
setSnapshot(next: AppLocalUiSnapshot): void {
|
||||
const prev = this.snapshot;
|
||||
if (
|
||||
prev.isQuickSwitcherOpen === next.isQuickSwitcherOpen
|
||||
&& prev.isCreateWorkspaceOpen === next.isCreateWorkspaceOpen
|
||||
&& prev.addToWorkspaceDialog === next.addToWorkspaceDialog
|
||||
&& prev.quickSearch === next.quickSearch
|
||||
&& prev.protocolSelectHost === next.protocolSelectHost
|
||||
&& prev.navigateToSection === next.navigateToSection
|
||||
&& prev.deepLinkHostDraft === next.deepLinkHostDraft
|
||||
&& prev.ephemeralHosts === next.ephemeralHosts
|
||||
&& prev.portForwardingRules === next.portForwardingRules
|
||||
&& prev.keyboardInteractiveQueue === next.keyboardInteractiveQueue
|
||||
&& prev.passphraseQueue === next.passphraseQueue
|
||||
&& prev.deleteHostConfirm === next.deleteHostConfirm
|
||||
&& prev.vaultFocusRequest === next.vaultFocusRequest
|
||||
&& prev.openNoteRequest === next.openNoteRequest
|
||||
&& prev.emptyVaultConflict === next.emptyVaultConflict
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.snapshot = next;
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
export const appLocalUiStore = new AppLocalUiStore();
|
||||
|
||||
export function publishAppLocalUi(snapshot: AppLocalUiSnapshot): void {
|
||||
appLocalUiStore.setSnapshot(snapshot);
|
||||
}
|
||||
|
||||
export function getAppLocalUiSnapshot(): AppLocalUiSnapshot {
|
||||
return appLocalUiStore.getSnapshot();
|
||||
}
|
||||
|
||||
export function subscribeAppLocalUi(listener: Listener): () => void {
|
||||
return appLocalUiStore.subscribe(listener);
|
||||
}
|
||||
|
||||
export function useAppLocalUiStore(): AppLocalUiSnapshot {
|
||||
return useSyncExternalStore(
|
||||
subscribeAppLocalUi,
|
||||
getAppLocalUiSnapshot,
|
||||
getAppLocalUiSnapshot,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import type { KnownHost } from '../../types';
|
||||
import type { AppActiveTabChromeProps } from './AppActiveTabChrome';
|
||||
import { appViewDomainsEqual, type AppViewDomains } from './appViewDomains';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
export type AppShellOverlays = {
|
||||
onAddKnownHost: (knownHost: KnownHost) => void;
|
||||
deleteHostConfirm: { hostId: string; name: string } | null;
|
||||
onCancelDeleteHost: () => void;
|
||||
onConfirmDeleteHost: () => void;
|
||||
};
|
||||
|
||||
export type AppShellPropsSnapshot = {
|
||||
domains: AppViewDomains | null;
|
||||
chrome: AppActiveTabChromeProps | null;
|
||||
overlays: AppShellOverlays | null;
|
||||
};
|
||||
|
||||
const EMPTY: AppShellPropsSnapshot = Object.freeze({
|
||||
domains: null,
|
||||
chrome: null,
|
||||
overlays: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Host islands publish domain / chrome / overlay bags here. `AppShell`
|
||||
* subscribes via `useSyncExternalStore` and only re-renders when bag
|
||||
* identities change (`appViewDomainsEqual` + chrome/overlays identity).
|
||||
*/
|
||||
class AppShellPropsStore {
|
||||
private snapshot: AppShellPropsSnapshot = EMPTY;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getSnapshot = (): AppShellPropsSnapshot => this.snapshot;
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
setDomains(domains: AppViewDomains): void {
|
||||
const prev = this.snapshot;
|
||||
if (prev.domains && appViewDomainsEqual(prev.domains, domains)) {
|
||||
return;
|
||||
}
|
||||
this.snapshot = { ...prev, domains };
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
|
||||
setChrome(chrome: AppActiveTabChromeProps): void {
|
||||
if (this.snapshot.chrome === chrome) return;
|
||||
this.snapshot = { ...this.snapshot, chrome };
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
|
||||
setOverlays(overlays: AppShellOverlays): void {
|
||||
if (this.snapshot.overlays === overlays) return;
|
||||
this.snapshot = { ...this.snapshot, overlays };
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
|
||||
setDomainSlice<K extends keyof AppViewDomains>(
|
||||
key: K,
|
||||
slice: AppViewDomains[K],
|
||||
): void {
|
||||
const prev = this.snapshot.domains;
|
||||
if (prev && prev[key] === slice) return;
|
||||
const domains = {
|
||||
vault: prev?.vault ?? {},
|
||||
terminal: prev?.terminal ?? {},
|
||||
chrome: prev?.chrome ?? {},
|
||||
dialogs: prev?.dialogs ?? {},
|
||||
mounts: prev?.mounts ?? {},
|
||||
[key]: slice,
|
||||
} as AppViewDomains;
|
||||
this.setDomains(domains);
|
||||
}
|
||||
}
|
||||
|
||||
export const appShellPropsStore = new AppShellPropsStore();
|
||||
|
||||
export function publishAppShellDomains(domains: AppViewDomains): void {
|
||||
appShellPropsStore.setDomains(domains);
|
||||
}
|
||||
|
||||
export function publishAppShellDomainSlice<K extends keyof AppViewDomains>(
|
||||
key: K,
|
||||
slice: AppViewDomains[K],
|
||||
): void {
|
||||
appShellPropsStore.setDomainSlice(key, slice);
|
||||
}
|
||||
|
||||
export function publishAppShellChrome(chrome: AppActiveTabChromeProps): void {
|
||||
appShellPropsStore.setChrome(chrome);
|
||||
}
|
||||
|
||||
export function publishAppShellOverlays(overlays: AppShellOverlays): void {
|
||||
appShellPropsStore.setOverlays(overlays);
|
||||
}
|
||||
|
||||
export function getAppShellPropsSnapshot(): AppShellPropsSnapshot {
|
||||
return appShellPropsStore.getSnapshot();
|
||||
}
|
||||
|
||||
export function subscribeAppShellProps(listener: Listener): () => void {
|
||||
return appShellPropsStore.subscribe(listener);
|
||||
}
|
||||
|
||||
export function useAppShellProps(): AppShellPropsSnapshot {
|
||||
return useSyncExternalStore(
|
||||
subscribeAppShellProps,
|
||||
getAppShellPropsSnapshot,
|
||||
getAppShellPropsSnapshot,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
|
||||
import { TERMINAL_THEMES } from '../../../infrastructure/config/terminalThemes';
|
||||
import { retainStableSessionsIgnoringPresentation } from '../../../domain/terminalPaneSessionsEqual';
|
||||
import { useI18n } from '../../i18n/I18nProvider';
|
||||
import { useCustomThemes } from '../../state/customThemeStore';
|
||||
import { useEditorTabChromeList } from '../../state/editorTabStore';
|
||||
import { toEditorTabId } from '../../state/activeTabStore';
|
||||
import {
|
||||
getSessionSnapshotActions,
|
||||
useSessionSnapshot,
|
||||
useSessionSnapshotActions,
|
||||
} from '../../state/sessionSnapshotStore';
|
||||
import { useSettingsChromeStore } from '../../state/settingsChromeStore';
|
||||
import { useVaultSnapshot } from '../../state/vaultSnapshotStore';
|
||||
import {
|
||||
getTerminalSettingsActions,
|
||||
useTerminalSettingsStore,
|
||||
} from '../../state/terminalSettingsStore';
|
||||
import { usePluginViewTabs } from '../../state/pluginViewTabStore';
|
||||
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
|
||||
import {
|
||||
publishAppShellChrome,
|
||||
publishAppShellDomainSlice,
|
||||
} from '../appShellPropsStore';
|
||||
import {
|
||||
getThemeRuntimeActions,
|
||||
subscribeThemeRuntimeActions,
|
||||
} from '../themeRuntimeBridge';
|
||||
|
||||
const IS_MAC_CLIENT =
|
||||
typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
|
||||
|
||||
/**
|
||||
* Chrome island: TopTabs / active-tab chrome from settings chrome store,
|
||||
* appearance chrome, and selective session/vault snapshot fields. Assembles
|
||||
* chrome + shell chrome bags field-by-field — never spreads a prepared bag.
|
||||
*/
|
||||
export function ChromeHost() {
|
||||
const { t } = useI18n();
|
||||
const settingsChrome = useSettingsChromeStore();
|
||||
const session = useSessionSnapshot();
|
||||
const sessionActions = useSessionSnapshotActions();
|
||||
const vault = useVaultSnapshot();
|
||||
const terminalSettings = useTerminalSettingsStore();
|
||||
const editorTabs = useEditorTabChromeList();
|
||||
const pluginViewTabs = usePluginViewTabs();
|
||||
void pluginViewTabs;
|
||||
const customThemes = useCustomThemes();
|
||||
const handlers = useSyncExternalStore(
|
||||
subscribeAppHandlers,
|
||||
getAppHandlers,
|
||||
getAppHandlers,
|
||||
);
|
||||
const themeRuntime = useSyncExternalStore(
|
||||
subscribeThemeRuntimeActions,
|
||||
getThemeRuntimeActions,
|
||||
getThemeRuntimeActions,
|
||||
);
|
||||
|
||||
const orphanSessionsForShellRef = useRef(session.orphanSessions);
|
||||
const orphanSessionsForShell = retainStableSessionsIgnoringPresentation(
|
||||
orphanSessionsForShellRef.current,
|
||||
session.orphanSessions as never,
|
||||
);
|
||||
orphanSessionsForShellRef.current = orphanSessionsForShell as typeof session.orphanSessions;
|
||||
|
||||
const themeById = useMemo(
|
||||
() => new Map([...customThemes, ...TERMINAL_THEMES].map((theme) => [theme.id, theme])),
|
||||
[customThemes],
|
||||
);
|
||||
|
||||
const hostById = useMemo(
|
||||
() => new Map(vault.hosts.map((host) => [host.id, host])),
|
||||
[vault.hosts],
|
||||
);
|
||||
|
||||
const sessionById = useMemo(
|
||||
() => new Map(session.sessions.map((s) => [s.id, s])),
|
||||
[session.sessions],
|
||||
);
|
||||
|
||||
const workspaceById = useMemo(
|
||||
() => new Map(session.workspaces.map((workspace) => [workspace.id, workspace])),
|
||||
[session.workspaces],
|
||||
);
|
||||
|
||||
const editorTabTopIds = useMemo(
|
||||
() => editorTabs.map((tab) => toEditorTabId(tab.id)),
|
||||
[editorTabs],
|
||||
);
|
||||
const pluginViewTabIds = useMemo(
|
||||
() => pluginViewTabs.map((tab) => tab.id),
|
||||
[pluginViewTabs],
|
||||
);
|
||||
const additionalWorkTabIds = useMemo(
|
||||
() => [...editorTabTopIds, ...pluginViewTabIds],
|
||||
[editorTabTopIds, pluginViewTabIds],
|
||||
);
|
||||
|
||||
const orderedTabsWithEditors = useMemo(
|
||||
() => sessionActions?.getOrderedWorkTabs(additionalWorkTabIds) ?? ['vault'],
|
||||
[additionalWorkTabIds, sessionActions],
|
||||
);
|
||||
|
||||
const reorderWorkTabs = useCallback((
|
||||
draggedId: string,
|
||||
targetId: string,
|
||||
position: 'before' | 'after' = 'before',
|
||||
) => {
|
||||
sessionActions?.reorderTabs(draggedId, targetId, position, additionalWorkTabIds);
|
||||
}, [additionalWorkTabIds, sessionActions]);
|
||||
|
||||
const chromeDomain = useMemo(() => {
|
||||
if (!handlers) return null;
|
||||
return {
|
||||
closeLogView: sessionActions?.closeLogView,
|
||||
handleEndSessionDrag: handlers.handleEndSessionDrag,
|
||||
handleOpenQuickSwitcher: handlers.handleOpenQuickSwitcher,
|
||||
handleOpenSettings: handlers.handleOpenSettings,
|
||||
handleRootContextMenu: handlers.handleRootContextMenu,
|
||||
handleSyncNowManual: handlers.handleSyncNowManual,
|
||||
isMacClient: IS_MAC_CLIENT,
|
||||
logViews: session.logViews,
|
||||
openLogView: sessionActions?.openLogView,
|
||||
orderedTabsWithEditors,
|
||||
orphanSessions: orphanSessionsForShell,
|
||||
reorderWorkTabs,
|
||||
resetSessionRename: sessionActions?.resetSessionRename,
|
||||
resetWorkspaceRename: sessionActions?.resetWorkspaceRename,
|
||||
sessionRenameTarget: session.sessionRenameTarget,
|
||||
setActiveTabId: sessionActions?.setActiveTabId,
|
||||
startSessionRename: sessionActions?.startSessionRename,
|
||||
renameSessionInline: sessionActions?.renameSessionInline,
|
||||
startWorkspaceRename: sessionActions?.startWorkspaceRename,
|
||||
submitSessionRename: sessionActions?.submitSessionRename,
|
||||
submitWorkspaceRename: sessionActions?.submitWorkspaceRename,
|
||||
t,
|
||||
themeById,
|
||||
workspaceRenameTarget: session.workspaceRenameTarget,
|
||||
};
|
||||
}, [
|
||||
handlers,
|
||||
orderedTabsWithEditors,
|
||||
orphanSessionsForShell,
|
||||
reorderWorkTabs,
|
||||
session.logViews,
|
||||
session.sessionRenameTarget,
|
||||
session.workspaceRenameTarget,
|
||||
sessionActions,
|
||||
t,
|
||||
themeById,
|
||||
]);
|
||||
|
||||
// Call-time getters so chrome can publish before Publisher layout effects
|
||||
// register action slots — avoids undefined applyAppTheme/setActiveTabId on
|
||||
// the first Host publish (startup TypeError under StrictMode).
|
||||
const setActiveTabId = useCallback((id: string) => {
|
||||
getSessionSnapshotActions()?.setActiveTabId?.(id);
|
||||
}, []);
|
||||
const applyAppTheme = useCallback(() => {
|
||||
getTerminalSettingsActions()?.applyAppTheme?.();
|
||||
}, []);
|
||||
|
||||
const appShellChrome = useMemo(() => {
|
||||
if (!handlers) return null;
|
||||
// Theme runtime actions may still be null on the first paint before
|
||||
// TerminalHost registers them; wait so AppActiveTabChrome never mounts
|
||||
// with a missing resolveSessionAppearance / currentTerminalTheme.
|
||||
if (!themeRuntime?.currentTerminalTheme || !themeRuntime?.resolveFocusedAppearance) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
showSftpTab: settingsChrome.showSftpTab,
|
||||
setActiveTabId,
|
||||
applyAppTheme,
|
||||
hostById,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
currentTerminalTheme: themeRuntime.currentTerminalTheme,
|
||||
followAppTerminalTheme: terminalSettings.followAppTerminalTheme,
|
||||
editorTabs,
|
||||
logViews: session.logViews,
|
||||
resolveSessionAppearance: themeRuntime.resolveFocusedAppearance,
|
||||
t,
|
||||
};
|
||||
}, [
|
||||
applyAppTheme,
|
||||
editorTabs,
|
||||
handlers,
|
||||
hostById,
|
||||
session.logViews,
|
||||
sessionById,
|
||||
setActiveTabId,
|
||||
settingsChrome.showSftpTab,
|
||||
t,
|
||||
terminalSettings.followAppTerminalTheme,
|
||||
themeById,
|
||||
themeRuntime,
|
||||
workspaceById,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (chromeDomain) publishAppShellDomainSlice('chrome', chromeDomain);
|
||||
if (appShellChrome) publishAppShellChrome(appShellChrome as never);
|
||||
}, [appShellChrome, chromeDomain]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useLayoutEffect, useMemo, useSyncExternalStore } from 'react';
|
||||
|
||||
import { getHostSearchMatch } from '../../../lib/searchMatcher';
|
||||
import type { Host } from '../../../types';
|
||||
import { useEditorTabChromeList } from '../../state/editorTabStore';
|
||||
import { useVaultSnapshot } from '../../state/vaultSnapshotStore';
|
||||
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
|
||||
import {
|
||||
publishAppShellDomainSlice,
|
||||
publishAppShellOverlays,
|
||||
} from '../appShellPropsStore';
|
||||
import { useAppLocalUiStore } from '../appLocalUiStore';
|
||||
|
||||
const EMPTY_HOST_RESULTS: Host[] = [];
|
||||
|
||||
/**
|
||||
* Dialogs island: local dialog/queue state from `appLocalUiStore`, plus a
|
||||
* selective vault hosts subscription for quick-search results when open.
|
||||
* Assembles dialogs + overlays field-by-field — never spreads a prepared bag.
|
||||
*/
|
||||
export function DialogsHost() {
|
||||
const local = useAppLocalUiStore();
|
||||
const vault = useVaultSnapshot();
|
||||
const editorTabs = useEditorTabChromeList();
|
||||
const handlers = useSyncExternalStore(
|
||||
subscribeAppHandlers,
|
||||
getAppHandlers,
|
||||
getAppHandlers,
|
||||
);
|
||||
|
||||
const quickResults = useMemo(() => {
|
||||
if (!local.isQuickSwitcherOpen) return EMPTY_HOST_RESULTS;
|
||||
const term = local.quickSearch.trim();
|
||||
if (!term) return vault.hosts as Host[];
|
||||
return (vault.hosts as Host[])
|
||||
.map((host) => ({ host, match: getHostSearchMatch(term, host) }))
|
||||
.filter((entry) => entry.match.matched)
|
||||
.sort((left, right) => {
|
||||
if (left.match.score !== right.match.score) {
|
||||
return right.match.score - left.match.score;
|
||||
}
|
||||
return left.host.label.localeCompare(right.host.label);
|
||||
})
|
||||
.map((entry) => entry.host);
|
||||
}, [local.isQuickSwitcherOpen, local.quickSearch, vault.hosts]);
|
||||
|
||||
const dialogsDomain = useMemo(() => {
|
||||
if (!handlers) return null;
|
||||
return {
|
||||
addToWorkspaceDialog: local.addToWorkspaceDialog,
|
||||
clearAndRemoveSource: handlers.clearAndRemoveSource,
|
||||
clearAndRemoveSources: handlers.clearAndRemoveSources,
|
||||
editorTabs,
|
||||
emptyVaultConflict: local.emptyVaultConflict,
|
||||
handleHostConnectWithProtocolCheck: handlers.handleHostConnectWithProtocolCheck,
|
||||
handleKeyboardInteractiveCancel: handlers.handleKeyboardInteractiveCancel,
|
||||
handleKeyboardInteractiveSubmit: handlers.handleKeyboardInteractiveSubmit,
|
||||
handlePassphraseCancel: handlers.handlePassphraseCancel,
|
||||
handlePassphraseSkip: handlers.handlePassphraseSkip,
|
||||
handlePassphraseSubmit: handlers.handlePassphraseSubmit,
|
||||
handleProtocolSelect: handlers.handleProtocolSelect,
|
||||
handleRequestCloseEditorTabRef: handlers.handleRequestCloseEditorTabRef,
|
||||
isCreateWorkspaceOpen: local.isCreateWorkspaceOpen,
|
||||
isQuickSwitcherOpen: local.isQuickSwitcherOpen,
|
||||
keyboardInteractiveQueue: local.keyboardInteractiveQueue,
|
||||
passphraseQueue: local.passphraseQueue,
|
||||
protocolSelectHost: local.protocolSelectHost,
|
||||
quickResults,
|
||||
quickSearch: local.quickSearch,
|
||||
resolveEmptyVaultConflict: handlers.resolveEmptyVaultConflict,
|
||||
setAddToWorkspaceDialog: handlers.setAddToWorkspaceDialog,
|
||||
setIsCreateWorkspaceOpen: handlers.setIsCreateWorkspaceOpen,
|
||||
setIsQuickSwitcherOpen: handlers.setIsQuickSwitcherOpen,
|
||||
setProtocolSelectHost: handlers.setProtocolSelectHost,
|
||||
setQuickSearch: handlers.setQuickSearch,
|
||||
};
|
||||
}, [
|
||||
editorTabs,
|
||||
handlers,
|
||||
local.addToWorkspaceDialog,
|
||||
local.emptyVaultConflict,
|
||||
local.isCreateWorkspaceOpen,
|
||||
local.isQuickSwitcherOpen,
|
||||
local.keyboardInteractiveQueue,
|
||||
local.passphraseQueue,
|
||||
local.protocolSelectHost,
|
||||
local.quickSearch,
|
||||
quickResults,
|
||||
]);
|
||||
|
||||
const overlays = useMemo(() => {
|
||||
if (!handlers) return null;
|
||||
return {
|
||||
onAddKnownHost: handlers.handleAddKnownHost as (knownHost: never) => void,
|
||||
deleteHostConfirm: local.deleteHostConfirm,
|
||||
onCancelDeleteHost: handlers.handleCancelDeleteHost as () => void,
|
||||
onConfirmDeleteHost: handlers.handleConfirmDeleteHost as () => void,
|
||||
};
|
||||
}, [handlers, local.deleteHostConfirm]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (dialogsDomain) publishAppShellDomainSlice('dialogs', dialogsDomain);
|
||||
if (overlays) publishAppShellOverlays(overlays as never);
|
||||
}, [dialogsDomain, overlays]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
|
||||
import { TERMINAL_THEME_AUTO } from '../../../domain/terminalAppearance';
|
||||
import { retainStableSessionsIgnoringPresentation } from '../../../domain/terminalPaneSessionsEqual';
|
||||
import { getAppSettingsRuntime } from '../../state/appRuntimeBridge';
|
||||
import { useAppearanceChromeStore } from '../../state/appearanceChromeStore';
|
||||
import { useCustomThemes } from '../../state/customThemeStore';
|
||||
import {
|
||||
useSessionSnapshot,
|
||||
useSessionSnapshotActions,
|
||||
} from '../../state/sessionSnapshotStore';
|
||||
import {
|
||||
useSettingsChromeActions,
|
||||
useSettingsChromeStore,
|
||||
} from '../../state/settingsChromeStore';
|
||||
import {
|
||||
useTerminalSettingsActions,
|
||||
useTerminalSettingsStore,
|
||||
} from '../../state/terminalSettingsStore';
|
||||
import { useThemeRuntime, useTerminalAppearanceInjection } from '../../state/useThemeRuntime';
|
||||
import {
|
||||
useVaultSnapshot,
|
||||
useVaultSnapshotActions,
|
||||
} from '../../state/vaultSnapshotStore';
|
||||
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
|
||||
import { publishAppShellDomainSlice } from '../appShellPropsStore';
|
||||
import { useAppLocalUiStore } from '../appLocalUiStore';
|
||||
import { registerThemeRuntimeActions } from '../themeRuntimeBridge';
|
||||
|
||||
/**
|
||||
* Terminal island: sessions from `sessionSnapshotStore`, terminal settings
|
||||
* from `terminalSettingsStore`, mutators via snapshot actions, theme runtime
|
||||
* owned here, glue handlers from the app handlers bridge. Assembles the full
|
||||
* terminal domain bag field-by-field — never spreads a prepared bag.
|
||||
*/
|
||||
export function TerminalHost() {
|
||||
const session = useSessionSnapshot();
|
||||
const sessionActions = useSessionSnapshotActions();
|
||||
const vault = useVaultSnapshot();
|
||||
const vaultActions = useVaultSnapshotActions();
|
||||
const terminalSettings = useTerminalSettingsStore();
|
||||
const terminalSettingsActions = useTerminalSettingsActions();
|
||||
const {
|
||||
followAppTerminalTheme,
|
||||
terminalThemeId,
|
||||
terminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
} = terminalSettings;
|
||||
const settingsChrome = useSettingsChromeStore();
|
||||
const settingsChromeActions = useSettingsChromeActions();
|
||||
const appearance = useAppearanceChromeStore();
|
||||
const customThemes = useCustomThemes();
|
||||
const local = useAppLocalUiStore();
|
||||
const handlers = useSyncExternalStore(
|
||||
subscribeAppHandlers,
|
||||
getAppHandlers,
|
||||
getAppHandlers,
|
||||
);
|
||||
|
||||
// Call-time getters: SettingsPublisher registers the runtime in a layout
|
||||
// effect after this Host's first render. Capturing noop setters into
|
||||
// useThemeRuntime would permanently drop follow-app UI theme persistence.
|
||||
const setLightUiThemeId = useCallback((id: string) => {
|
||||
getAppSettingsRuntime()?.setLightUiThemeId?.(id);
|
||||
}, []);
|
||||
const setDarkUiThemeId = useCallback((id: string) => {
|
||||
getAppSettingsRuntime()?.setDarkUiThemeId?.(id);
|
||||
}, []);
|
||||
|
||||
const themeRuntime = useThemeRuntime({
|
||||
terminalThemeId,
|
||||
terminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
followAppTerminalTheme,
|
||||
resolvedTheme: settingsChrome.resolvedTheme,
|
||||
lightUiThemeId: settingsChrome.lightUiThemeId,
|
||||
darkUiThemeId: settingsChrome.darkUiThemeId,
|
||||
accentMode: appearance.accentMode,
|
||||
customAccent: appearance.customAccent,
|
||||
customThemes,
|
||||
setTheme: settingsChromeActions.setTheme,
|
||||
setLightUiThemeId,
|
||||
setDarkUiThemeId,
|
||||
});
|
||||
|
||||
const {
|
||||
globalAppearance,
|
||||
accentedGlobalAppearance,
|
||||
clearIntent: clearThemeIntent,
|
||||
settleManualIntent: settleManualThemeIntent,
|
||||
pickTheme: pickTerminalTheme,
|
||||
resolveFocusedAppearance,
|
||||
currentTerminalTheme,
|
||||
} = themeRuntime;
|
||||
|
||||
// Inject live accent into CSS vars without publishing accented theme identity
|
||||
// into the terminal domain bag (accent drag must not rebuild AppShell).
|
||||
useTerminalAppearanceInjection(accentedGlobalAppearance, {
|
||||
includeChromeSurfaces: followAppTerminalTheme,
|
||||
});
|
||||
|
||||
const prevFollowAppTerminalThemeRef = useRef(followAppTerminalTheme);
|
||||
useLayoutEffect(() => {
|
||||
if (prevFollowAppTerminalThemeRef.current === followAppTerminalTheme) return;
|
||||
prevFollowAppTerminalThemeRef.current = followAppTerminalTheme;
|
||||
clearThemeIntent();
|
||||
}, [followAppTerminalTheme, clearThemeIntent]);
|
||||
|
||||
// Bridge exposes the stable base theme only — ChromeHost must not republish
|
||||
// when accentedGlobalAppearance identity churns during color-picker drag.
|
||||
const themeBridgeActions = useMemo(() => ({
|
||||
clearThemeIntent,
|
||||
settleManualThemeIntent,
|
||||
pickTerminalTheme,
|
||||
resolveFocusedAppearance: resolveFocusedAppearance as (...args: never[]) => unknown,
|
||||
currentTerminalTheme,
|
||||
globalAppearance,
|
||||
}), [
|
||||
clearThemeIntent,
|
||||
currentTerminalTheme,
|
||||
globalAppearance,
|
||||
pickTerminalTheme,
|
||||
resolveFocusedAppearance,
|
||||
settleManualThemeIntent,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
registerThemeRuntimeActions(themeBridgeActions);
|
||||
return () => {
|
||||
registerThemeRuntimeActions(null);
|
||||
};
|
||||
}, [themeBridgeActions]);
|
||||
|
||||
const sessionsForShellRef = useRef(session.sessions);
|
||||
const sessionsForShell = retainStableSessionsIgnoringPresentation(
|
||||
sessionsForShellRef.current,
|
||||
session.sessions as never,
|
||||
);
|
||||
sessionsForShellRef.current = sessionsForShell as typeof session.sessions;
|
||||
|
||||
const hostById = useMemo(
|
||||
() => new Map(vault.hosts.map((host) => [host.id, host])),
|
||||
[vault.hosts],
|
||||
);
|
||||
|
||||
const terminalHosts = useMemo(
|
||||
() => (
|
||||
local.ephemeralHosts.length > 0
|
||||
? [...vault.hosts, ...local.ephemeralHosts]
|
||||
: vault.hosts
|
||||
),
|
||||
[local.ephemeralHosts, vault.hosts],
|
||||
);
|
||||
|
||||
const handleDefaultTerminalThemeChange = useCallback((themeId: string) => {
|
||||
// Persist the default theme for ephemeral/manual hosts. Mode overrides
|
||||
// reset to auto so the chosen theme becomes the new baseline for the
|
||||
// current resolved UI mode (same behavior as pre-Host App).
|
||||
terminalSettingsActions?.setTerminalThemeId(themeId);
|
||||
if (settingsChrome.resolvedTheme === 'dark') {
|
||||
terminalSettingsActions?.setTerminalThemeDarkId(TERMINAL_THEME_AUTO);
|
||||
} else {
|
||||
terminalSettingsActions?.setTerminalThemeLightId(TERMINAL_THEME_AUTO);
|
||||
}
|
||||
}, [settingsChrome.resolvedTheme, terminalSettingsActions]);
|
||||
|
||||
const handleFollowAppTerminalThemeChange = useCallback((themeId: string) => {
|
||||
pickTerminalTheme(themeId);
|
||||
}, [pickTerminalTheme]);
|
||||
|
||||
const terminalDomain = useMemo(() => {
|
||||
if (!handlers) return null;
|
||||
return {
|
||||
addSessionToWorkspace: sessionActions?.addSessionToWorkspace,
|
||||
appendHostToWorkspace: sessionActions?.appendHostToWorkspace,
|
||||
appendLocalTerminalToWorkspace: sessionActions?.appendLocalTerminalToWorkspace,
|
||||
clearSessionFontSizeOverride: sessionActions?.clearSessionFontSizeOverride,
|
||||
closeSession: sessionActions?.closeSession,
|
||||
closeTabsBatch: handlers.closeTabsBatch,
|
||||
copySessionWithCurrentShell: handlers.copySessionWithCurrentShell,
|
||||
copyWorkspaceWithCurrentShell: handlers.copyWorkspaceWithCurrentShell,
|
||||
copySessionToNewWindowWithCurrentShell: handlers.copySessionToNewWindowWithCurrentShell,
|
||||
closeWorkspace: sessionActions?.closeWorkspace,
|
||||
createWorkspaceFromSessions: sessionActions?.createWorkspaceFromSessions,
|
||||
createWorkspaceFromTargets: handlers.createWorkspaceFromTargets,
|
||||
createWorkspaceWithHosts: handlers.createWorkspaceWithHosts,
|
||||
currentTerminalTheme,
|
||||
draggingSessionId: session.draggingSessionId,
|
||||
editorWordWrap: terminalSettings.editorWordWrap,
|
||||
followAppTerminalTheme: terminalSettings.followAppTerminalTheme,
|
||||
clearThemeIntent,
|
||||
settleManualThemeIntent,
|
||||
pickTerminalTheme,
|
||||
resolveSessionAppearance: resolveFocusedAppearance,
|
||||
handleConnectSerial: handlers.handleConnectSerial,
|
||||
handleConnectToHost: handlers.handleConnectToHost,
|
||||
handleCreateLocalTerminal: handlers.handleCreateLocalTerminal,
|
||||
handleDefaultTerminalThemeChange,
|
||||
handleFollowAppTerminalThemeChange,
|
||||
handleHotkeyAction: handlers.handleHotkeyAction,
|
||||
handleSessionStatusChange: handlers.handleSessionStatusChange,
|
||||
handleTerminalDataCapture: handlers.handleTerminalDataCapture,
|
||||
handleUpdateHostFromTerminal: handlers.handleUpdateHostFromTerminal,
|
||||
hostById,
|
||||
terminalHosts,
|
||||
updateTerminalHosts: handlers.updateTerminalHosts,
|
||||
hotkeyScheme: terminalSettings.hotkeyScheme,
|
||||
isBroadcastEnabled: sessionActions?.isBroadcastEnabled,
|
||||
keyBindings: terminalSettings.keyBindings,
|
||||
openNoteRequest: local.openNoteRequest,
|
||||
portForwardingRules: local.portForwardingRules,
|
||||
removeSessionFromWorkspace: sessionActions?.removeSessionFromWorkspace,
|
||||
reorderWorkspaceSessions: sessionActions?.reorderWorkspaceSessions,
|
||||
runSnippet: handlers.runSnippet,
|
||||
sessionLogsDir: terminalSettings.sessionLogsDir,
|
||||
sessionLogsEnabled: terminalSettings.sessionLogsEnabled,
|
||||
sessionLogsFormat: terminalSettings.sessionLogsFormat,
|
||||
sessionLogsTimestampsEnabled: terminalSettings.sessionLogsTimestampsEnabled,
|
||||
sessions: sessionsForShell,
|
||||
setDraggingSessionId: sessionActions?.setDraggingSessionId,
|
||||
setEditorWordWrap: terminalSettingsActions?.setEditorWordWrap,
|
||||
setTerminalFontFamilyId: terminalSettingsActions?.setTerminalFontFamilyId,
|
||||
setTerminalFontSize: terminalSettingsActions?.setTerminalFontSize,
|
||||
setWorkspaceFocusedSession: sessionActions?.setWorkspaceFocusedSession,
|
||||
sftpAutoOpenSidebar: terminalSettings.sftpAutoOpenSidebar,
|
||||
sftpFollowTerminalCwd: terminalSettings.sftpFollowTerminalCwd,
|
||||
setSftpFollowTerminalCwd: terminalSettingsActions?.setSftpFollowTerminalCwd,
|
||||
sftpAutoSync: terminalSettings.sftpAutoSync,
|
||||
sftpDefaultViewMode: terminalSettings.sftpDefaultViewMode,
|
||||
sftpDoubleClickBehavior: terminalSettings.sftpDoubleClickBehavior,
|
||||
sftpShowHiddenFiles: terminalSettings.sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload: terminalSettings.sftpUseCompressedUpload,
|
||||
splitSessionWithCurrentShell: handlers.splitSessionWithCurrentShell,
|
||||
sshDebugLogsEnabled: terminalSettings.sshDebugLogsEnabled,
|
||||
terminalFontFamilyId: terminalSettings.terminalFontFamilyId,
|
||||
terminalFontSize: terminalSettings.terminalFontSize,
|
||||
terminalSettings: terminalSettings.terminalSettings,
|
||||
terminalThemeId: terminalSettings.terminalThemeId,
|
||||
toggleBroadcast: sessionActions?.toggleBroadcast,
|
||||
toggleScriptsSidePanelRef: handlers.toggleScriptsSidePanelRef,
|
||||
toggleSidePanelRef: handlers.toggleSidePanelRef,
|
||||
toggleWorkspaceViewMode: sessionActions?.toggleWorkspaceViewMode,
|
||||
updateHostDistro: vaultActions?.updateHostDistro,
|
||||
updateSplitSizes: sessionActions?.updateSplitSizes,
|
||||
updateSessionFontSize: sessionActions?.updateSessionFontSize,
|
||||
updateSessionRestoreCwd: sessionActions?.updateSessionRestoreCwd,
|
||||
updateSessionDynamicTitle: sessionActions?.updateSessionDynamicTitle,
|
||||
updateSessionCodingCliProvider: sessionActions?.updateSessionCodingCliProvider,
|
||||
updateTerminalSetting: terminalSettingsActions?.updateTerminalSetting,
|
||||
workspaces: session.workspaces,
|
||||
};
|
||||
}, [
|
||||
clearThemeIntent,
|
||||
currentTerminalTheme,
|
||||
handleDefaultTerminalThemeChange,
|
||||
handleFollowAppTerminalThemeChange,
|
||||
handlers,
|
||||
hostById,
|
||||
local.openNoteRequest,
|
||||
local.portForwardingRules,
|
||||
pickTerminalTheme,
|
||||
resolveFocusedAppearance,
|
||||
session.draggingSessionId,
|
||||
session.workspaces,
|
||||
sessionActions,
|
||||
sessionsForShell,
|
||||
settleManualThemeIntent,
|
||||
terminalHosts,
|
||||
terminalSettings,
|
||||
terminalSettingsActions,
|
||||
vaultActions,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (terminalDomain) publishAppShellDomainSlice('terminal', terminalDomain);
|
||||
}, [terminalDomain]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useLayoutEffect, useMemo, useSyncExternalStore } from 'react';
|
||||
|
||||
import { getEffectiveKnownHosts } from '../../../infrastructure/syncHelpers';
|
||||
import {
|
||||
useVaultSnapshot,
|
||||
useVaultSnapshotActions,
|
||||
} from '../../state/vaultSnapshotStore';
|
||||
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
|
||||
import { publishAppShellDomainSlice } from '../appShellPropsStore';
|
||||
import { useAppLocalUiStore } from '../appLocalUiStore';
|
||||
import { APP_MOUNTS_DOMAIN } from './mountsDomain';
|
||||
|
||||
/**
|
||||
* Vault island: catalog from `vaultSnapshotStore`, glue handlers from the
|
||||
* app handlers bridge, local vault UI from `appLocalUiStore`. Assembles the
|
||||
* full vault domain bag field-by-field — never spreads a prepared bag from
|
||||
* AppSideEffects.
|
||||
*/
|
||||
export function VaultHost() {
|
||||
const vault = useVaultSnapshot();
|
||||
const actions = useVaultSnapshotActions();
|
||||
const local = useAppLocalUiStore();
|
||||
const handlers = useSyncExternalStore(
|
||||
subscribeAppHandlers,
|
||||
getAppHandlers,
|
||||
getAppHandlers,
|
||||
);
|
||||
|
||||
const effectiveKnownHosts = useMemo(
|
||||
() => getEffectiveKnownHosts(vault.knownHosts as never) ?? [],
|
||||
[vault.knownHosts],
|
||||
);
|
||||
|
||||
const vaultDomain = useMemo(() => {
|
||||
if (!handlers) return null;
|
||||
return {
|
||||
addShellHistoryEntry: actions?.addShellHistoryEntry,
|
||||
removeShellHistoryEntry: actions?.removeShellHistoryEntry,
|
||||
commitPluginImporterData: actions?.commitPluginImporterData,
|
||||
commitVaultImportTransaction: actions?.commitVaultImportTransaction,
|
||||
commitVaultGroupMutation: actions?.commitVaultGroupMutation,
|
||||
convertKnownHostToHost: actions?.convertKnownHostToHost,
|
||||
customGroups: vault.customGroups,
|
||||
deepLinkHostDraft: local.deepLinkHostDraft,
|
||||
effectiveKnownHosts,
|
||||
groupConfigs: vault.groupConfigs,
|
||||
handleAddKnownHost: handlers.handleAddKnownHost,
|
||||
handleDeleteHost: handlers.handleDeleteHost,
|
||||
handleOpenHostFromVaultNote: handlers.handleOpenHostFromVaultNote,
|
||||
handleOpenVaultHostFromChat: handlers.handleOpenVaultHostFromChat,
|
||||
handleOpenVaultNoteFromChat: handlers.handleOpenVaultNoteFromChat,
|
||||
handleOpenVaultSectionFromChat: handlers.handleOpenVaultSectionFromChat,
|
||||
handleOpenVaultSnippetFromChat: handlers.handleOpenVaultSnippetFromChat,
|
||||
hosts: vault.hosts,
|
||||
identities: vault.identities,
|
||||
importOrReuseKey: actions?.importOrReuseKey,
|
||||
keys: vault.keys,
|
||||
managedSources: vault.managedSources,
|
||||
navigateToSection: local.navigateToSection,
|
||||
proxyProfiles: vault.proxyProfiles,
|
||||
readPersistedHosts: actions?.readPersistedHosts,
|
||||
readPersistedManagedSources: actions?.readPersistedManagedSources,
|
||||
setDeepLinkHostDraft: handlers.setDeepLinkHostDraft,
|
||||
setNavigateToSection: handlers.setNavigateToSection,
|
||||
setVaultFocusRequest: handlers.setVaultFocusRequest,
|
||||
snippetPackages: vault.snippetPackages,
|
||||
snippets: vault.snippets,
|
||||
unmanageSource: handlers.unmanageSource,
|
||||
updateCustomGroups: actions?.updateCustomGroups,
|
||||
updateGroupConfigs: actions?.updateGroupConfigs,
|
||||
updateHosts: actions?.updateHosts,
|
||||
updateIdentities: actions?.updateIdentities,
|
||||
updateKeys: actions?.updateKeys,
|
||||
updateKnownHosts: actions?.updateKnownHosts,
|
||||
updateManagedSources: actions?.updateManagedSources,
|
||||
updateProxyProfiles: actions?.updateProxyProfiles,
|
||||
updateSnippetPackages: actions?.updateSnippetPackages,
|
||||
updateSnippets: actions?.updateSnippets,
|
||||
vaultFocusRequest: local.vaultFocusRequest,
|
||||
};
|
||||
}, [
|
||||
actions,
|
||||
effectiveKnownHosts,
|
||||
handlers,
|
||||
local.deepLinkHostDraft,
|
||||
local.navigateToSection,
|
||||
local.vaultFocusRequest,
|
||||
vault.customGroups,
|
||||
vault.groupConfigs,
|
||||
vault.hosts,
|
||||
vault.identities,
|
||||
vault.keys,
|
||||
vault.managedSources,
|
||||
vault.proxyProfiles,
|
||||
vault.snippetPackages,
|
||||
vault.snippets,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (vaultDomain) publishAppShellDomainSlice('vault', vaultDomain);
|
||||
publishAppShellDomainSlice('mounts', APP_MOUNTS_DOMAIN);
|
||||
}, [vaultDomain]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { LogViewWrapper, SftpViewMount, TerminalLayerMount, VaultViewContainer } from '../AppMounts';
|
||||
|
||||
/** Lazy mount wrappers — stable module identity for the app lifetime. */
|
||||
export const APP_MOUNTS_DOMAIN = Object.freeze({
|
||||
VaultViewContainer,
|
||||
SftpViewMount,
|
||||
TerminalLayerMount,
|
||||
LogViewWrapper,
|
||||
});
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
removeKeyboardInteractiveRequest,
|
||||
shouldQueueKeyboardInteractiveRequest,
|
||||
} from "./useAppStartupEffects.ts";
|
||||
import {
|
||||
clearTerminalBootEpoch,
|
||||
setTerminalBootEpoch,
|
||||
} from "../../domain/terminalBootEpoch.ts";
|
||||
|
||||
const sessions = [{ id: "terminal-1" }, { id: "terminal-2" }];
|
||||
|
||||
@@ -19,6 +23,42 @@ test("terminal-scoped keyboard-interactive requests are limited to owned session
|
||||
);
|
||||
});
|
||||
|
||||
test("disconnected terminal sessions do not queue keyboard-interactive prompts", () => {
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest(
|
||||
{ scope: "terminal", sessionId: "terminal-1" },
|
||||
[{ id: "terminal-1", status: "disconnected" }],
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest(
|
||||
{ scope: "terminal", sessionId: "terminal-1" },
|
||||
[{ id: "terminal-1", status: "connecting" }],
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("superseded terminal boot epochs do not queue keyboard-interactive prompts", () => {
|
||||
setTerminalBootEpoch("terminal-1", 3);
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest(
|
||||
{ scope: "terminal", sessionId: "terminal-1", bootEpoch: 1 },
|
||||
[{ id: "terminal-1", status: "connecting" }],
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest(
|
||||
{ scope: "terminal", sessionId: "terminal-1", bootEpoch: 3 },
|
||||
[{ id: "terminal-1", status: "connecting" }],
|
||||
),
|
||||
true,
|
||||
);
|
||||
clearTerminalBootEpoch("terminal-1");
|
||||
});
|
||||
|
||||
test("external keyboard-interactive requests are not filtered by terminal session ids", () => {
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "sftp-conn-1" }, sessions),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useLayoutEffect, useMemo, type ReactNode } from 'react';
|
||||
|
||||
import {
|
||||
AppLockChromeContext,
|
||||
registerAppAppLockRuntime,
|
||||
type AppAppLockRuntime,
|
||||
} from '../../state/appRuntimeBridge';
|
||||
|
||||
export type AppLockRuntimePublisherProps = {
|
||||
/** App-lock runtime owned by `AppLockGate` (index.tsx render prop). */
|
||||
appLock: AppAppLockRuntime;
|
||||
/** `settings.appLockSettings.enabled` from the gate's settings instance. */
|
||||
appLockEnabled: boolean;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Publishes the gate-owned app-lock runtime the same way SettingsPublisher
|
||||
* publishes settings: the full runtime goes on the `appRuntimeBridge` slot for
|
||||
* imperative callers (`getAppAppLockRuntime()`), and a narrow memoized chrome
|
||||
* slice goes on context so TopTabs / AppSideEffects re-render only when the
|
||||
* lock state actually changes — the full runtime changes identity on every
|
||||
* gate render.
|
||||
*/
|
||||
export function AppLockRuntimePublisher({
|
||||
appLock,
|
||||
appLockEnabled,
|
||||
children,
|
||||
}: AppLockRuntimePublisherProps) {
|
||||
useLayoutEffect(() => {
|
||||
registerAppAppLockRuntime(appLock);
|
||||
}, [appLock]);
|
||||
|
||||
// Only a real unmount clears the slot; see VaultPublisher for why.
|
||||
useLayoutEffect(() => () => {
|
||||
registerAppAppLockRuntime(null);
|
||||
}, []);
|
||||
|
||||
const chrome = useMemo(
|
||||
() => ({
|
||||
appLockEnabled,
|
||||
locked: appLock.locked,
|
||||
initialized: appLock.initialized,
|
||||
}),
|
||||
[appLockEnabled, appLock.locked, appLock.initialized],
|
||||
);
|
||||
|
||||
return (
|
||||
<AppLockChromeContext.Provider value={chrome}>
|
||||
{children}
|
||||
</AppLockChromeContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useLayoutEffect, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
AppSessionRuntimeContext,
|
||||
registerAppSessionRuntime,
|
||||
} from '../../state/appRuntimeBridge';
|
||||
import {
|
||||
publishSessionSnapshot,
|
||||
registerSessionSnapshotActions,
|
||||
} from '../../state/sessionSnapshotStore';
|
||||
import { useSessionState } from '../../state/useSessionState';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type SessionPublisherProps = {
|
||||
/** Peer session windows must not write the main window's restore record. */
|
||||
persistSessionRestore: boolean;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Owns `useSessionState` and publishes it the same three ways `VaultPublisher`
|
||||
* publishes the vault: catalog into `sessionSnapshotStore`, mutators into its
|
||||
* action slot, and the whole runtime onto `appRuntimeBridge` for App.
|
||||
*/
|
||||
export function SessionPublisher({ persistSessionRestore, children }: SessionPublisherProps) {
|
||||
const session = useSessionState({ persistSessionRestore });
|
||||
const {
|
||||
sessions,
|
||||
orphanSessions,
|
||||
workspaces,
|
||||
logViews,
|
||||
draggingSessionId,
|
||||
sessionRenameTarget,
|
||||
workspaceRenameTarget,
|
||||
setActiveTabId,
|
||||
closeSession,
|
||||
closeSessions,
|
||||
closeWorkspace,
|
||||
openLogView,
|
||||
closeLogView,
|
||||
setDraggingSessionId,
|
||||
startSessionRename,
|
||||
renameSessionInline,
|
||||
submitSessionRename,
|
||||
resetSessionRename,
|
||||
startWorkspaceRename,
|
||||
submitWorkspaceRename,
|
||||
resetWorkspaceRename,
|
||||
removeSessionFromWorkspace,
|
||||
setWorkspaceFocusedSession,
|
||||
toggleWorkspaceViewMode,
|
||||
createLocalTerminal,
|
||||
createSerialSession,
|
||||
connectToHost,
|
||||
updateSessionStatus,
|
||||
updateSessionFontSize,
|
||||
clearSessionFontSizeOverride,
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendLocalTerminalToWorkspace,
|
||||
createWorkspaceFromTargets,
|
||||
updateSplitSizes,
|
||||
splitSession,
|
||||
reorderWorkspaceSessions,
|
||||
moveFocusInWorkspace,
|
||||
runSnippet,
|
||||
getOrderedWorkTabs,
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
createSessionFromCloneSource,
|
||||
updateSessionRestoreCwd,
|
||||
getSessionRestoreCwd,
|
||||
updateSessionDynamicTitle,
|
||||
updateSessionCodingCliProvider,
|
||||
} = session;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
registerAppSessionRuntime(session);
|
||||
}, [session]);
|
||||
|
||||
// Only a real unmount clears the slot; see VaultPublisher for why.
|
||||
useLayoutEffect(() => () => {
|
||||
registerAppSessionRuntime(null);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
publishSessionSnapshot({
|
||||
sessions,
|
||||
orphanSessions,
|
||||
workspaces,
|
||||
logViews,
|
||||
draggingSessionId,
|
||||
sessionRenameTarget,
|
||||
workspaceRenameTarget,
|
||||
});
|
||||
}, [
|
||||
draggingSessionId,
|
||||
logViews,
|
||||
orphanSessions,
|
||||
sessionRenameTarget,
|
||||
sessions,
|
||||
workspaceRenameTarget,
|
||||
workspaces,
|
||||
]);
|
||||
|
||||
const sessionActions = useMemo(() => ({
|
||||
setActiveTabId,
|
||||
closeSession,
|
||||
closeSessions,
|
||||
closeWorkspace,
|
||||
openLogView,
|
||||
closeLogView,
|
||||
setDraggingSessionId,
|
||||
startSessionRename,
|
||||
renameSessionInline,
|
||||
submitSessionRename,
|
||||
resetSessionRename,
|
||||
startWorkspaceRename,
|
||||
submitWorkspaceRename,
|
||||
resetWorkspaceRename,
|
||||
removeSessionFromWorkspace,
|
||||
setWorkspaceFocusedSession,
|
||||
toggleWorkspaceViewMode,
|
||||
createLocalTerminal,
|
||||
createSerialSession,
|
||||
connectToHost,
|
||||
updateSessionStatus,
|
||||
updateSessionFontSize,
|
||||
clearSessionFontSizeOverride,
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendLocalTerminalToWorkspace,
|
||||
createWorkspaceFromTargets,
|
||||
updateSplitSizes,
|
||||
splitSession,
|
||||
reorderWorkspaceSessions,
|
||||
moveFocusInWorkspace,
|
||||
runSnippet,
|
||||
getOrderedWorkTabs,
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
createSessionFromCloneSource,
|
||||
updateSessionRestoreCwd,
|
||||
getSessionRestoreCwd,
|
||||
updateSessionDynamicTitle,
|
||||
updateSessionCodingCliProvider,
|
||||
}), [
|
||||
addSessionToWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendLocalTerminalToWorkspace,
|
||||
clearSessionFontSizeOverride,
|
||||
closeLogView,
|
||||
closeSession,
|
||||
closeSessions,
|
||||
closeWorkspace,
|
||||
connectToHost,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
createLocalTerminal,
|
||||
createSerialSession,
|
||||
createSessionFromCloneSource,
|
||||
createWorkspaceFromSessions,
|
||||
createWorkspaceFromTargets,
|
||||
createWorkspaceWithHosts,
|
||||
getOrderedWorkTabs,
|
||||
getSessionRestoreCwd,
|
||||
isBroadcastEnabled,
|
||||
moveFocusInWorkspace,
|
||||
openLogView,
|
||||
removeSessionFromWorkspace,
|
||||
renameSessionInline,
|
||||
reorderTabs,
|
||||
reorderWorkspaceSessions,
|
||||
resetSessionRename,
|
||||
resetWorkspaceRename,
|
||||
runSnippet,
|
||||
setActiveTabId,
|
||||
setDraggingSessionId,
|
||||
setWorkspaceFocusedSession,
|
||||
splitSession,
|
||||
startSessionRename,
|
||||
startWorkspaceRename,
|
||||
submitSessionRename,
|
||||
submitWorkspaceRename,
|
||||
toggleBroadcast,
|
||||
toggleWorkspaceViewMode,
|
||||
updateSessionCodingCliProvider,
|
||||
updateSessionDynamicTitle,
|
||||
updateSessionFontSize,
|
||||
updateSessionRestoreCwd,
|
||||
updateSessionStatus,
|
||||
updateSplitSizes,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
registerSessionSnapshotActions(sessionActions);
|
||||
return () => {
|
||||
registerSessionSnapshotActions(null);
|
||||
};
|
||||
}, [sessionActions]);
|
||||
|
||||
return (
|
||||
<AppSessionRuntimeContext.Provider value={session}>
|
||||
{children}
|
||||
</AppSessionRuntimeContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useLayoutEffect, type ReactNode } from 'react';
|
||||
|
||||
import {
|
||||
AppSettingsRuntimeContext,
|
||||
registerAppSettingsRuntime,
|
||||
type AppSettingsRuntime,
|
||||
} from '../../state/appRuntimeBridge';
|
||||
|
||||
export type SettingsPublisherProps = {
|
||||
/**
|
||||
* Pre-built settings runtime owned by an ancestor. `AppLockGate` (index.tsx)
|
||||
* owns `useSettingsState` so the lock overlay can render before app children
|
||||
* mount; the publisher only binds the runtime slot and context.
|
||||
*/
|
||||
settings: AppSettingsRuntime;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Publishes the gate-owned settings runtime the same way `VaultPublisher` /
|
||||
* `SessionPublisher` hand over their runtimes: a context for render-time reads
|
||||
* and the `appRuntimeBridge` slot for imperative callers.
|
||||
*
|
||||
* The store fan-out (`settingsChromeStore` / `appearanceChromeStore`) already
|
||||
* happens inside `useSettingsState`, so this publisher only relocates the
|
||||
* binding out of the component that also builds the shell's domain bags.
|
||||
*/
|
||||
export function SettingsPublisher({ settings, children }: SettingsPublisherProps) {
|
||||
useLayoutEffect(() => {
|
||||
registerAppSettingsRuntime(settings);
|
||||
}, [settings]);
|
||||
|
||||
// Only a real unmount clears the slot; see VaultPublisher for why.
|
||||
useLayoutEffect(() => () => {
|
||||
registerAppSettingsRuntime(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppSettingsRuntimeContext.Provider value={settings}>
|
||||
{children}
|
||||
</AppSettingsRuntimeContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useLayoutEffect, useMemo, useRef, type ReactNode } from 'react';
|
||||
|
||||
import {
|
||||
AppVaultRuntimeContext,
|
||||
registerAppVaultRuntime,
|
||||
type AppVaultContextValue,
|
||||
} from '../../state/appRuntimeBridge';
|
||||
import {
|
||||
publishVaultSnapshot,
|
||||
registerVaultSnapshotActions,
|
||||
} from '../../state/vaultSnapshotStore';
|
||||
import { useVaultState } from '../../state/useVaultState';
|
||||
import { getEffectiveKnownHosts } from '../../../infrastructure/syncHelpers';
|
||||
|
||||
export type VaultPublisherProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
function vaultContextValuesEqual(
|
||||
prev: AppVaultContextValue,
|
||||
next: AppVaultContextValue,
|
||||
): boolean {
|
||||
const keys = Object.keys(next) as Array<keyof AppVaultContextValue>;
|
||||
return keys.every((key) => prev[key] === next[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns `useVaultState` and publishes it three ways: the catalog into
|
||||
* `vaultSnapshotStore` for shell surfaces that subscribe to a slice, the
|
||||
* mutators into the same store's action slot, and a **catalog-only** runtime
|
||||
* onto `AppVaultRuntimeContext` for App.
|
||||
*
|
||||
* `notes` / `noteGroups` / `connectionLogs` / `shellHistory` are intentionally
|
||||
* absent from the context value: they churn on a different cadence and already
|
||||
* fan out through dedicated stores. Including them would force App (the
|
||||
* domain-bag builder) to re-render on every note edit or session log append.
|
||||
* Imperative callers that still need the full hook return use
|
||||
* `getAppVaultRuntime()`.
|
||||
*/
|
||||
export function VaultPublisher({ children }: VaultPublisherProps) {
|
||||
const vault = useVaultState();
|
||||
const {
|
||||
isInitialized,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
snippetPackages,
|
||||
customGroups,
|
||||
knownHosts,
|
||||
managedSources,
|
||||
groupConfigs,
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
importOrReuseKey,
|
||||
updateIdentities,
|
||||
updateProxyProfiles,
|
||||
updateSnippets,
|
||||
updateSnippetPackages,
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
updateGroupConfigs,
|
||||
convertKnownHostToHost,
|
||||
readPersistedHosts,
|
||||
readPersistedManagedSources,
|
||||
commitPluginImporterData,
|
||||
commitVaultImportTransaction,
|
||||
commitVaultGroupMutation,
|
||||
updateHostDistro,
|
||||
updateHostLastConnected,
|
||||
addShellHistoryEntry,
|
||||
removeShellHistoryEntry,
|
||||
} = vault;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
registerAppVaultRuntime(vault);
|
||||
}, [vault]);
|
||||
|
||||
// Only a real unmount clears the slot. A re-render re-registers through the
|
||||
// effect above, and StrictMode's simulated remount re-runs both.
|
||||
useLayoutEffect(() => () => {
|
||||
registerAppVaultRuntime(null);
|
||||
}, []);
|
||||
|
||||
// useVaultState decrypts hosts/keys before it reads known hosts, so the state
|
||||
// is briefly empty at boot even when storage has entries. Publish the same
|
||||
// storage fallback App uses so a subscriber connecting during that window
|
||||
// does not re-prompt for a fingerprint it already trusts.
|
||||
const effectiveKnownHosts = useMemo(
|
||||
() => getEffectiveKnownHosts(knownHosts) ?? [],
|
||||
[knownHosts],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
publishVaultSnapshot({
|
||||
isVaultInitialized: isInitialized,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
snippetPackages,
|
||||
customGroups,
|
||||
knownHosts: effectiveKnownHosts,
|
||||
managedSources,
|
||||
groupConfigs,
|
||||
});
|
||||
}, [
|
||||
customGroups,
|
||||
effectiveKnownHosts,
|
||||
groupConfigs,
|
||||
hosts,
|
||||
identities,
|
||||
isInitialized,
|
||||
keys,
|
||||
managedSources,
|
||||
proxyProfiles,
|
||||
snippetPackages,
|
||||
snippets,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
registerVaultSnapshotActions({
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
importOrReuseKey,
|
||||
updateIdentities,
|
||||
updateProxyProfiles,
|
||||
updateSnippets,
|
||||
updateSnippetPackages,
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
updateGroupConfigs,
|
||||
convertKnownHostToHost,
|
||||
readPersistedHosts,
|
||||
readPersistedManagedSources,
|
||||
commitPluginImporterData,
|
||||
commitVaultImportTransaction,
|
||||
commitVaultGroupMutation,
|
||||
updateHostDistro,
|
||||
updateHostLastConnected,
|
||||
addShellHistoryEntry,
|
||||
removeShellHistoryEntry,
|
||||
});
|
||||
return () => {
|
||||
registerVaultSnapshotActions(null);
|
||||
};
|
||||
}, [
|
||||
addShellHistoryEntry,
|
||||
commitPluginImporterData,
|
||||
commitVaultImportTransaction,
|
||||
commitVaultGroupMutation,
|
||||
convertKnownHostToHost,
|
||||
importOrReuseKey,
|
||||
readPersistedHosts,
|
||||
readPersistedManagedSources,
|
||||
removeShellHistoryEntry,
|
||||
updateCustomGroups,
|
||||
updateGroupConfigs,
|
||||
updateHostDistro,
|
||||
updateHostLastConnected,
|
||||
updateHosts,
|
||||
updateIdentities,
|
||||
updateKeys,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
updateProxyProfiles,
|
||||
updateSnippetPackages,
|
||||
updateSnippets,
|
||||
]);
|
||||
|
||||
// Strip high-churn fields, then retain the previous object identity when only
|
||||
// those fields (or exportData) changed so React context consumers stay quiet.
|
||||
const vaultForAppRef = useRef<AppVaultContextValue | null>(null);
|
||||
const vaultForApp = useMemo((): AppVaultContextValue => {
|
||||
const {
|
||||
notes: _notes,
|
||||
noteGroups: _noteGroups,
|
||||
connectionLogs: _connectionLogs,
|
||||
shellHistory: _shellHistory,
|
||||
exportData: _exportData,
|
||||
...catalog
|
||||
} = vault;
|
||||
const prev = vaultForAppRef.current;
|
||||
if (prev && vaultContextValuesEqual(prev, catalog)) {
|
||||
return prev;
|
||||
}
|
||||
vaultForAppRef.current = catalog;
|
||||
return catalog;
|
||||
}, [vault]);
|
||||
|
||||
return (
|
||||
<AppVaultRuntimeContext.Provider value={vaultForApp}>
|
||||
{children}
|
||||
</AppVaultRuntimeContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const appSource = readFileSync(new URL('../../App.tsx', import.meta.url), 'utf8');
|
||||
const appSideEffectsSource = readFileSync(
|
||||
new URL('./AppSideEffects.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const indexSource = readFileSync(new URL('../../index.tsx', import.meta.url), 'utf8');
|
||||
const startupEffectsSource = readFileSync(
|
||||
new URL('./useAppStartupEffects.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const updateCheckSource = readFileSync(
|
||||
new URL('../state/useUpdateCheck.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const portForwardingAutoStartSource = readFileSync(
|
||||
new URL('../state/usePortForwardingAutoStart.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const appLockBridgeSource = readFileSync(
|
||||
new URL('../state/useAppLockBridge.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('every renderer root mounts under StrictMode', () => {
|
||||
assert.match(indexSource, /import \{ StrictMode, Suspense, lazy \} from 'react'/);
|
||||
|
||||
const renderCalls = indexSource.match(/root\.render\(/g) ?? [];
|
||||
assert.equal(renderCalls.length, 4, 'main, settings, tray and terminal-popup roots');
|
||||
|
||||
let cursor = 0;
|
||||
for (let index = 0; index < renderCalls.length; index += 1) {
|
||||
const renderAt = indexSource.indexOf('root.render(', cursor);
|
||||
assert.notEqual(renderAt, -1);
|
||||
const opener = indexSource.slice(renderAt, renderAt + 60);
|
||||
assert.match(opener, /root\.render\(\s*<StrictMode>/, `root.render #${index + 1} lacks StrictMode`);
|
||||
cursor = renderAt + 'root.render('.length;
|
||||
}
|
||||
});
|
||||
|
||||
test('clone-session payload is consumed once even if the effect re-runs', () => {
|
||||
const effectStart = appSideEffectsSource.indexOf('consumedNewWindowSessionRef');
|
||||
assert.notEqual(effectStart, -1, 'clone-session effect must latch on payload identity');
|
||||
const effectEnd = appSideEffectsSource.indexOf(
|
||||
'}, [createSessionFromCloneSource, isVaultInitialized, pendingNewWindowSession]);',
|
||||
effectStart,
|
||||
);
|
||||
assert.notEqual(effectEnd, -1);
|
||||
const body = appSideEffectsSource.slice(effectStart, effectEnd);
|
||||
|
||||
// A ref comparison is required: clearing the state only lands on the next
|
||||
// render, so a re-invoked effect still closes over the same payload.
|
||||
assert.match(body, /if \(consumedNewWindowSessionRef\.current === pendingNewWindowSession\) return;/);
|
||||
assert.ok(
|
||||
body.indexOf('consumedNewWindowSessionRef.current = pending')
|
||||
< body.indexOf('createSessionFromCloneSource(pending.sourceSession'),
|
||||
'payload must be marked consumed before the clone is created',
|
||||
);
|
||||
});
|
||||
|
||||
test('rendererReady is notified once per renderer process', () => {
|
||||
assert.match(appLockBridgeSource, /^let rendererReadySent = false;$/m);
|
||||
const guardAt = appLockBridgeSource.indexOf('if (rendererReadySent) return;');
|
||||
assert.notEqual(guardAt, -1);
|
||||
const guarded = appLockBridgeSource.slice(guardAt, guardAt + 200);
|
||||
assert.match(guarded, /rendererReadySent = true;/);
|
||||
assert.match(guarded, /netcattyBridge\.get\(\)\?\.rendererReady\?\.\(\)/);
|
||||
assert.ok(
|
||||
guarded.indexOf('rendererReadySent = true;')
|
||||
< guarded.indexOf('netcattyBridge.get()?.rendererReady?.()'),
|
||||
'the latch must be set before the IPC call so a re-entrant effect is blocked',
|
||||
);
|
||||
});
|
||||
|
||||
test('update-available toast latches on the release version', () => {
|
||||
const latchAt = startupEffectsSource.indexOf('toastedUpdateVersionRef');
|
||||
assert.notEqual(latchAt, -1);
|
||||
assert.match(startupEffectsSource, /const toastedUpdateVersionRef = useRef<string \| null>\(null\)/);
|
||||
assert.match(startupEffectsSource, /if \(toastedUpdateVersionRef\.current === version\) return;/);
|
||||
|
||||
const guardAt = startupEffectsSource.indexOf('if (toastedUpdateVersionRef.current === version) return;');
|
||||
const toastAt = startupEffectsSource.indexOf('toast.info(', guardAt);
|
||||
assert.notEqual(toastAt, -1);
|
||||
assert.ok(guardAt < toastAt, 'the version latch must gate the toast call');
|
||||
});
|
||||
|
||||
test('port-forward auto-start runs once across a StrictMode double effect', () => {
|
||||
assert.match(
|
||||
portForwardingAutoStartSource,
|
||||
/const autoStartExecutedRef = useRef\(false\);/,
|
||||
'the launch auto-start needs a module-render latch, not just an effect dep list',
|
||||
);
|
||||
|
||||
const effectStart = portForwardingAutoStartSource.indexOf('if (autoStartExecutedRef.current) return;');
|
||||
assert.notEqual(effectStart, -1, 'the effect must bail out when the latch is already set');
|
||||
const effectEnd = portForwardingAutoStartSource.indexOf(
|
||||
'}, [\n enabled,\n isVaultInitialized,\n runAutoStart,\n ]);',
|
||||
effectStart,
|
||||
);
|
||||
assert.notEqual(effectEnd, -1, 'auto-start effect dep list moved; update this contract');
|
||||
const body = portForwardingAutoStartSource.slice(effectStart, effectEnd);
|
||||
|
||||
// StrictMode invokes the effect twice with the same render's closure, so the
|
||||
// latch has to be written before the async run is kicked off — awaiting or
|
||||
// deferring the write would let the second invoke start a duplicate tunnel.
|
||||
const latchAt = body.indexOf('autoStartExecutedRef.current = true;');
|
||||
const runAt = body.indexOf('void runAutoStart();');
|
||||
assert.notEqual(latchAt, -1);
|
||||
assert.notEqual(runAt, -1);
|
||||
assert.ok(latchAt < runAt, 'the latch must be set before runAutoStart() is called');
|
||||
|
||||
// The vault gate must also sit before the latch: latching on a pre-hydration
|
||||
// invoke would permanently suppress the real auto-start.
|
||||
assert.ok(
|
||||
body.indexOf('if (!isVaultInitialized) return;') < latchAt,
|
||||
'the vault gate must precede the latch write',
|
||||
);
|
||||
});
|
||||
|
||||
test('cancelled startup update check resets its latch instead of skipping forever', () => {
|
||||
const scheduleAt = updateCheckSource.indexOf('let checkArmed = true;');
|
||||
assert.notEqual(scheduleAt, -1);
|
||||
const tail = updateCheckSource.slice(scheduleAt);
|
||||
|
||||
// The latch is only meaningful once the timer actually fires; a cleanup that
|
||||
// cancels it beforehand must let the next effect schedule again.
|
||||
assert.match(tail, /startupCheckTimeoutRef\.current = setTimeout\(async \(\) => \{\s*\n\s*checkArmed = false;/);
|
||||
assert.match(tail, /if \(checkArmed\) \{\s*\n\s*hasCheckedOnStartupRef\.current = false;\s*\n\s*\}/);
|
||||
});
|
||||
|
||||
test('terminal popup config survives StrictMode unsubscribe/resubscribe', () => {
|
||||
const preloadSource = readFileSync(new URL('../../electron/preload.cjs', import.meta.url), 'utf8');
|
||||
const apiSource = readFileSync(new URL('../../electron/preload/api.cjs', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(preloadSource, /lastPayload:\s*null/);
|
||||
assert.match(
|
||||
preloadSource,
|
||||
/terminalPopupConfigState\.lastPayload = payload/,
|
||||
'incoming popup config must be retained beyond the one-shot pending slot',
|
||||
);
|
||||
|
||||
const subscribeAt = apiSource.indexOf('onTerminalPopupConfig:');
|
||||
assert.notEqual(subscribeAt, -1);
|
||||
const subscribe = apiSource.slice(subscribeAt, subscribeAt + 700);
|
||||
assert.match(
|
||||
subscribe,
|
||||
/terminalPopupConfigState\.pending \?\? terminalPopupConfigState\.lastPayload/,
|
||||
'resubscribe must replay lastPayload after pending was drained',
|
||||
);
|
||||
assert.match(subscribe, /terminalPopupConfigState\.pending = null/);
|
||||
assert.doesNotMatch(
|
||||
subscribe,
|
||||
/terminalPopupConfigState\.lastPayload = null/,
|
||||
'StrictMode remount must not clear lastPayload on subscribe',
|
||||
);
|
||||
});
|
||||
|
||||
test('vault init cancels the superseded StrictMode effect before publishing ready', () => {
|
||||
const vaultSource = readFileSync(
|
||||
new URL('../state/useVaultState.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const initAt = vaultSource.indexOf('let cancelled = false;');
|
||||
assert.notEqual(initAt, -1, 'vault init must track cancellation');
|
||||
const initSlice = vaultSource.slice(initAt, initAt + 12000);
|
||||
assert.match(initSlice, /return \(\) => \{\s*\n\s*cancelled = true;\s*\n\s*\};/);
|
||||
assert.match(
|
||||
initSlice,
|
||||
/if \(!cancelled\) \{\s*\n\s*setIsInitialized\(true\);\s*\n\s*setVaultInitialized\(true\);\s*\n\s*\}/,
|
||||
'only the surviving init may mark the vault ready',
|
||||
);
|
||||
assert.match(initSlice, /if \(cancelled\) return;/);
|
||||
});
|
||||
|
||||
test('global hotkey registration cleans up across StrictMode remount', () => {
|
||||
const systemEffectsSource = readFileSync(
|
||||
new URL('../state/systemSettingsEffects.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const hotkeyAt = systemEffectsSource.indexOf('Persist and sync toggle window hotkey setting');
|
||||
assert.notEqual(hotkeyAt, -1);
|
||||
const hotkeyEffect = systemEffectsSource.slice(hotkeyAt, hotkeyAt + 2200);
|
||||
assert.match(hotkeyEffect, /let cancelled = false;/);
|
||||
assert.match(hotkeyEffect, /if \(cancelled\) return;/);
|
||||
assert.match(
|
||||
hotkeyEffect,
|
||||
/if \(didRegister\) \{\s*\n\s*bridge\?\.unregisterGlobalHotkey/,
|
||||
'cleanup must unregister a registration started by this effect',
|
||||
);
|
||||
// Early return before notify must not skip returning the cleanup function.
|
||||
assert.doesNotMatch(
|
||||
hotkeyEffect,
|
||||
/if \(!persistMountedRef\.current\) return;\s*\n\s*notifySettingsChanged/,
|
||||
);
|
||||
});
|
||||
|
||||
test('settings persistMountedRef resets on StrictMode cleanup', () => {
|
||||
const settingsSource = readFileSync(
|
||||
new URL('../state/useSettingsState.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const markAt = settingsSource.indexOf('Mark persist effects mounted AFTER all persist useEffects');
|
||||
assert.notEqual(markAt, -1);
|
||||
const markEffect = settingsSource.slice(markAt, markAt + 500);
|
||||
assert.match(markEffect, /persistMountedRef\.current = true;/);
|
||||
assert.match(
|
||||
markEffect,
|
||||
/return \(\) => \{\s*\n\s*persistMountedRef\.current = false;\s*\n\s*\};/,
|
||||
'remount must treat boot as a fresh mount, not a settings change',
|
||||
);
|
||||
});
|
||||
|
||||
test('tray panel connect flush latches against StrictMode double invoke', () => {
|
||||
const sideEffectsSource = readFileSync(
|
||||
new URL('./AppSideEffects.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(sideEffectsSource, /pendingTrayConnectFlushKeyRef/);
|
||||
assert.match(
|
||||
sideEffectsSource,
|
||||
/if \(pendingTrayConnectFlushKeyRef\.current === flushKey\) return;/,
|
||||
);
|
||||
});
|
||||
|
||||
test('ssh transport idle TTL notify latches against StrictMode double invoke', () => {
|
||||
const settingsSource = readFileSync(
|
||||
new URL('../state/useSettingsState.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(settingsSource, /lastPushedSshTransportIdleTtlRef/);
|
||||
assert.match(
|
||||
settingsSource,
|
||||
/if \(lastPushedSshTransportIdleTtlRef\.current === sshTransportIdleTtlMs\) return;/,
|
||||
);
|
||||
assert.match(
|
||||
settingsSource,
|
||||
/lastPushedSshTransportIdleTtlRef\.current = sshTransportIdleTtlMs;/,
|
||||
);
|
||||
});
|
||||
|
||||
test('terminal selection Ask-AI payload is consumed once under StrictMode', () => {
|
||||
const hostSource = readFileSync(
|
||||
new URL('../../components/terminalLayer/TerminalLayerSupport.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(hostSource, /consumedTerminalSelectionRequestIds/);
|
||||
assert.doesNotMatch(
|
||||
hostSource,
|
||||
/consumedTerminalSelectionRequestIdRef/,
|
||||
'component refs reset on StrictMode remount; use a module Set',
|
||||
);
|
||||
assert.match(
|
||||
hostSource,
|
||||
/if \(consumedTerminalSelectionRequestIds\.has\(pendingTerminalSelection\.requestId\)\)/,
|
||||
);
|
||||
const latchAt = hostSource.indexOf('markTerminalSelectionRequestConsumed(pendingTerminalSelection.requestId)');
|
||||
const draftAt = hostSource.indexOf('updateDraft(scopeKey, defaultAgentId');
|
||||
assert.ok(latchAt > 0 && draftAt > latchAt, 'must latch before mutating the draft');
|
||||
});
|
||||
|
||||
test('Codex App Server interaction bridge is app-singleton like MCP approvals', () => {
|
||||
assert.match(appSource, /setupCodexAppServerInteractionBridge/);
|
||||
const panelSource = readFileSync(
|
||||
new URL('../../components/AIChatSidePanel.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
panelSource,
|
||||
/setupCodexAppServerInteractionBridge/,
|
||||
'per-panel Codex IPC listeners fan out approvals under retained multi-tab mounts',
|
||||
);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets.ts';
|
||||
import { buildNumberShortcutTabTargets, buildTabShortcutNumberById } from './tabShortcutTargets.ts';
|
||||
|
||||
test('number shortcut tabs include vault and sftp by default', () => {
|
||||
assert.deepEqual(
|
||||
@@ -62,3 +62,43 @@ test('pinned tabs cannot be duplicated by a malformed persisted work ordering',
|
||||
['vault', 'sftp', 'session-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('shortcut number map uses 1-based indices matching Ctrl/Cmd+[1...9]', () => {
|
||||
const map = buildTabShortcutNumberById({
|
||||
showSftpTab: true,
|
||||
shellOnlyTabNumberShortcuts: false,
|
||||
orderedTabs: ['session-1', 'workspace-1'],
|
||||
editorTabIds: [],
|
||||
});
|
||||
assert.equal(map.get('vault'), 1);
|
||||
assert.equal(map.get('sftp'), 2);
|
||||
assert.equal(map.get('session-1'), 3);
|
||||
assert.equal(map.get('workspace-1'), 4);
|
||||
});
|
||||
|
||||
test('shortcut number map skips pinned tabs in shell-only mode', () => {
|
||||
const map = buildTabShortcutNumberById({
|
||||
showSftpTab: true,
|
||||
shellOnlyTabNumberShortcuts: true,
|
||||
orderedTabs: ['session-1', 'workspace-1'],
|
||||
editorTabIds: [],
|
||||
});
|
||||
assert.equal(map.has('vault'), false);
|
||||
assert.equal(map.has('sftp'), false);
|
||||
assert.equal(map.get('session-1'), 1);
|
||||
assert.equal(map.get('workspace-1'), 2);
|
||||
});
|
||||
|
||||
test('shortcut number map caps at nine entries', () => {
|
||||
const orderedTabs = Array.from({ length: 12 }, (_, index) => `session-${index + 1}`);
|
||||
const map = buildTabShortcutNumberById({
|
||||
showSftpTab: false,
|
||||
shellOnlyTabNumberShortcuts: true,
|
||||
orderedTabs,
|
||||
editorTabIds: [],
|
||||
});
|
||||
assert.equal(map.size, 9);
|
||||
assert.equal(map.get('session-1'), 1);
|
||||
assert.equal(map.get('session-9'), 9);
|
||||
assert.equal(map.has('session-10'), false);
|
||||
});
|
||||
@@ -12,3 +12,22 @@ export function buildNumberShortcutTabTargets(params: {
|
||||
const pinnedTabs = params.showSftpTab ? ['vault', 'sftp'] : ['vault'];
|
||||
return [...new Set([...pinnedTabs, ...workTabs])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps tab ids to Cmd/Ctrl+[1...9] shortcut indices (1-based).
|
||||
* Only the first nine shortcut targets receive a number.
|
||||
*/
|
||||
export function buildTabShortcutNumberById(params: {
|
||||
showSftpTab: boolean;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
orderedTabs: readonly string[];
|
||||
editorTabIds: readonly string[];
|
||||
}): ReadonlyMap<string, number> {
|
||||
const targets = buildNumberShortcutTabTargets(params);
|
||||
const map = new Map<string, number>();
|
||||
const limit = Math.min(9, targets.length);
|
||||
for (let index = 0; index < limit; index += 1) {
|
||||
map.set(targets[index], index + 1);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Theme runtime actions produced by TerminalHost (`useThemeRuntime`).
|
||||
* AppSideEffects handlers (default/follow theme changes) call through this
|
||||
* bridge instead of co-hosting the hook.
|
||||
*/
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
export type ThemeRuntimeBridgeActions = {
|
||||
clearThemeIntent: () => void;
|
||||
settleManualThemeIntent: () => void;
|
||||
pickTerminalTheme: (themeId: string) => void;
|
||||
resolveFocusedAppearance: (...args: never[]) => unknown;
|
||||
currentTerminalTheme: unknown;
|
||||
globalAppearance: unknown;
|
||||
};
|
||||
|
||||
class ThemeRuntimeBridge {
|
||||
private actions: ThemeRuntimeBridgeActions | null = null;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
get = (): ThemeRuntimeBridgeActions | null => this.actions;
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
set(next: ThemeRuntimeBridgeActions | null): void {
|
||||
if (this.actions === next) return;
|
||||
this.actions = next;
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
const bridge = new ThemeRuntimeBridge();
|
||||
|
||||
export function registerThemeRuntimeActions(
|
||||
actions: ThemeRuntimeBridgeActions | null,
|
||||
): void {
|
||||
bridge.set(actions);
|
||||
}
|
||||
|
||||
export function getThemeRuntimeActions(): ThemeRuntimeBridgeActions | null {
|
||||
return bridge.get();
|
||||
}
|
||||
|
||||
export function subscribeThemeRuntimeActions(listener: Listener): () => void {
|
||||
return bridge.subscribe(listener);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from '../sta
|
||||
import { hasNewSourceFingerprint } from '../state/sftp/transferProgressMetadata';
|
||||
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from '../../infrastructure/config/storageKeys';
|
||||
import type { TransferTask } from '../../domain/models';
|
||||
import { isTerminalBootEpochCurrent } from '../../domain/terminalBootEpoch';
|
||||
import {
|
||||
canApplyDedicatedResumeProgress,
|
||||
createDedicatedResumeChildUpdateBatcher,
|
||||
@@ -24,8 +25,10 @@ type KeyboardInteractiveRequestLike = {
|
||||
scope?: KeyboardInteractiveScope;
|
||||
sessionId?: string;
|
||||
hostId?: string;
|
||||
requestId?: string;
|
||||
bootEpoch?: number;
|
||||
};
|
||||
type SessionIdLike = { id: string; hostId?: string; hostname?: string };
|
||||
type SessionIdLike = { id: string; hostId?: string; hostname?: string; status?: string };
|
||||
type KeyboardInteractiveQueueItem = { requestId: string };
|
||||
|
||||
export function shouldQueueKeyboardInteractiveRequest(
|
||||
@@ -34,7 +37,14 @@ export function shouldQueueKeyboardInteractiveRequest(
|
||||
): boolean {
|
||||
if (request.scope !== "terminal") return true;
|
||||
if (!request.sessionId) return false;
|
||||
return sessions.some((session) => session.id === request.sessionId);
|
||||
const session = sessions.find((entry) => entry.id === request.sessionId);
|
||||
if (!session) return false;
|
||||
// Status-bar disconnect keeps the tab; do not queue MFA for aborted panes.
|
||||
if (session.status === "disconnected") return false;
|
||||
// After disconnect → reconnect the tab is connecting again; reject MFA from
|
||||
// a superseded SSH start that still shares this sessionId.
|
||||
if (!isTerminalBootEpochCurrent(request.sessionId, request.bootEpoch)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function removeKeyboardInteractiveRequest<T extends KeyboardInteractiveQueueItem>(
|
||||
@@ -203,6 +213,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
}, [dedicatedResumeHosts, enabled, identities, isVaultInitialized, keys, knownHosts, terminalSettings]);
|
||||
|
||||
// Show toast notification when update is available (only when auto-download is idle)
|
||||
const toastedUpdateVersionRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
// Skip "update available" toast if auto-download has already started or completed
|
||||
@@ -211,6 +222,8 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
if (localStorageAdapter.readString('netcatty_auto_update_enabled_v1') === 'false') return;
|
||||
if (updateState.hasUpdate && updateState.latestRelease) {
|
||||
const version = updateState.latestRelease.version;
|
||||
if (toastedUpdateVersionRef.current === version) return;
|
||||
toastedUpdateVersionRef.current = version;
|
||||
toast.info(
|
||||
t('update.available.message', { version }),
|
||||
{
|
||||
@@ -409,6 +422,9 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
targetType: task.direction === "download" ? "local" : "sftp",
|
||||
sourceSftpId: task.direction === "download" ? sftpId : undefined,
|
||||
targetSftpId: task.direction === "upload" ? sftpId : undefined,
|
||||
// Keep host-scoped path gates across session reopen (Codex P1).
|
||||
sourceHostId: task.sourceHostId,
|
||||
targetHostId: task.targetHostId,
|
||||
totalBytes: task.totalBytes,
|
||||
resumable: task.resumable !== false,
|
||||
checkpointBytes,
|
||||
@@ -420,7 +436,30 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
});
|
||||
},
|
||||
);
|
||||
if (result?.cancelled || result?.error === "Transfer cancelled") {
|
||||
// Same-id retry stole ownership; wait for the live owner's terminal
|
||||
// status instead of treating this invoke as completed (Codex P2).
|
||||
if (result?.superseded === true) {
|
||||
// Wait for live owner terminal status only (no fixed deadline).
|
||||
for (;;) {
|
||||
const latest = sftpTransferCenterStore.getSnapshot().tasks.find((candidate) => candidate.id === task.id);
|
||||
const status = latest?.status;
|
||||
if (status === "completed" || status === "cancelled" || status === "failed") {
|
||||
if (status === "failed") {
|
||||
throw new Error(latest?.error || "Transfer failed");
|
||||
}
|
||||
if (status === "cancelled") {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "cancelled",
|
||||
transferId: task.id,
|
||||
endedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
// completed: events already applied; cancelled handled above.
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
} else if (result?.cancelled || result?.error === "Transfer cancelled") {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({ type: "cancelled", transferId: task.id, endedAt: Date.now() });
|
||||
} else if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
@@ -493,7 +532,12 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
if (!bridge?.onKeyboardInteractive) return;
|
||||
|
||||
const unsubscribe = bridge.onKeyboardInteractive((request) => {
|
||||
if (!shouldQueueKeyboardInteractiveRequest(request, sessionsRef.current)) return;
|
||||
if (!shouldQueueKeyboardInteractiveRequest(request, sessionsRef.current)) {
|
||||
if (request.scope === "terminal" && request.requestId) {
|
||||
void bridge.respondKeyboardInteractive?.(request.requestId, [], true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.log('[App] Keyboard-interactive request received:', request);
|
||||
// Add to queue instead of replacing - supports multiple concurrent sessions
|
||||
setKeyboardInteractiveQueue(prev => [...prev, {
|
||||
@@ -511,10 +555,23 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
const unsubscribeCancelled = bridge.onKeyboardInteractiveCancelled?.((event) => {
|
||||
setKeyboardInteractiveQueue(prev => removeKeyboardInteractiveRequest(prev, event.requestId));
|
||||
});
|
||||
const onTerminalDisconnected = (event: Event) => {
|
||||
const sessionId = (event as CustomEvent<{ sessionId?: string }>).detail?.sessionId;
|
||||
if (!sessionId) return;
|
||||
setKeyboardInteractiveQueue((prev) => {
|
||||
const doomed = prev.filter((request) => request.sessionId === sessionId);
|
||||
for (const request of doomed) {
|
||||
void bridge.respondKeyboardInteractive?.(request.requestId, [], true);
|
||||
}
|
||||
return prev.filter((request) => request.sessionId !== sessionId);
|
||||
});
|
||||
};
|
||||
window.addEventListener("netcatty:terminal-session-disconnected", onTerminalDisconnected);
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
unsubscribeCancelled?.();
|
||||
window.removeEventListener("netcatty:terminal-session-disconnected", onTerminalDisconnected);
|
||||
};
|
||||
}, [enabled, setKeyboardInteractiveQueue]);
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
|
||||
import { useSettingsChromeStore } from '../state/settingsChromeStore';
|
||||
import { buildAppThemeCssVars } from '../state/settingsStateDefaults';
|
||||
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
|
||||
/**
|
||||
* App theme CSS variables for surfaces that need them (vault surface, plugin
|
||||
* theme tokens). Reads accent from appearanceChromeStore and UI theme ids from
|
||||
* settingsChromeStore so accent drags only re-render the leaf that applies the
|
||||
* vars, never the App shell.
|
||||
*/
|
||||
export function useAppThemeStyle(): React.CSSProperties {
|
||||
const { accentMode, customAccent } = useAppearanceChromeStore();
|
||||
const { resolvedTheme, darkUiThemeId, lightUiThemeId } = useSettingsChromeStore();
|
||||
return useMemo(() => {
|
||||
const tokens = getUiThemeById(
|
||||
resolvedTheme,
|
||||
resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId,
|
||||
).tokens;
|
||||
return {
|
||||
...buildAppThemeCssVars(tokens, accentMode, customAccent),
|
||||
colorScheme: resolvedTheme,
|
||||
} as React.CSSProperties;
|
||||
}, [accentMode, customAccent, darkUiThemeId, lightUiThemeId, resolvedTheme]);
|
||||
}
|
||||
@@ -267,6 +267,32 @@ test('shared host tree uses the followed terminal theme when follow-app terminal
|
||||
assert.equal(resolved.id, currentTheme.id);
|
||||
});
|
||||
|
||||
test('follow-app host tree applies custom accent onto the published base theme', () => {
|
||||
const currentTheme = makeTheme('app-light', 'light', '#ffffff');
|
||||
const host = {
|
||||
id: 'host-1',
|
||||
label: 'Host',
|
||||
hostname: 'host.local',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
} as Host;
|
||||
|
||||
const resolved = resolveWorkTabHostTreeTheme({
|
||||
activeHostId: host.id,
|
||||
accentMode: 'custom',
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: '0 100% 50%',
|
||||
followAppTerminalTheme: true,
|
||||
hostById: new Map([[host.id, host]]),
|
||||
themeById: new Map([[currentTheme.id, currentTheme]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved.id, currentTheme.id);
|
||||
assert.notEqual(resolved.colors.cursor, currentTheme.colors.cursor);
|
||||
assert.notEqual(resolved, currentTheme);
|
||||
});
|
||||
|
||||
test('shared host tree falls back to the current terminal theme without an active host', () => {
|
||||
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
|
||||
|
||||
|
||||
@@ -179,7 +179,9 @@ export function resolveWorkTabHostTreeTheme({
|
||||
hostById: ReadonlyMap<string, Host>;
|
||||
themeById: ReadonlyMap<string, TerminalTheme>;
|
||||
}): TerminalTheme {
|
||||
if (!activeHostId || followAppTerminalTheme) return currentTerminalTheme;
|
||||
if (!activeHostId || followAppTerminalTheme) {
|
||||
return applyCustomAccentToTerminalTheme(currentTerminalTheme, accentMode, customAccent);
|
||||
}
|
||||
|
||||
const host = hostById.get(activeHostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import en from '../locales/en.ts';
|
||||
import ru from '../locales/ru.ts';
|
||||
import es from '../locales/es.ts';
|
||||
import zhCN from '../locales/zh-CN.ts';
|
||||
import zhTW from '../locales/zh-TW.ts';
|
||||
|
||||
@@ -39,7 +40,7 @@ const keys = [
|
||||
] as const;
|
||||
|
||||
test('convergent sync copy exists in every bundled locale', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, zhCN, zhTW })) {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, 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}`);
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
|
||||
import en from "../locales/en.ts";
|
||||
import ru from "../locales/ru.ts";
|
||||
import es from "../locales/es.ts";
|
||||
import zhCN from "../locales/zh-CN.ts";
|
||||
|
||||
const strategyKeys = [
|
||||
@@ -17,7 +18,7 @@ const strategyKeys = [
|
||||
] as const;
|
||||
|
||||
test("cloud sync strategy copy exists in every bundled locale", () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, zhCN })) {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN })) {
|
||||
for (const key of strategyKeys) {
|
||||
assert.equal(
|
||||
typeof messages[key],
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
|
||||
import en from './en.ts';
|
||||
import ru from './ru.ts';
|
||||
import es from './es.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
@@ -19,7 +20,7 @@ const STEER_KEYS = [
|
||||
] 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 })) {
|
||||
for (const [name, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
|
||||
const missing = STEER_KEYS.filter(key => !messages[key]);
|
||||
assert.deepEqual(missing, [], `${name} is missing Codex steering labels`);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.providers.apiKey.placeholder': 'Enter API key',
|
||||
'ai.providers.apiKey.decrypting': 'Decrypting...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Anthropic-compatible: host with or without /v1 (for example https://gateway.example or https://gateway.example/v1). Detection and chat both use /v1/models and /v1/messages.',
|
||||
'ai.providers.skipTLSVerify': 'Skip TLS certificate verification (for self-signed certs)',
|
||||
'ai.providers.defaultModel': 'Default Model',
|
||||
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
|
||||
@@ -61,6 +62,15 @@ export const enAiMessages: Messages = {
|
||||
'ai.providers.contextWindow.help': 'Leave blank to use the model list value when available, otherwise Netcatty uses a safe default.',
|
||||
'ai.providers.contextWindow.error': 'Enter a positive whole number, or leave it blank.',
|
||||
'ai.providers.refreshModels': 'Refresh models',
|
||||
'ai.providers.test': 'Test',
|
||||
'ai.providers.test.testing': 'Testing…',
|
||||
'ai.providers.test.ok': 'Connected ({latency} ms)',
|
||||
'ai.providers.test.warn': 'Reached endpoint, but response looks incomplete ({latency} ms)',
|
||||
'ai.providers.test.warnSlow': 'Connected, but slow ({latency} ms)',
|
||||
'ai.providers.test.error': 'Failed ({detail})',
|
||||
'ai.providers.test.missingBaseUrl': 'Enter a Base URL first',
|
||||
'ai.providers.test.missingApiKey': 'Enter an API key first',
|
||||
'ai.providers.test.unavailable': 'Connection test is unavailable in this environment',
|
||||
'ai.providers.searchModel': 'Search or type model ID...',
|
||||
'ai.providers.filterModels': 'Filter models...',
|
||||
'ai.providers.loadingModels': 'Loading models...',
|
||||
@@ -404,6 +414,14 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.reject': 'Reject',
|
||||
'ai.chat.toolLabel': 'Tool',
|
||||
'ai.chat.targetLabel': 'Target',
|
||||
'ai.chat.rawCommand': 'Command',
|
||||
'ai.chat.copyCommand': 'Copy',
|
||||
'ai.chat.commandCopied': 'Copied',
|
||||
'ai.chat.approvalSession': 'Session',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': 'Cwd',
|
||||
'ai.chat.approvalReason': 'Reason',
|
||||
'ai.chat.approvalInvocation': 'Invocation',
|
||||
'ai.chat.permissionRequired': 'Permission Required',
|
||||
'ai.chat.permissionDescription': 'The AI agent wants to execute a tool call that requires your approval.',
|
||||
'ai.chat.commandBlocked': 'This command is blocked by your security policy and cannot be executed.',
|
||||
@@ -443,6 +461,8 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.newChat': 'New Chat',
|
||||
'ai.chat.allSessions': 'All Sessions',
|
||||
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
|
||||
'ai.chat.jumpNav': 'Jump to message',
|
||||
'ai.chat.jumpUntitled': '(empty message)',
|
||||
'ai.chat.usedTools': 'Tools used: {n}',
|
||||
'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)',
|
||||
'ai.chat.noSessions': 'No previous sessions',
|
||||
|
||||
@@ -83,14 +83,44 @@ export const enCoreMessages: Messages = {
|
||||
'credentials.protectionUnavailable.message': 'Saved passwords and keys cannot be auto-decrypted on this device. Re-enter credentials before connecting.',
|
||||
'credentials.protectionUnavailable.action': 'Open Settings',
|
||||
|
||||
// App Lock
|
||||
'appLock.title': 'Netcatty is locked',
|
||||
'appLock.reason.default': 'Enter your lock password to continue.',
|
||||
'appLock.reason.startup': 'Netcatty locks each time this window opens.',
|
||||
'appLock.reason.idle': 'Netcatty locked after the inactivity timeout.',
|
||||
'appLock.reason.manual': 'Netcatty was locked manually.',
|
||||
'appLock.passwordLabel': 'Lock password',
|
||||
'appLock.passwordPlaceholder': 'Enter password to unlock',
|
||||
'appLock.unlock': 'Unlock',
|
||||
'appLock.unlocking': 'Unlocking...',
|
||||
'appLock.usePassword': 'Use password instead',
|
||||
'appLock.error.emptyPassword': 'Enter the lock password.',
|
||||
'appLock.error.incorrectPassword': 'Incorrect lock password.',
|
||||
'appLock.systemUnlock.unlockWith': 'Unlock with {label}',
|
||||
'appLock.systemUnlock.preparing': 'Preparing {label}...',
|
||||
'appLock.systemUnlock.verifying': 'Waiting for {label}...',
|
||||
'appLock.systemUnlock.error': 'System unlock was not completed. Use the lock password to continue.',
|
||||
'appLock.logoLabel': 'Netcatty',
|
||||
'appLock.reset.title': 'Reset App Lock',
|
||||
'appLock.reset.description': 'This removes the current lock password and turns off App Lock. Set a new password from Settings when you need it again.',
|
||||
'appLock.reset.cancel': 'Cancel',
|
||||
'appLock.reset.confirm': 'Reset App Lock',
|
||||
'appLock.reset.resetting': 'Resetting...',
|
||||
'appLock.reset.error': 'Could not reset App Lock. Try again.',
|
||||
'topTabs.lockApp': 'Lock Netcatty',
|
||||
|
||||
// Settings shell
|
||||
'settings.title': 'Settings',
|
||||
'settings.tab.application': 'Application',
|
||||
'settings.tab.appearance': 'Appearance',
|
||||
'settings.tab.terminal': 'Terminal',
|
||||
'settings.tab.shortcuts': 'Shortcuts',
|
||||
'settings.tab.ai': 'AI',
|
||||
'settings.tab.syncCloud': 'Sync & Cloud',
|
||||
'settings.tab.system': 'System',
|
||||
'settings.search.open': 'Search settings',
|
||||
'settings.search.placeholder': 'Search settings…',
|
||||
'settings.search.noResults': 'No matching settings',
|
||||
|
||||
// Settings > System
|
||||
'settings.system.title': 'System',
|
||||
@@ -114,6 +144,59 @@ export const enCoreMessages: Messages = {
|
||||
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
|
||||
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
|
||||
|
||||
// Settings > System > App Lock
|
||||
'settings.appLock.title': 'App Lock',
|
||||
'settings.appLock.description': 'Require a local lock password when Netcatty opens, after inactivity, or when you lock it manually.',
|
||||
'settings.appLock.setupTitle': 'Set a lock password',
|
||||
'settings.appLock.setupDescription': 'Create the password below. App Lock turns on immediately after it is saved.',
|
||||
'settings.appLock.manageTitle': 'App Lock is ready',
|
||||
'settings.appLock.enabledStatus': 'Enabled. Netcatty locks on startup, after inactivity, and when you lock it manually.',
|
||||
'settings.appLock.disabledStatus': 'Password saved, but App Lock is currently off.',
|
||||
'settings.appLock.disableTitle': 'Turn off App Lock',
|
||||
'settings.appLock.disableDescription': 'Enter the current lock password to stop locking Netcatty. The saved password will be removed; turning App Lock on again requires creating a new one.',
|
||||
'settings.appLock.disable': 'Turn off App Lock',
|
||||
'settings.appLock.disabling': 'Turning off...',
|
||||
'settings.appLock.setupPasswordTitle': 'Create lock password',
|
||||
'settings.appLock.setupPasswordDescription': 'This password is required to unlock Netcatty and to change or turn off App Lock later.',
|
||||
'settings.appLock.changePasswordTitle': 'Change lock password',
|
||||
'settings.appLock.changePasswordDescription': 'Enter the current lock password, then choose a new one.',
|
||||
'settings.appLock.enable': 'Enable app lock',
|
||||
'settings.appLock.enableDesc': 'Lock on startup and after the selected inactivity timeout.',
|
||||
'settings.appLock.timeout': 'Inactivity timeout',
|
||||
'settings.appLock.timeoutDesc': 'Lock Netcatty when there is no keyboard, pointer, wheel, touch, or focus activity.',
|
||||
'settings.appLock.timeout.0': 'Never lock automatically',
|
||||
'settings.appLock.timeout.1': '1 minute',
|
||||
'settings.appLock.timeout.5': '5 minutes',
|
||||
'settings.appLock.timeout.15': '15 minutes',
|
||||
'settings.appLock.timeout.30': '30 minutes',
|
||||
'settings.appLock.timeout.60': '60 minutes',
|
||||
'settings.appLock.systemUnlock.label': '{label} unlock',
|
||||
'settings.appLock.systemUnlock.desc': 'Use {label} as an alternate way to unlock Netcatty on this device.',
|
||||
'settings.appLock.systemUnlock.unavailableDesc': '{label} is not available on this device right now.',
|
||||
'settings.appLock.systemUnlock.unavailable': 'System unlock is not available on this device.',
|
||||
'settings.appLock.systemUnlock.locked': 'Unlock Netcatty before changing system unlock.',
|
||||
'settings.appLock.systemUnlock.autoPrompt.label': 'Open {label} automatically',
|
||||
'settings.appLock.systemUnlock.autoPrompt.desc': 'Show the {label} prompt automatically when Netcatty is waiting to unlock.',
|
||||
'settings.appLock.currentPassword': 'Current password',
|
||||
'settings.appLock.currentPasswordPlaceholder': 'Required to disable or replace',
|
||||
'settings.appLock.currentPasswordForDisablePlaceholder': 'Current password to turn off App Lock',
|
||||
'settings.appLock.currentPasswordForChangePlaceholder': 'Current password to change it',
|
||||
'settings.appLock.newPassword': 'New password',
|
||||
'settings.appLock.newPasswordPlaceholder': 'Set lock password',
|
||||
'settings.appLock.confirmPassword': 'Confirm password',
|
||||
'settings.appLock.confirmPasswordPlaceholder': 'Repeat password',
|
||||
'settings.appLock.savePassword': 'Set and enable',
|
||||
'settings.appLock.savingPassword': 'Saving...',
|
||||
'settings.appLock.passwordSet': 'Password configured',
|
||||
'settings.appLock.replacePassword': 'Replace password',
|
||||
'settings.appLock.enableAfterPassword': 'Set a lock password before enabling app lock.',
|
||||
'settings.appLock.localOnlyHint': 'Device-local privacy lock. It is not synced and is not an OS security boundary.',
|
||||
'settings.appLock.validation.currentRequired': 'Enter the current lock password.',
|
||||
'settings.appLock.validation.newRequired': 'Enter a new lock password.',
|
||||
'settings.appLock.validation.confirmRequired': 'Confirm the new lock password.',
|
||||
'settings.appLock.validation.mismatch': 'The new passwords do not match.',
|
||||
'settings.appLock.validation.incorrect': 'The current lock password is incorrect.',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': 'Network Proxy',
|
||||
'settings.system.networkProxy.description': 'HTTP(S) proxy for cloud sync and AI providers. This is separate from SSH ProxyJump / ProxyCommand profiles.',
|
||||
@@ -169,6 +252,10 @@ export const enCoreMessages: Messages = {
|
||||
|
||||
// Settings > Session Restore
|
||||
'settings.sessionRestore.title': 'Session Restore',
|
||||
'settings.sessionRestore.startupLanding': 'On launch',
|
||||
'settings.sessionRestore.startupLandingDesc': 'Choose whether to open the host home page or a local terminal when Netcatty starts with no restored sessions.',
|
||||
'settings.sessionRestore.startupLanding.vault': 'Home (hosts)',
|
||||
'settings.sessionRestore.startupLanding.localTerminal': 'Local terminal',
|
||||
'settings.sessionRestore.restorePreviousSession': 'Restore previous tabs and workspaces',
|
||||
'settings.sessionRestore.restorePreviousSessionDesc': 'Reopen terminal tabs and workspace layouts from the last app session.',
|
||||
'settings.sessionRestore.restoreTerminalCwd': 'Restore terminal working directory',
|
||||
@@ -197,6 +284,9 @@ export const enCoreMessages: Messages = {
|
||||
'settings.sessionLogs.timestamps': 'Add timestamps',
|
||||
'settings.sessionLogs.timestampsDesc': 'Prefix each line in plain text and HTML logs with the local time.',
|
||||
'settings.sessionLogs.hint': 'Session logs capture all terminal output for troubleshooting and auditing purposes.',
|
||||
'settings.sessionLogs.clearAll': 'Clear all logs',
|
||||
'settings.sessionLogs.clearAllDesc': 'Delete all saved session log files and host subdirectories from the save directory.',
|
||||
'settings.sessionLogs.clearConfirm': 'This will permanently delete all session log files in the save directory. This cannot be undone. Continue?',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
'settings.sshDebugLogs.title': 'SSH Debug Logs',
|
||||
@@ -231,6 +321,7 @@ export const enCoreMessages: Messages = {
|
||||
'tray.status.active': 'Active',
|
||||
'tray.status.inactive': 'Inactive',
|
||||
'tray.status.error': 'Error',
|
||||
'tray.status.unknown': 'Unknown',
|
||||
'tray.recentHosts': 'Recent Hosts',
|
||||
'tray.empty.title': 'Nothing here yet',
|
||||
'tray.empty.subtitle': 'Go connect to a server, they miss you 🚀',
|
||||
@@ -598,6 +689,12 @@ Highlight the focused split pane:
|
||||
'settings.terminal.behavior.dynamicTabTitle.off': 'Disabled',
|
||||
'settings.terminal.behavior.dynamicTabTitle.agent': 'Agents only',
|
||||
'settings.terminal.behavior.dynamicTabTitle.all': 'All sessions',
|
||||
'settings.terminal.behavior.oscNotifications': 'OSC desktop notifications',
|
||||
'settings.terminal.behavior.oscNotifications.desc':
|
||||
'Show a system notification when a program emits OSC 9, OSC 777 notify, or kitty OSC 99. Used by Codex and other agents for turn-complete and approval alerts.',
|
||||
'settings.terminal.behavior.oscNotifications.off': 'Disabled',
|
||||
'settings.terminal.behavior.oscNotifications.unfocused': 'When unfocused',
|
||||
'settings.terminal.behavior.oscNotifications.always': 'Always',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 clipboard',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'Allow remote programs (tmux, vim, etc.) to access the local clipboard via OSC-52 escape sequences.',
|
||||
@@ -781,6 +878,8 @@ Highlight the focused split pane:
|
||||
'settings.shortcuts.disableTerminalFontZoom.desc': 'Turn off terminal font zoom shortcuts, including Cmd/Ctrl + mouse wheel.',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Tab shortcuts skip pinned tabs',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'When enabled, Cmd/Ctrl+[1...9] and Ctrl+Tab switch only work tabs (terminals, workspaces, editors), not the pinned Vault or SFTP tabs.',
|
||||
'settings.shortcuts.showTabNumberBadges.label': 'Show tab number badges',
|
||||
'settings.shortcuts.showTabNumberBadges.desc': 'While holding Cmd/Ctrl, show 1-9 on tabs that match Cmd/Ctrl+[1...9], so you can jump accurately.',
|
||||
'settings.shortcuts.section.custom': 'Custom Shortcuts',
|
||||
'settings.shortcuts.resetAll': 'Reset All',
|
||||
'settings.shortcuts.recording': 'Press keys...',
|
||||
|
||||
@@ -18,8 +18,11 @@ export const enScriptsMessages = {
|
||||
'scripts.editor.modalSubtitle': 'Edit metadata and script code in a larger workspace.',
|
||||
'scripts.editor.lineCount': '{count} lines',
|
||||
'scripts.editor.resize': 'Resize editor',
|
||||
'scripts.targets.hint': 'Automatic triggers apply to selected hosts, or to all hosts when that option is enabled.',
|
||||
'scripts.targets.hint': 'Selected groups are resolved dynamically, so hosts added later are included automatically.',
|
||||
'scripts.targets.connectOrderHint': 'Run order for connect scripts is configured per host under Host details → Automation.',
|
||||
'scripts.targets.currentHostMismatch': 'This script is not assigned to the current host.',
|
||||
'hostDetails.automation.groupScripts': 'Inherited group scripts',
|
||||
'hostDetails.automation.groupScriptsHint': 'These scripts follow the host group dynamically and are ordered before the host-specific queue.',
|
||||
'scripts.actions.runNowHint': 'Run on selected targets, or on all connectable hosts when that option is enabled.',
|
||||
'scripts.actions.runParallel': 'Run on all tabs (parallel)',
|
||||
'scripts.actions.runSequential': 'Run on all tabs (sequential)',
|
||||
|
||||
@@ -8,8 +8,12 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.empty': 'No data available.',
|
||||
'systemManager.tabs.overview': 'Overview',
|
||||
'systemManager.tabs.processes': 'Processes',
|
||||
'systemManager.tabs.ports': 'Ports',
|
||||
'systemManager.tabs.services': 'Services',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.tabs.ariaLabel': 'System manager sections',
|
||||
'systemManager.popup.loading': 'Opening terminal…',
|
||||
'systemManager.popup.startupFailed': 'The startup command did not complete successfully. Check that the target is still available and try again.',
|
||||
|
||||
@@ -23,6 +27,9 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
|
||||
'systemManager.errors.loadDockerImages': 'Failed to load images',
|
||||
'systemManager.errors.loadOverview': 'Failed to load system overview',
|
||||
'systemManager.errors.loadGpu': 'Failed to load GPU / NPU stats',
|
||||
'systemManager.errors.loadPorts': 'Failed to load listening ports',
|
||||
'systemManager.errors.loadServices': 'Failed to load systemd services',
|
||||
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
|
||||
|
||||
'systemManager.overview.empty': 'No system overview data yet.',
|
||||
@@ -187,6 +194,51 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.start': 'Start',
|
||||
'systemManager.docker.stop': 'Stop',
|
||||
|
||||
'systemManager.ports.unavailable': 'No listening-port tools (ss / netstat) detected on this host.',
|
||||
'systemManager.ports.loading': 'Loading listening ports…',
|
||||
'systemManager.ports.empty': 'No listening ports reported.',
|
||||
'systemManager.ports.search': 'Search ports…',
|
||||
'systemManager.ports.meta': '{{count}} listener(s)',
|
||||
'systemManager.ports.filter.all': 'All',
|
||||
'systemManager.ports.unknownProcess': 'Unknown process',
|
||||
'systemManager.ports.terminate': 'Terminate',
|
||||
'systemManager.ports.confirmTerminate': 'Send SIGTERM to process {{pid}} that holds this port?',
|
||||
|
||||
'systemManager.services.unavailable': 'systemctl is not available on this host.',
|
||||
'systemManager.services.loading': 'Loading systemd services…',
|
||||
'systemManager.services.empty': 'No systemd services found.',
|
||||
'systemManager.services.search': 'Search services…',
|
||||
'systemManager.services.meta': '{{count}} service(s)',
|
||||
'systemManager.services.filter.all': 'All',
|
||||
'systemManager.services.filter.running': 'Running',
|
||||
'systemManager.services.filter.failed': 'Failed',
|
||||
'systemManager.services.filter.inactive': 'Inactive',
|
||||
'systemManager.services.start': 'Start',
|
||||
'systemManager.services.stop': 'Stop',
|
||||
'systemManager.services.restart': 'Restart',
|
||||
'systemManager.services.enable': 'Enable',
|
||||
'systemManager.services.disable': 'Disable',
|
||||
'systemManager.services.reload': 'Reload',
|
||||
'systemManager.services.scope.user': 'user',
|
||||
'systemManager.services.confirmAction': '{{action}} {{name}}?',
|
||||
|
||||
'systemManager.gpu.unavailable': 'No NVIDIA GPU or Ascend NPU tools detected on this host.',
|
||||
'systemManager.gpu.loading': 'Loading accelerator stats…',
|
||||
'systemManager.gpu.empty': 'Accelerator tools are present, but no devices were reported.',
|
||||
'systemManager.gpu.meta': '{{devices}} device(s) · {{processes}} process(es)',
|
||||
'systemManager.gpu.devices': 'Devices',
|
||||
'systemManager.gpu.processes': 'Compute processes',
|
||||
'systemManager.gpu.noProcesses': 'No compute processes reported.',
|
||||
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
|
||||
'systemManager.gpu.vendor.ascend': 'Ascend',
|
||||
'systemManager.gpu.util': 'Util',
|
||||
'systemManager.gpu.memory': 'VRAM',
|
||||
'systemManager.gpu.hbm': 'HBM',
|
||||
'systemManager.gpu.temperature': 'Temperature',
|
||||
'systemManager.gpu.power': 'Power',
|
||||
'systemManager.gpu.fan': 'Fan {{value}}%',
|
||||
'systemManager.gpu.driver': 'Driver {{version}}',
|
||||
|
||||
'systemManager.inspect.status': 'Status',
|
||||
'systemManager.inspect.image': 'Image',
|
||||
'systemManager.inspect.created': 'Created',
|
||||
|
||||
@@ -82,6 +82,10 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.statusbar.copyHostname.tooltip': 'Copy host address ({hostname})',
|
||||
'terminal.statusbar.copyHostname.toast': 'Copied host address: {hostname}',
|
||||
'terminal.statusbar.copyHostname.error': 'Failed to copy host address to clipboard',
|
||||
'terminal.statusbar.disconnect.label': 'Disconnect',
|
||||
'terminal.statusbar.disconnect.tooltip': 'Disconnect this session without closing the tab',
|
||||
'terminal.statusbar.reconnect.label': 'Reconnect',
|
||||
'terminal.statusbar.reconnect.tooltip': 'Reconnect this session',
|
||||
'terminal.serverStats.cpu': 'CPU Usage',
|
||||
'terminal.serverStats.cpuCores': 'CPU Core Usage',
|
||||
'terminal.serverStats.memory': 'Memory Usage',
|
||||
@@ -655,6 +659,9 @@ export const enTerminalMessages: Messages = {
|
||||
'snippets.scriptEditor.modalTitle': 'Edit script',
|
||||
'snippets.targets.title': 'Targets',
|
||||
'snippets.targets.add': 'Add targets',
|
||||
'snippets.targets.selectHosts': 'Hosts',
|
||||
'snippets.targets.selectGroups': 'Groups',
|
||||
'snippets.targets.noGroups': 'No groups found',
|
||||
'snippets.targets.allHosts': 'Apply to all hosts',
|
||||
'snippets.targets.allHostsShort': 'All hosts',
|
||||
'snippets.targets.allHostsActive': 'Applies to every connectable host.',
|
||||
@@ -741,7 +748,7 @@ export const enTerminalMessages: Messages = {
|
||||
'snippets.shortkey.recording': 'Press a key combination...',
|
||||
'snippets.shortkey.hint': 'Press this shortcut in terminal to quickly send the command.',
|
||||
'snippets.shortkey.clear': 'Clear shortcut',
|
||||
'snippets.shortkey.error.systemConflict': 'This shortcut conflicts with a system shortcut',
|
||||
'snippets.shortkey.error.systemConflict': 'This shortcut conflicts with {name}',
|
||||
'snippets.shortkey.error.snippetConflict': 'This shortcut is already used by snippet: {name}',
|
||||
|
||||
'snippets.variables.dialogTitle': 'Snippet variables',
|
||||
|
||||
@@ -229,6 +229,10 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.moveTo.confirm': 'Move',
|
||||
'sftp.moveTo.pathNotFound': 'Directory not found or inaccessible',
|
||||
'sftp.context.download': 'Download',
|
||||
'sftp.context.extract': 'Extract here',
|
||||
'sftp.extract.extracting': 'Extracting {fileName}...',
|
||||
'sftp.extract.success': 'Extracted {fileName}',
|
||||
'sftp.extract.error': 'Failed to extract {fileName}: {error}',
|
||||
'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',
|
||||
@@ -268,6 +272,8 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.transfers.calculatingTotal': 'Calculating total size...',
|
||||
'sftp.transfers.filesCount': '{count} files',
|
||||
'sftp.transfers.filesProgress': '{current}/{total} files',
|
||||
// Progressive folder walk: total is "found so far", not a fixed grand total.
|
||||
'sftp.transfers.filesDiscoveredProgress': '{completed} done · {discovered} found',
|
||||
'sftp.transfers.expandChildren': 'Show files',
|
||||
'sftp.transfers.collapseChildren': 'Hide files',
|
||||
'sftp.transfers.expandChildList': 'Show detail',
|
||||
@@ -323,6 +329,7 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.transfers.dragToResize': 'Drag to resize',
|
||||
'sftp.goUp': 'Go up',
|
||||
'sftp.goToTerminalCwd': 'Go to terminal directory',
|
||||
'sftp.locatePathInTerminal': 'Open path in terminal',
|
||||
'sftp.followTerminalCwd': 'Follow terminal directory',
|
||||
'sftp.followTerminalCwd.enable': 'Enable follow terminal directory',
|
||||
'sftp.followTerminalCwd.disable': 'Disable follow terminal directory',
|
||||
@@ -486,7 +493,7 @@ export const enVaultMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host',
|
||||
'settings.sshDeepLink.title': 'SSH and Telnet links',
|
||||
'settings.sshDeepLink.enable': 'Open ssh:// and telnet:// links with Netcatty',
|
||||
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// and telnet:// links from browsers and other apps.',
|
||||
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// and telnet:// links from browsers and other apps, plus PuTTY-style launches such as -ssh user@host -P 22 -pw password.',
|
||||
'settings.explorerContextMenu.title': 'Windows Explorer',
|
||||
'settings.explorerContextMenu.enable': 'Show “Open in Netcatty” in folder context menus',
|
||||
'settings.explorerContextMenu.enableDesc': 'Adds a right-click item for folders and folder backgrounds so you can open a local terminal there. Applies immediately; no reinstall needed.',
|
||||
@@ -560,6 +567,9 @@ export const enVaultMessages: Messages = {
|
||||
'settings.sftp.compressedUpload.enable': 'Enable folder compression',
|
||||
'settings.sftp.compressedUpload.enableDesc': 'Automatically compress folders using tar before transfer. Requires tar support on the server. Falls back to regular transfer if not available.',
|
||||
|
||||
'settings.sftp.skipUnchanged.enable': 'Skip unchanged files',
|
||||
'settings.sftp.skipUnchanged.enableDesc': 'Skip files whose size and modification time already match the destination (rsync-style). Disabled when replacing a folder into a staging path.',
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Search hosts or tabs',
|
||||
'qs.jumpTo': 'Jump To',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Messages } from './types';
|
||||
import { esCoreMessages } from './es/core';
|
||||
import { esVaultMessages } from './es/vault';
|
||||
import { esTerminalMessages } from './es/terminal';
|
||||
import { esAiMessages } from './es/ai';
|
||||
import { esSystemManagerMessages } from './es/systemManager';
|
||||
import { esScriptsMessages } from './es/scripts';
|
||||
|
||||
export type { Messages } from './types';
|
||||
|
||||
const es: Messages = {
|
||||
...esCoreMessages,
|
||||
...esVaultMessages,
|
||||
...esTerminalMessages,
|
||||
...esAiMessages,
|
||||
...esSystemManagerMessages,
|
||||
...esScriptsMessages,
|
||||
};
|
||||
|
||||
export default es;
|
||||
@@ -0,0 +1,638 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Configuración del agente',
|
||||
'ai.chat.preparing': 'Preparando…',
|
||||
'ai.chat.compactingContext': 'Compactando el contexto anterior…',
|
||||
'ai.chat.compactingStep': 'Recortando el contexto para el siguiente paso…',
|
||||
'ai.chat.compactionRetry': 'La petición era demasiado grande. Compactando el contexto y reintentando…',
|
||||
'ai.chat.compactionBanner': 'Contexto compactado: {before}K → {after}K tokens',
|
||||
'ai.chat.contextUsage': 'Uso de contexto: {used} / {max} tokens',
|
||||
'ai.chat.activity.title': 'Actividad del agente',
|
||||
'ai.chat.activity.plan': 'Plan',
|
||||
'ai.chat.activity.webSearch': 'Búsqueda web',
|
||||
'ai.chat.activity.fileChanges': 'Cambios de archivos',
|
||||
'ai.chat.activity.status.running': 'En ejecución',
|
||||
'ai.chat.activity.status.completed': 'Completado',
|
||||
'ai.chat.activity.status.failed': 'Falló',
|
||||
'ai.chat.activity.file.add': 'Agregar',
|
||||
'ai.chat.activity.file.update': 'Actualizar',
|
||||
'ai.chat.activity.file.delete': 'Eliminar',
|
||||
'ai.chat.activity.usage': 'Tokens',
|
||||
'ai.chat.activity.usage.input': 'entrada',
|
||||
'ai.chat.activity.usage.output': 'salida',
|
||||
'ai.chat.activity.usage.cached': 'en caché',
|
||||
'ai.chat.activity.usage.reasoning': 'razonamiento',
|
||||
'ai.title': 'IA',
|
||||
'ai.description': 'Configura proveedores de IA, agentes y ajustes de seguridad',
|
||||
'ai.providers': 'Proveedores',
|
||||
'ai.agents': 'Agentes',
|
||||
'ai.providers.empty': 'No hay proveedores configurados. Agrega un proveedor para comenzar.',
|
||||
'ai.providers.add': 'Agregar proveedor',
|
||||
'ai.providers.active': 'Activo',
|
||||
'ai.providers.apiKeyConfigured': 'Clave de API configurada',
|
||||
'ai.providers.noApiKey': 'Sin clave de API',
|
||||
'ai.providers.configure': 'Configurar',
|
||||
'ai.providers.remove': 'Quitar',
|
||||
'ai.providers.name': 'Nombre para mostrar',
|
||||
'ai.providers.name.placeholder': 'p. ej. Mi proveedor',
|
||||
'ai.providers.style': 'Estilo de protocolo',
|
||||
'ai.providers.style.anthropic': 'Compatible con Anthropic',
|
||||
'ai.providers.style.openai': 'Compatible con OpenAI',
|
||||
'ai.providers.style.google': 'Compatible con Google',
|
||||
'ai.providers.style.inherited': 'auto',
|
||||
'ai.providers.style.help': 'Selecciona qué formato de API usan las peticiones. Anúlalo cuando un endpoint de terceros hable un dialecto distinto al que sugiere su tipo de proveedor.',
|
||||
'ai.providers.icon.change': 'Cambiar ícono',
|
||||
'ai.providers.icon.upload': 'Subir imagen',
|
||||
'ai.providers.icon.reset': 'Restablecer',
|
||||
'ai.providers.icon.close': 'Cerrar',
|
||||
'ai.providers.icon.uploadedNote': 'Ícono personalizado (64×64 WebP)',
|
||||
'ai.providers.icon.errorType': 'Elige un archivo de imagen.',
|
||||
'ai.providers.apiKey': 'Clave de API',
|
||||
'ai.providers.apiKey.placeholder': 'Ingresa la clave de API',
|
||||
'ai.providers.apiKey.decrypting': 'Descifrando...',
|
||||
'ai.providers.baseUrl': 'URL base',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Compatible con Anthropic: host con o sin /v1 (por ejemplo https://gateway.example o https://gateway.example/v1). La detección y el chat usan /v1/models y /v1/messages.',
|
||||
'ai.providers.skipTLSVerify': 'Omitir la verificación del certificado TLS (para certificados autofirmados)',
|
||||
'ai.providers.defaultModel': 'Modelo predeterminado',
|
||||
'ai.providers.defaultModel.placeholder': 'p. ej. gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.contextWindow': 'Ventana de contexto',
|
||||
'ai.providers.contextWindow.placeholder': 'p. ej. 128000',
|
||||
'ai.providers.contextWindow.help': 'Déjalo en blanco para usar el valor de la lista de modelos cuando esté disponible; de lo contrario, Netcatty usa un valor predeterminado seguro.',
|
||||
'ai.providers.contextWindow.error': 'Ingresa un número entero positivo o déjalo en blanco.',
|
||||
'ai.providers.refreshModels': 'Actualizar modelos',
|
||||
'ai.providers.test': 'Probar',
|
||||
'ai.providers.test.testing': 'Probando…',
|
||||
'ai.providers.test.ok': 'Conectado ({latency} ms)',
|
||||
'ai.providers.test.warn': 'Se alcanzó el endpoint, pero la respuesta parece incompleta ({latency} ms)',
|
||||
'ai.providers.test.warnSlow': 'Conectado, pero lento ({latency} ms)',
|
||||
'ai.providers.test.error': 'Falló ({detail})',
|
||||
'ai.providers.test.missingBaseUrl': 'Primero ingresa una URL base',
|
||||
'ai.providers.test.missingApiKey': 'Primero ingresa una clave de API',
|
||||
'ai.providers.test.unavailable': 'La prueba de conexión no está disponible en este entorno',
|
||||
'ai.providers.searchModel': 'Busca o escribe el ID del modelo...',
|
||||
'ai.providers.filterModels': 'Filtrar modelos...',
|
||||
'ai.providers.loadingModels': 'Cargando modelos...',
|
||||
'ai.providers.noMatchingModels': 'No hay modelos que coincidan',
|
||||
'ai.providers.clickToLoadModels': 'Haz clic para cargar los modelos',
|
||||
'ai.providers.showingModels': 'Mostrando los primeros 100 de {count} modelos. Escribe para filtrar.',
|
||||
'ai.providers.advancedParams': 'Parámetros avanzados',
|
||||
'ai.providers.advancedParams.hint': 'Déjalo en blanco para usar los valores predeterminados del proveedor.',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': 'p. ej. 4096',
|
||||
'ai.providers.advancedParams.default': 'Predeterminado del proveedor',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': 'Conecta OpenAI Codex. Inicia sesión con ChatGPT aquí, o habilita una clave de API de un proveedor compatible con OpenAI y un endpoint personalizado en Configuración.',
|
||||
'ai.codex.appServer.title': 'Usar Codex App Server',
|
||||
'ai.codex.appServer.experimental': 'Experimental',
|
||||
'ai.codex.appServer.description': 'Usa el protocolo persistente de Codex para aprobaciones nativas, controles de sandbox, modelos en vivo y preguntas a mitad de turno. El SDK sigue siendo el predeterminado.',
|
||||
'ai.codex.appServer.checking': 'Comprobando la compatibilidad con App Server…',
|
||||
'ai.codex.appServer.available': 'App Server está disponible para esta Codex CLI.',
|
||||
'ai.codex.appServer.modelCatalogWarning': 'El catálogo de modelos de Codex en vivo no está disponible. Se usa la lista de modelos integrada.',
|
||||
'ai.codex.appServer.approval.allowSession': 'Permitir por sesión',
|
||||
'ai.codex.appServer.userInput.title': 'Codex necesita tu aporte',
|
||||
'ai.codex.appServer.userInput.description': 'Responde estas preguntas para continuar el turno actual.',
|
||||
'ai.codex.appServer.userInput.other': 'Ingresa otra respuesta',
|
||||
'ai.codex.appServer.userInput.autoResolve': 'Codex continuará automáticamente si no se proporciona una respuesta a tiempo.',
|
||||
'ai.codex.appServer.userInput.skip': 'Omitir',
|
||||
'ai.codex.appServer.userInput.submit': 'Continuar',
|
||||
'ai.codex.steer.addInstruction': 'Agregar instrucción',
|
||||
'ai.codex.steer.sending': 'Agregando instrucción…',
|
||||
'ai.codex.steer.placeholder': 'Agrega una instrucción mientras Codex está trabajando…',
|
||||
'ai.codex.steer.notSteerableReview': 'Este turno de revisión de Codex no puede aceptar instrucciones adicionales. Tu borrador se conservó.',
|
||||
'ai.codex.steer.notSteerableCompact': 'Este turno de compactación de Codex no puede aceptar instrucciones adicionales. Tu borrador se conservó.',
|
||||
'ai.codex.steer.busy': 'Ya se está enviando otra instrucción a Codex.',
|
||||
'ai.codex.steer.inactive': 'El turno de Codex ya terminó. Tu borrador se conservó.',
|
||||
'ai.codex.steer.unsupported': 'Las instrucciones en curso requieren el runtime de Codex App Server.',
|
||||
'ai.codex.steer.failed': 'Codex no pudo aceptar la instrucción adicional. Tu borrador se conservó.',
|
||||
'ai.codex.detecting': 'Detectando...',
|
||||
'ai.codex.notFound': 'No encontrado',
|
||||
'ai.codex.awaitingLogin': 'Esperando inicio de sesión',
|
||||
'ai.codex.connectedChatGPT': 'Conectado mediante ChatGPT',
|
||||
'ai.codex.connectedApiKey': 'Conectado mediante clave de API',
|
||||
'ai.codex.connectedCustomConfig': 'Conectado mediante ~/.codex/config.toml',
|
||||
'ai.codex.customConfigIncomplete': 'Configuración personalizada detectada (falta la variable de entorno)',
|
||||
'ai.codex.customConfigHint': 'Usando el proveedor personalizado "{provider}" configurado en ~/.codex/config.toml: no se necesita inicio de sesión con ChatGPT.',
|
||||
'ai.codex.customConfigMissingEnvKey': 'Advertencia: {envKey} no está definida en tu entorno de shell. Expórtala (o inicia netcatty desde un shell que la tenga) para que Codex pueda autenticarse.',
|
||||
'ai.codex.notConnected': 'No conectado',
|
||||
'ai.codex.statusUnknown': 'Estado desconocido',
|
||||
'ai.codex.path': 'Ruta:',
|
||||
'ai.codex.notFoundHint': 'No se pudo encontrar codex en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
|
||||
'ai.codex.customPathPlaceholder': 'p. ej. /usr/local/bin/codex',
|
||||
'ai.codex.check': 'Verificar',
|
||||
'ai.codex.resetPath': 'Restablecer',
|
||||
'ai.codex.openLogin': 'Abrir inicio de sesión',
|
||||
'ai.codex.logout': 'Cerrar sesión',
|
||||
'ai.codex.connectChatGPT': 'Conectar ChatGPT',
|
||||
'ai.codex.refreshStatus': 'Actualizar estado',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': "El asistente de codificación agéntica de Anthropic. Requiere la CLI de Claude Code del sistema.",
|
||||
'ai.claude.detecting': 'Detectando...',
|
||||
'ai.claude.detected': 'Detectado',
|
||||
'ai.claude.notFound': 'No encontrado',
|
||||
'ai.claude.path': 'Ruta:',
|
||||
'ai.claude.notFoundHint': 'No se pudo encontrar claude en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
|
||||
'ai.claude.customPathPlaceholder': 'p. ej. /usr/local/bin/claude',
|
||||
'ai.claude.configSection': 'Autenticación y configuración (opcional)',
|
||||
'ai.claude.configDir': 'Directorio de configuración',
|
||||
'ai.claude.configDir.placeholder': '~/.claude (déjalo en blanco para usar el predeterminado)',
|
||||
'ai.claude.configDir.hint': 'Define CLAUDE_CONFIG_DIR: apunta a una carpeta donde hayas ejecutado el inicio de sesión `claude` (contiene settings.json + credenciales).',
|
||||
'ai.claude.settings': 'Archivo de configuración',
|
||||
'ai.claude.settings.placeholder': '~/team-settings.json (ruta o JSON en línea {"model":"..."})',
|
||||
'ai.claude.settings.hint': 'Opcional. Una ruta a settings.json o un JSON en línea, pasado al SDK como `settings`. Se suma a — y es independiente de — el directorio de configuración anterior (se combina por encima, no lo reemplaza).',
|
||||
'ai.claude.envVars': 'Variables de entorno',
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': 'Una KEY=VALUE por línea, pasada al agente de Claude. Se almacena localmente en texto plano; para claves de API o credenciales, prefiere el directorio de configuración anterior (un inicio de sesión `claude`).',
|
||||
'ai.claude.check': 'Verificar',
|
||||
'ai.claude.resetPath': 'Restablecer',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
'ai.copilot.description': 'Usa la CLI de GitHub Copilot. Una vez detectada, se puede seleccionar como agente de codificación externo.',
|
||||
'ai.copilot.detecting': 'Detectando...',
|
||||
'ai.copilot.detected': 'Detectado',
|
||||
'ai.copilot.notFound': 'No encontrado',
|
||||
'ai.copilot.path': 'Ruta:',
|
||||
'ai.copilot.notFoundHint': 'No se pudo encontrar copilot en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
|
||||
'ai.copilot.customPathPlaceholder': 'p. ej. /usr/local/bin/copilot',
|
||||
'ai.copilot.check': 'Verificar',
|
||||
'ai.copilot.resetPath': 'Restablecer',
|
||||
|
||||
// AI Cursor SDK
|
||||
'ai.cursor.title': 'Cursor',
|
||||
'ai.cursor.description': 'Usa el SDK de Cursor o el inicio de sesión local de la CLI de Agent.',
|
||||
'ai.cursor.detecting': 'Detectando...',
|
||||
'ai.cursor.detected': 'Disponible',
|
||||
'ai.cursor.notFound': 'No disponible',
|
||||
'ai.cursor.path': 'Runtime:',
|
||||
'ai.cursor.notFoundHint': 'Ingresa una clave de API para habilitar Cursor o cambia al inicio de sesión por CLI.',
|
||||
'ai.cursor.notInstalledHint': 'No se detectó el SDK de Cursor ni la CLI de Agent.',
|
||||
'ai.cursor.installStatus': 'Runtime de Cursor',
|
||||
'ai.cursor.installed': 'Detectado',
|
||||
'ai.cursor.notInstalled': 'No detectado',
|
||||
'ai.cursor.modeCli': 'Inicio de sesión por CLI',
|
||||
'ai.cursor.modeApiKey': 'Clave de API',
|
||||
'ai.cursor.modeCliHint': 'Usa tu sesión local de `cursor-agent login` y la cuota Automática de la suscripción. La clave de API guardada se conserva, pero no se usa en este modo.',
|
||||
'ai.cursor.modeApiKeyHint': 'Usa la API de Cursor medida. El inicio de sesión por CLI se ignora mientras este modo esté activo.',
|
||||
'ai.cursor.cliLoginStatus': 'Inicio de sesión por CLI',
|
||||
'ai.cursor.cliLoginOk': 'Sesión iniciada',
|
||||
'ai.cursor.cliLoginAs': 'Sesión iniciada como {{email}}',
|
||||
'ai.cursor.cliLoginMissing': 'Sin sesión iniciada',
|
||||
'ai.cursor.cliLoginHint': 'Ejecuta `cursor-agent login` en una terminal y luego haz clic en Verificar.',
|
||||
'ai.cursor.apiKeyStatus': 'Clave de API',
|
||||
'ai.cursor.apiKeyConfigured': 'Configurada',
|
||||
'ai.cursor.apiKeyMissing': 'Faltante',
|
||||
'ai.cursor.apiKeyFromEnv': 'Desde el entorno',
|
||||
'ai.cursor.apiKey': 'Clave de API',
|
||||
'ai.cursor.apiKeyPlaceholder': 'Ingresa la clave de API de Cursor',
|
||||
'ai.cursor.apiKeyPlaceholder.env': 'Usando CURSOR_API_KEY; ingresa una clave para anularla',
|
||||
'ai.cursor.apiKeyEnvHint': 'Cursor puede usar CURSOR_API_KEY de tu shell. Guarda una clave aquí solo si quieres que Netcatty la anule.',
|
||||
'ai.cursor.apiKeyOverrideHint': 'Netcatty usará la clave guardada aquí antes que CURSOR_API_KEY.',
|
||||
'ai.cursor.saveApiKey': 'Guardar',
|
||||
'ai.cursor.saved': 'Guardada',
|
||||
'ai.cursor.showApiKey': 'Mostrar clave de API',
|
||||
'ai.cursor.hideApiKey': 'Ocultar clave de API',
|
||||
'ai.cursor.customPathPlaceholder': 'p. ej. /usr/local/bin/cursor',
|
||||
'ai.cursor.check': 'Verificar',
|
||||
|
||||
// AI CodeBuddy Code
|
||||
'ai.codebuddy.title': 'CodeBuddy Code',
|
||||
'ai.codebuddy.description': 'Usa CodeBuddy Code mediante el SDK oficial de Agent (`@tencent-ai/agent-sdk`). Una vez detectado, se puede seleccionar como agente de codificación externo.',
|
||||
'ai.codebuddy.detecting': 'Detectando...',
|
||||
'ai.codebuddy.detected': 'Detectado',
|
||||
'ai.codebuddy.notFound': 'No encontrado',
|
||||
'ai.codebuddy.path': 'Ruta:',
|
||||
'ai.codebuddy.notFoundHint': 'No se pudo encontrar codebuddy en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
|
||||
'ai.codebuddy.customPathPlaceholder': 'p. ej. /usr/local/bin/codebuddy',
|
||||
'ai.codebuddy.check': 'Verificar',
|
||||
'ai.codebuddy.resetPath': 'Restablecer',
|
||||
'ai.codebuddy.configSection': 'Autenticación y configuración (opcional)',
|
||||
'ai.codebuddy.internetEnv': 'Entorno de Internet',
|
||||
'ai.codebuddy.internetEnv.default': 'Predeterminado (internacional)',
|
||||
'ai.codebuddy.internetEnv.internal': 'Interno',
|
||||
'ai.codebuddy.internetEnv.ioa': 'IOA',
|
||||
'ai.codebuddy.internetEnv.hint': 'Define CODEBUDDY_INTERNET_ENVIRONMENT: elige Interno o IOA para entornos de red restringidos.',
|
||||
'ai.codebuddy.envVars': 'Variables de entorno',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': 'Una KEY=VALUE por línea, pasada al agente de CodeBuddy. Define CODEBUDDY_API_KEY o CODEBUDDY_AUTH_TOKEN aquí para la autenticación. Se almacena localmente en texto plano.',
|
||||
'ai.codebuddy.advancedSection': 'Opciones avanzadas (SDK 0.3.230)',
|
||||
'ai.codebuddy.effort': 'Esfuerzo de razonamiento',
|
||||
'ai.codebuddy.effort.default': 'Predeterminado',
|
||||
'ai.codebuddy.effort.low': 'Bajo',
|
||||
'ai.codebuddy.effort.medium': 'Medio',
|
||||
'ai.codebuddy.effort.high': 'Alto',
|
||||
'ai.codebuddy.effort.xhigh': 'XHigh',
|
||||
'ai.codebuddy.effort.hint': 'Controla la profundidad de razonamiento del modelo. Usa Bajo para comandos simples y ahorrar tokens, y Alto/XHigh para diagnósticos complejos.',
|
||||
'ai.codebuddy.maxTurns': 'Máximo de turnos',
|
||||
'ai.codebuddy.maxTurns.hint': 'Limita el máximo de turnos de conversación por petición para evitar bucles descontrolados. Déjalo vacío para usar el predeterminado.',
|
||||
'ai.codebuddy.maxBudget': 'Presupuesto máximo (USD)',
|
||||
'ai.codebuddy.maxBudget.hint': 'Gasto máximo (USD) por petición. Se detiene automáticamente al superarse. Déjalo vacío para no tener límite.',
|
||||
'ai.codebuddy.sandbox': 'Modo sandbox',
|
||||
'ai.codebuddy.sandbox.hint': 'Ejecuta llamadas a herramientas en un sandbox, restringiendo el acceso al sistema de archivos y a la red.',
|
||||
'ai.codebuddy.fileCheckpointing': 'Puntos de control de archivos',
|
||||
'ai.codebuddy.fileCheckpointing.hint': 'Habilita puntos de control de operaciones de archivos para poder revertir las modificaciones de IA a los archivos.',
|
||||
'ai.codebuddy.elicitation.title': 'CodeBuddy necesita tu aporte',
|
||||
'ai.codebuddy.elicitation.description': 'Revisa la petición para continuar el turno actual.',
|
||||
'ai.codebuddy.elicitation.select': 'Selecciona una opción',
|
||||
'ai.codebuddy.elicitation.yes': 'Sí',
|
||||
'ai.codebuddy.elicitation.no': 'No',
|
||||
'ai.codebuddy.elicitation.decline': 'Rechazar',
|
||||
'ai.codebuddy.elicitation.accept': 'Continuar',
|
||||
'ai.codebuddy.elicitation.validation.required': '{field} es obligatorio.',
|
||||
'ai.codebuddy.elicitation.validation.invalidType': '{field} tiene un valor no válido.',
|
||||
'ai.codebuddy.elicitation.validation.integer': '{field} debe ser un número entero.',
|
||||
'ai.codebuddy.elicitation.validation.notInteger': '{field} debe ser un número entero.',
|
||||
'ai.codebuddy.elicitation.validation.minimum': '{field} debe ser al menos {limit}.',
|
||||
'ai.codebuddy.elicitation.validation.maximum': '{field} debe ser como máximo {limit}.',
|
||||
'ai.codebuddy.elicitation.validation.minLength': '{field} debe contener al menos {limit} caracteres.',
|
||||
'ai.codebuddy.elicitation.validation.maxLength': '{field} debe contener como máximo {limit} caracteres.',
|
||||
'ai.codebuddy.elicitation.validation.minItems': 'Selecciona al menos {limit} opciones para {field}.',
|
||||
'ai.codebuddy.elicitation.validation.maxItems': 'Selecciona como máximo {limit} opciones para {field}.',
|
||||
'ai.codebuddy.elicitation.validation.format': '{field} debe coincidir con el formato {format}.',
|
||||
'ai.codebuddy.elicitation.validation.option': 'Selecciona una opción válida para {field}.',
|
||||
|
||||
// AI OpenCode
|
||||
'ai.opencode.title': 'OpenCode',
|
||||
'ai.opencode.description': 'Usa OpenCode mediante el SDK oficial. Configura proveedores y claves en OpenCode y luego selecciónalo como agente de codificación externo.',
|
||||
'ai.opencode.detecting': 'Detectando...',
|
||||
'ai.opencode.detected': 'Detectado',
|
||||
'ai.opencode.notFound': 'No encontrado',
|
||||
'ai.opencode.path': 'Ruta:',
|
||||
'ai.opencode.notFoundHint': 'No se pudo encontrar opencode en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
|
||||
'ai.opencode.customPathPlaceholder': 'p. ej. /usr/local/bin/opencode',
|
||||
'ai.opencode.check': 'Verificar',
|
||||
'ai.opencode.resetPath': 'Restablecer',
|
||||
|
||||
// AI Grok Build (in-app managed agent — distinct from External MCP client install)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': "La CLI de agente de codificación Grok Build de xAI. Instala la CLI de Grok, inicia sesión con `grok login` o define XAI_API_KEY, y luego selecciónala como agente externo.",
|
||||
'ai.grok.detecting': 'Detectando...',
|
||||
'ai.grok.detected': 'Detectado',
|
||||
'ai.grok.notFound': 'No encontrado',
|
||||
'ai.grok.path': 'Ruta:',
|
||||
'ai.grok.notFoundHint': 'No se pudo encontrar grok en el PATH. Instala la CLI de Grok Build o especifica la ruta del ejecutable a continuación.',
|
||||
'ai.grok.customPathPlaceholder': 'p. ej. /usr/local/bin/grok',
|
||||
'ai.grok.check': 'Verificar',
|
||||
'ai.grok.resetPath': 'Restablecer',
|
||||
'ai.grok.runtime.acp.title': 'Usar Grok ACP (agente stdio)',
|
||||
'ai.grok.runtime.acp.default': 'Predeterminado',
|
||||
'ai.grok.runtime.acp.description':
|
||||
'Habla con Grok mediante el Protocolo de Cliente de Agente (grok agent stdio). Inyecta el MCP de Netcatty en session/new. Apágalo para usar la ruta original de CLI headless con streaming-json.',
|
||||
'ai.grok.runtime.streamingJson.hint':
|
||||
'Usando streaming-json headless (grok -p --output-format streaming-json). El archivo .grok/config.toml del proyecto se usa para la inyección de MCP.',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': 'Agente predeterminado',
|
||||
'ai.defaultAgent.description': 'Agente que se usará al iniciar una nueva sesión de IA',
|
||||
'ai.defaultAgent.catty': 'Catty (Integrado)',
|
||||
'ai.toolAccess.title': 'Acceso a herramientas',
|
||||
'ai.toolAccess.mode': 'Modo de acceso de Netcatty',
|
||||
'ai.toolAccess.description': 'Elige cómo acceden los agentes externos a las sesiones de Netcatty. MCP expone el servidor integrado, mientras que Habilidades + CLI apunta a los agentes hacia las habilidades locales de Netcatty y los comandos de CLI.',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Habilidades + CLI',
|
||||
|
||||
// External MCP (productized catalog MCP for Codex / Claude Code / Cursor)
|
||||
'ai.externalMcp.title': 'MCP externo',
|
||||
'ai.externalMcp.description': 'Expón Netcatty como un servidor MCP para clientes externos como Codex, Claude Code, Cursor y Grok. Usa las mismas herramientas del catálogo que los agentes de la aplicación (terminal, SFTP, Vault, reenvío de puertos). Mantén Netcatty en ejecución mientras los clientes estén conectados.',
|
||||
'ai.externalMcp.sessionsExposed': 'Sesiones en alcance: {count}',
|
||||
'ai.externalMcp.mode': 'Modo de disponibilidad',
|
||||
'ai.externalMcp.mode.temporary': 'Temporal',
|
||||
'ai.externalMcp.mode.persistent': 'Siempre activo',
|
||||
'ai.externalMcp.mode.description': 'El modo temporal se desactiva automáticamente tras el tiempo de inactividad. El modo Siempre activo restaura el MCP externo cuando se inicia Netcatty.',
|
||||
'ai.externalMcp.idleTimeout': 'Tiempo de inactividad',
|
||||
'ai.externalMcp.idleTimeout.description': 'En modo temporal, desactiva el MCP externo después de esta cantidad de minutos sin operaciones de MCP.',
|
||||
'ai.externalMcp.idleTimeout.minutes': 'min',
|
||||
'ai.externalMcp.focusOnHostOpen': 'Enfocar ventana en host_open',
|
||||
'ai.externalMcp.focusOnHostOpen.description': 'Cuando un cliente de MCP abre un host, trae la ventana principal al frente. Apágalo para seguir trabajando sin interrupciones.',
|
||||
'ai.externalMcp.silentSessions': 'Sesiones MCP silenciosas',
|
||||
'ai.externalMcp.silentSessions.description': 'Las sesiones abiertas por la IA se mantienen fuera de tu barra de pestañas y no se restauran tras reiniciar. Puedes verlas en cualquier momento desde el panel de la bandeja.',
|
||||
'ai.externalMcp.sessionIdleTimeout': 'Tiempo de inactividad de la sesión abierta',
|
||||
'ai.externalMcp.sessionIdleTimeout.description': 'Cierra automáticamente las sesiones abiertas por una IA después de esta cantidad de minutos sin actividad de terminal o archivos.',
|
||||
'ai.externalMcp.usage.title': 'Cómo usarlo',
|
||||
'ai.externalMcp.usage.keepRunning': '1. Activa el MCP externo y mantén Netcatty en ejecución.',
|
||||
'ai.externalMcp.usage.localhost': '2. Los clientes se conectan mediante el launcher local (solo 127.0.0.1). El descubrimiento se elimina cuando desactivas el interruptor.',
|
||||
'ai.externalMcp.usage.permissions': '3. Las operaciones de escritura siguen Configuración → IA → Seguridad (observador / confirmar / automático) y la lista de bloqueo de comandos.',
|
||||
'ai.externalMcp.usage.capabilities': '4. Todas las herramientas del catálogo están disponibles: terminal, SFTP, Vault y reenvío de puertos. Los secretos (contraseñas / claves privadas) nunca se devuelven.',
|
||||
'ai.externalMcp.help.ariaLabel': 'Ayuda del MCP externo',
|
||||
'ai.externalMcp.security': 'Seguridad',
|
||||
'ai.externalMcp.security.description': 'Escucha en 127.0.0.1 con un token rotativo, reutiliza el modo de permisos de IA para las escrituras y elimina el descubrimiento al desactivarse. Sin OAuth: este es un puente local de escritorio.',
|
||||
'ai.externalMcp.permissionMode': 'Modo de permisos actual: {mode}',
|
||||
'ai.externalMcp.permissionMode.label': 'Modo de permiso de escritura',
|
||||
'ai.externalMcp.permissionMode.hint': 'La misma configuración que Configuración → IA → Seguridad. Automático ejecuta las herramientas de escritura de Netcatty sin solicitar aprobación de Netcatty; Confirmar pregunta cada vez. Los clientes externos (Codex / Claude / Grok) aún pueden mostrar su propia interfaz de aprobación de herramientas.',
|
||||
'ai.externalMcp.permissionMode.unknown': 'Desconocido',
|
||||
'ai.externalMcp.discovery': 'Descubrimiento',
|
||||
'ai.externalMcp.launcher': 'Launcher',
|
||||
'ai.externalMcp.unavailable': 'No disponible',
|
||||
'ai.externalMcp.bridgeUnavailable': 'Puente de MCP externo no disponible',
|
||||
'ai.externalMcp.copy': 'Copiar',
|
||||
'ai.externalMcp.copied': 'Copiado',
|
||||
'ai.externalMcp.copyFailed': 'No se pudo copiar. Intenta copiarlo manualmente.',
|
||||
'ai.externalMcp.refresh': 'Actualizar',
|
||||
'ai.externalMcp.clientConfiguration': 'Configuración del cliente',
|
||||
'ai.externalMcp.clientConfiguration.description': 'Elige un cliente para instalarlo con un clic, o copia fragmentos de CLI o configuración.',
|
||||
'ai.externalMcp.client.codex': 'Codex',
|
||||
'ai.externalMcp.client.claude': 'Claude Code',
|
||||
'ai.externalMcp.client.grok': 'Grok',
|
||||
'ai.externalMcp.client.cursor': 'Cursor',
|
||||
'ai.externalMcp.cliCommand': 'Comando CLI',
|
||||
'ai.externalMcp.configSnippet': 'Fragmento de configuración',
|
||||
'ai.externalMcp.addToCodex': 'Agregar a Codex',
|
||||
'ai.externalMcp.addToClaude': 'Agregar a Claude Code',
|
||||
'ai.externalMcp.addToGrok': 'Agregar a Grok',
|
||||
'ai.externalMcp.codexAdded': 'Entrada de MCP de Codex agregada. Reinicia Codex o abre una nueva sesión de Codex.',
|
||||
'ai.externalMcp.claudeAdded': 'Entrada de MCP de Claude Code agregada. Reinicia Claude Code o abre una nueva sesión de Claude Code.',
|
||||
'ai.externalMcp.grokAdded': 'Entrada de MCP de Grok agregada. Reinicia Grok o abre una nueva sesión de Grok.',
|
||||
'ai.externalMcp.installCodex': 'Instala Codex por separado y luego haz clic en Actualizar.',
|
||||
'ai.externalMcp.installClaude': 'Instala Claude Code por separado y luego haz clic en Actualizar.',
|
||||
'ai.externalMcp.installGrok': 'Instala la CLI de Grok por separado y luego haz clic en Actualizar.',
|
||||
'ai.externalMcp.conflict.description': 'Ya existe una entrada netcatty-external que apunta a otro lugar. Quítala o edítala manualmente.',
|
||||
'ai.externalMcp.enableForLauncher': 'Habilita el MCP externo para obtener una ruta de launcher utilizable.',
|
||||
'ai.externalMcp.cursor.title': 'Cursor / otros clientes',
|
||||
'ai.externalMcp.cursor.description': 'Combina esto en tu configuración de MCP (por ejemplo, ~/.cursor/mcp.json). No reemplaces todo el archivo si ya tienes otros servidores.',
|
||||
'ai.externalMcp.status.unavailable': 'No disponible',
|
||||
'ai.externalMcp.status.disabled': 'Desactivado',
|
||||
'ai.externalMcp.status.running': 'En ejecución',
|
||||
'ai.externalMcp.status.starting': 'Iniciando',
|
||||
'ai.externalMcp.status.error': 'Error',
|
||||
'ai.externalMcp.status.configured': 'Configurado',
|
||||
'ai.externalMcp.status.notConfigured': 'No configurado',
|
||||
'ai.externalMcp.status.checking': 'Comprobando',
|
||||
'ai.externalMcp.status.codexNotFound': 'Codex no encontrado',
|
||||
'ai.externalMcp.status.claudeNotFound': 'Claude Code no encontrado',
|
||||
'ai.externalMcp.status.grokNotFound': 'Grok no encontrado',
|
||||
'ai.externalMcp.status.conflict': 'Conflicto',
|
||||
'ai.userSkills.title': 'Habilidades de usuario',
|
||||
'ai.userSkills.description': 'Abre la carpeta de habilidades de Netcatty para agregar tus propios directorios de habilidades. Netcatty escanea estas habilidades automáticamente e inyecta solo índices ligeros, salvo que una habilidad coincida claramente con la petición actual.',
|
||||
'ai.userSkills.openFolder': 'Abrir carpeta de habilidades',
|
||||
'ai.userSkills.reload': 'Recargar habilidades',
|
||||
'ai.userSkills.location': 'Ubicación',
|
||||
'ai.userSkills.loading': 'Escaneando habilidades de usuario...',
|
||||
'ai.userSkills.summary': '{ready} listas, {warnings} advertencias',
|
||||
'ai.userSkills.empty': 'Aún no se encontraron habilidades de usuario. Abre la carpeta para agregar directorios de habilidades con un archivo SKILL.md.',
|
||||
'ai.userSkills.unavailable': 'Las habilidades de usuario no están disponibles en este entorno.',
|
||||
'ai.userSkills.status.ready': 'Lista',
|
||||
'ai.userSkills.status.warning': 'Advertencia',
|
||||
|
||||
// AI Quick Messages
|
||||
'ai.quickMessages.title': 'Mensajes rápidos',
|
||||
'ai.quickMessages.description': 'Crea prompts reutilizables que puedes insertar desde el chat de IA con / o con el botón de mensajes rápidos. A diferencia de las habilidades de usuario, los mensajes rápidos llenan el compositor con texto.',
|
||||
'ai.quickMessages.add': 'Agregar mensaje rápido',
|
||||
'ai.quickMessages.createTitle': 'Nuevo mensaje rápido',
|
||||
'ai.quickMessages.editTitle': 'Editar mensaje rápido',
|
||||
'ai.quickMessages.name': 'Nombre',
|
||||
'ai.quickMessages.name.placeholder': 'p. ej. Verificar espacio en disco',
|
||||
'ai.quickMessages.slug': 'Comando',
|
||||
'ai.quickMessages.slug.placeholder': 'disk-check',
|
||||
'ai.quickMessages.descriptionField': 'Descripción (opcional)',
|
||||
'ai.quickMessages.descriptionField.placeholder': 'Breve indicación sobre lo que hace este prompt',
|
||||
'ai.quickMessages.content': 'Contenido del mensaje',
|
||||
'ai.quickMessages.content.placeholder': 'Texto completo del prompt para insertar al seleccionarlo...',
|
||||
'ai.quickMessages.empty': 'Aún no hay mensajes rápidos. Agrega algunos prompts que uses seguido.',
|
||||
'ai.quickMessages.confirmDelete': '¿Eliminar el mensaje rápido "{name}"?',
|
||||
'ai.quickMessages.error.nameRequired': 'El nombre es obligatorio.',
|
||||
'ai.quickMessages.error.invalidSlug': 'El comando solo puede contener letras minúsculas, números y guiones.',
|
||||
'ai.quickMessages.error.contentRequired': 'El contenido del mensaje es obligatorio.',
|
||||
'ai.quickMessages.error.slugTaken': 'Este comando ya lo usa otro mensaje rápido.',
|
||||
'ai.quickMessages.error.slugConflictsWithSkill': 'Este comando entra en conflicto con la habilidad de usuario "/{slug}". Elige otro.',
|
||||
'ai.quickMessages.error.maxItems': 'Puedes guardar como máximo {max} mensajes rápidos.',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': 'No hay ningún proveedor de IA configurado. Ve a **Configuración → IA → Proveedores** para agregar y habilitar un proveedor.',
|
||||
'ai.chat.toolDenied': 'La acción fue rechazada por el usuario.',
|
||||
'ai.chat.toolApproved': 'Aprobado',
|
||||
'ai.chat.toolApprovalHint': 'Presiona Enter una vez · Esc para rechazar',
|
||||
'ai.chat.approve': 'Aprobar',
|
||||
'ai.chat.approveOnce': 'Una vez',
|
||||
'ai.chat.alwaysAllow': 'Siempre',
|
||||
'ai.chat.slashStopDesc': 'Detener el turno actual de IA y cancelar las herramientas en curso',
|
||||
'ai.chat.slashCompactDesc': 'Resumir el contexto anterior de la conversación',
|
||||
'ai.chat.reject': 'Rechazar',
|
||||
'ai.chat.toolLabel': 'Herramienta',
|
||||
'ai.chat.targetLabel': 'Destino',
|
||||
'ai.chat.rawCommand': 'Comando',
|
||||
'ai.chat.copyCommand': 'Copiar',
|
||||
'ai.chat.commandCopied': 'Copiado',
|
||||
'ai.chat.approvalSession': 'Sesión',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': 'Cwd',
|
||||
'ai.chat.approvalReason': 'Motivo',
|
||||
'ai.chat.approvalInvocation': 'Invocación',
|
||||
'ai.chat.permissionRequired': 'Permiso requerido',
|
||||
'ai.chat.permissionDescription': 'El agente de IA quiere ejecutar una llamada a una herramienta que requiere tu aprobación.',
|
||||
'ai.chat.commandBlocked': 'Este comando está bloqueado por tu política de seguridad y no se puede ejecutar.',
|
||||
'ai.chat.recommendAllow': 'Permitir',
|
||||
'ai.chat.recommendConfirm': 'Confirmar',
|
||||
'ai.chat.recommendDeny': 'Denegar',
|
||||
'ai.chat.exportConversation': 'Exportar conversación',
|
||||
'ai.chat.exportAs': 'Exportar como',
|
||||
'ai.chat.exportMarkdown': 'Markdown',
|
||||
'ai.chat.exportJSON': 'JSON',
|
||||
'ai.chat.exportPlainText': 'Texto plano',
|
||||
'ai.chat.thinking': 'Pensando',
|
||||
'ai.chat.thoughtFor': 'Pensó durante {duration}',
|
||||
'ai.chat.thought': 'Pensamiento',
|
||||
'ai.chat.agents': 'Agentes',
|
||||
'ai.chat.detectedOnMachine': 'Detectado en esta máquina',
|
||||
'ai.chat.rescan': 'Volver a escanear',
|
||||
'ai.chat.permObserver': 'Observador',
|
||||
'ai.chat.permConfirm': 'Confirmar',
|
||||
'ai.chat.permAuto': 'Automático',
|
||||
'ai.chat.permObserverDesc': 'Solo lectura (Cursor CLI: Ask)',
|
||||
'ai.chat.permConfirmDesc': 'Preguntar antes de que Netcatty escriba (Cursor CLI: agent + force)',
|
||||
'ai.chat.permAutoDesc': 'Ejecutar libremente (Cursor CLI: agent + force)',
|
||||
'ai.chat.emptyHint': 'Pregunta sobre tus servidores, ejecuta comandos u obtén ayuda con las configuraciones.',
|
||||
'ai.chat.placeholder': 'Mensaje a {agent} — @ para incluir contexto, / para comandos',
|
||||
'ai.chat.placeholderDefault': 'Mensaje al agente Catty...',
|
||||
'ai.chat.noModel': 'Sin modelo',
|
||||
'ai.chat.noProviderModel': 'No hay modelo predeterminado: define uno en Configuración → IA → Proveedores.',
|
||||
'ai.chat.selectProvider': 'Seleccionar proveedor',
|
||||
'ai.chat.recent': 'Recientes',
|
||||
'ai.chat.viewAll': 'Ver todo',
|
||||
'ai.chat.untitled': 'Sin título',
|
||||
'ai.chat.justNow': 'Ahora mismo',
|
||||
'ai.chat.minutesAgo': 'hace {n} min',
|
||||
'ai.chat.hoursAgo': 'hace {n} h',
|
||||
'ai.chat.daysAgo': 'hace {n} d',
|
||||
'ai.chat.newChat': 'Nuevo chat',
|
||||
'ai.chat.allSessions': 'Todas las sesiones',
|
||||
'ai.chat.loadEarlierMessages': 'Cargar mensajes anteriores ({n} más)',
|
||||
'ai.chat.jumpNav': 'Ir al mensaje',
|
||||
'ai.chat.jumpUntitled': '(mensaje vacío)',
|
||||
'ai.chat.usedTools': 'Herramientas usadas: {n}',
|
||||
'ai.chat.loadMoreSessions': 'Cargar más sesiones ({n} más)',
|
||||
'ai.chat.noSessions': 'No hay sesiones anteriores',
|
||||
'ai.chat.retryHint': 'Puedes reintentar enviando tu mensaje de nuevo.',
|
||||
'ai.chat.approvalTimeout': 'La aprobación de la herramienta expiró después de 5 minutos. Puedes reintentar enviando tu mensaje de nuevo.',
|
||||
'ai.chat.menuHosts': 'Hosts',
|
||||
'ai.chat.menuContext': 'Contexto',
|
||||
'ai.chat.menuFiles': 'Archivos',
|
||||
'ai.chat.menuImage': 'Imagen',
|
||||
'ai.chat.menuMentionHost': 'Mencionar host',
|
||||
'ai.chat.menuUserSkills': 'Habilidades de usuario',
|
||||
'ai.chat.menuSlashCommands': 'Comandos de barra',
|
||||
'ai.chat.slashCommands': 'Comandos de barra',
|
||||
'ai.chat.slashSystemCommands': 'Comandos',
|
||||
'ai.chat.slashQuickMessages': 'Mensajes rápidos',
|
||||
'ai.chat.slashUserSkills': 'Habilidades de usuario',
|
||||
'ai.chat.quickMessages': 'Comandos de barra',
|
||||
'ai.chat.slashNoResults': 'No hay comandos que coincidan',
|
||||
'ai.chat.slashEmptyHint': 'Agrega prompts en Configuración → IA → Mensajes rápidos.',
|
||||
|
||||
// AI Chat Shortcuts
|
||||
'ai.chatShortcuts.title': 'Atajos de chat',
|
||||
'ai.chatShortcuts.selectionAction': 'Mostrar "Agregar a la conversación" al seleccionar texto de la terminal',
|
||||
'ai.chatShortcuts.selectionAction.description': 'Muestra un pequeño botón de IA junto al texto seleccionado de la terminal.',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Los controladores del proceso principal de Codex aún no están cargados. Reinicia Netcatty por completo o reinicia el proceso de desarrollo de Electron y vuelve a intentarlo.',
|
||||
|
||||
// AI Web Search
|
||||
'ai.webSearch.title': 'Búsqueda web',
|
||||
'ai.webSearch.enable': 'Habilitar búsqueda web',
|
||||
'ai.webSearch.enable.description': 'Permite que el agente de IA busque en la web información actual.',
|
||||
'ai.webSearch.provider': 'Proveedor de búsqueda',
|
||||
'ai.webSearch.provider.description': 'Elige un proveedor de API de búsqueda web.',
|
||||
'ai.webSearch.apiKey': 'Clave de API',
|
||||
'ai.webSearch.apiKey.description': 'Clave de API para el proveedor de búsqueda seleccionado.',
|
||||
'ai.webSearch.apiKey.placeholder': 'Ingresa la clave de API...',
|
||||
'ai.webSearch.apiHost': 'Host de API',
|
||||
'ai.webSearch.apiHost.description': 'Endpoint de API personalizado. Deja el predeterminado salvo que uses un proxy.',
|
||||
'ai.webSearch.apiHost.searxngDescription': 'URL de tu instancia de SearXNG (obligatoria).',
|
||||
'ai.webSearch.maxResults': 'Máximo de resultados',
|
||||
'ai.webSearch.maxResults.description': 'Cantidad máxima de resultados de búsqueda a devolver (1-20).',
|
||||
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': 'Seguridad',
|
||||
'ai.safety.permissionMode': 'Modo de permisos',
|
||||
'ai.safety.permissionMode.description': 'Controla cómo interactúa la IA con tus sesiones de terminal de Netcatty. El modo Observador bloquea las operaciones de escritura que pasan por Netcatty. Las CLIs de agentes externos pueden seguir teniendo sus propias herramientas locales y flujo de aprobación.',
|
||||
'ai.safety.permissionMode.observer': 'Observador: solo lectura, sin acciones',
|
||||
'ai.safety.permissionMode.confirm': 'Confirmar: preguntar antes de las acciones',
|
||||
'ai.safety.permissionMode.auto': 'Automático: ejecutar libremente',
|
||||
'ai.safety.commandTimeout': 'Tiempo de espera de comandos',
|
||||
'ai.safety.commandTimeout.description': 'Máximo de segundos que un comando puede ejecutarse antes de ser terminado mediante la ejecución de Netcatty.',
|
||||
'ai.safety.commandTimeout.unit': 'seg',
|
||||
'ai.safety.maxIterations': 'Máximo de iteraciones',
|
||||
'ai.safety.maxIterations.description': 'Máximo de bucles de uso de herramientas de IA para evitar una ejecución descontrolada. Los agentes externos pueden tener sus propios límites de iteración internos que tienen prioridad.',
|
||||
'ai.safety.blocklist': 'Lista de bloqueo de comandos',
|
||||
'ai.safety.blocklist.description': 'Patrones regex para bloquear comandos peligrosos ejecutados mediante Netcatty.',
|
||||
'ai.safety.blocklist.placeholder': 'Patrón regex...',
|
||||
'ai.safety.blocklist.reset': 'Restablecer a los valores predeterminados',
|
||||
'ai.safety.blocklist.add': 'Agregar patrón',
|
||||
'ai.safety.grants.title': 'Memoria de permisos',
|
||||
'ai.safety.grants.heading': 'Reglas de permitir en modo Confirmar',
|
||||
'ai.safety.grants.description': 'El modo Confirmar pregunta antes de ejecutar una operación. Las reglas guardadas permiten automáticamente las operaciones que coinciden en todas las sesiones/nodos de terminal, y se pueden editar manualmente.',
|
||||
'ai.safety.grants.empty': 'Aún no hay reglas guardadas. Aprueba una herramienta con "Permitir siempre" o agrega una manualmente.',
|
||||
'ai.safety.grants.capability': 'Capacidad',
|
||||
'ai.safety.grants.sessionPattern': 'Patrón de sesión',
|
||||
'ai.safety.grants.commandPattern': 'Patrón de comando (opcional)',
|
||||
'ai.safety.grants.note': 'Nota (opcional)',
|
||||
'ai.safety.grants.add': 'Agregar regla',
|
||||
'ai.safety.grants.remove': 'Quitar',
|
||||
'ai.safety.grants.export': 'Exportar JSON',
|
||||
'ai.safety.grants.import': 'Importar JSON',
|
||||
'ai.safety.note': 'Estos ajustes de seguridad se aplican a las acciones que pasan por Netcatty. Las CLIs de agentes externos también pueden exponer herramientas locales regidas por el propio agente.',
|
||||
|
||||
// Unified tooltips for terminal workspace and top tabs (issue #954)
|
||||
'terminal.layer.addTerminal': 'Agregar terminal',
|
||||
'terminal.layer.switchToSplitView': 'Cambiar a vista dividida',
|
||||
'terminal.layer.sftp': 'SFTP',
|
||||
'terminal.layer.scripts': 'Scripts',
|
||||
'terminal.layer.history': 'Historial',
|
||||
'terminal.layer.theme': 'Tema',
|
||||
'terminal.layer.notes': 'Notas',
|
||||
'terminal.layer.aiChat': 'Chat de IA',
|
||||
'terminal.layer.movePanelLeft': 'Mover panel a la izquierda',
|
||||
'terminal.layer.movePanelRight': 'Mover panel a la derecha',
|
||||
'terminal.layer.closePanel': 'Cerrar panel',
|
||||
'terminal.layer.closePane': 'Cerrar división',
|
||||
'terminal.layer.resizeSplit': 'Redimensionar división',
|
||||
'terminal.layer.splitHorizontal': 'Dividir arriba y abajo',
|
||||
'terminal.layer.splitVertical': 'Dividir izquierda y derecha',
|
||||
'terminal.layer.openInNewSplit': 'Abrir en una división nueva',
|
||||
'terminal.layer.hostTree.search': 'Buscar hosts...',
|
||||
'terminal.layer.hostTree.searchButton': 'Buscar',
|
||||
'terminal.layer.hostTree.tagsButton': 'Filtrar por etiquetas',
|
||||
'terminal.layer.hostTree.newHost': 'Nuevo host',
|
||||
'terminal.layer.hostTree.newHostInGroup': 'Nuevo host en este grupo',
|
||||
'terminal.layer.hostTree.editHost': 'Editar host',
|
||||
'terminal.layer.hostTree.hostSavedNextConnection': 'Host actualizado. Los ajustes de conexión se aplicarán la próxima vez que te conectes.',
|
||||
'terminal.layer.hostTree.newGroup': 'Nuevo grupo',
|
||||
'terminal.layer.hostTree.localShell': 'Shell local',
|
||||
'terminal.layer.hostTree.tagsEmpty': 'No hay etiquetas disponibles',
|
||||
'terminal.layer.hostTree.clearTags': 'Borrar selección',
|
||||
'terminal.layer.hostTree.collapse': 'Contraer lista de hosts',
|
||||
'terminal.layer.hostTree.expand': 'Expandir lista de hosts',
|
||||
'terminal.layer.hostTree.empty': 'No se encontraron hosts',
|
||||
'terminal.layer.hostTree.details.host': 'Host',
|
||||
'terminal.layer.hostTree.details.user': 'Usuario',
|
||||
'terminal.layer.hostTree.details.port': 'Puerto',
|
||||
'terminal.layer.hostTree.details.protocol': 'Protocolo',
|
||||
'terminal.layer.hostTree.details.group': 'Grupo',
|
||||
'terminal.layer.hostTree.details.tags': 'Etiquetas',
|
||||
'terminal.layer.hostTree.details.lastConnected': 'Última conexión',
|
||||
'topTabs.openQuickSwitcher': 'Abrir selector rápido',
|
||||
'topTabs.moreTabs': 'Más pestañas',
|
||||
'topTabs.aiAssistant': 'Asistente de IA',
|
||||
'topTabs.newLocalTerminal': 'Nueva terminal local',
|
||||
'topTabs.controlPanel': 'Controles rápidos',
|
||||
'topTabs.controlPanel.externalMcp': 'MCP externo',
|
||||
'topTabs.controlPanel.theme': 'Tema',
|
||||
'topTabs.controlPanel.theme.light': 'Claro',
|
||||
'topTabs.controlPanel.theme.dark': 'Oscuro',
|
||||
'topTabs.controlPanel.theme.system': 'Sistema',
|
||||
'topTabs.externalMcp.enable': 'Habilitar MCP externo',
|
||||
'topTabs.externalMcp.disable': 'Desactivar MCP externo',
|
||||
'topTabs.windowOpacity': 'Opacidad de la ventana',
|
||||
'topTabs.openSettings': 'Abrir Configuración',
|
||||
'ai.chat.sessionHistory': 'Historial de sesiones',
|
||||
'ai.chat.resizeInput': 'Arrastra para redimensionar el campo de mensaje',
|
||||
'ai.chat.attach': 'Adjuntar',
|
||||
'ai.chat.terminalSelectionAttachment': 'Selección de la terminal',
|
||||
'ai.chat.terminalSelectionLines': 'líneas: {count}',
|
||||
'ai.chat.collapse': 'Contraer',
|
||||
'ai.chat.expand': 'Expandir',
|
||||
'ai.chat.enableAgent': 'Habilitar {name}',
|
||||
'ai.chat.artifact.noteFallback': 'Nota de Vault',
|
||||
'ai.chat.artifact.openNotes': 'Abrir Notas',
|
||||
'ai.chat.artifact.openHosts': 'Abrir Hosts',
|
||||
'ai.chat.artifact.notesSummary': '{count} notas en Vault',
|
||||
'ai.chat.artifact.hostsSummary': '{count} hosts en Vault',
|
||||
'ai.chat.artifact.hostsAdded': 'Se agregaron {count} hosts',
|
||||
'ai.chat.artifact.hostsPreview': 'Vista previa de {count} hosts',
|
||||
'ai.chat.artifact.failed': 'La operación de Vault falló',
|
||||
'ai.chat.artifact.unavailableTitle': 'No disponible',
|
||||
'ai.chat.artifact.noteMissing': 'Esta nota ya no está en tu Vault.',
|
||||
'ai.chat.artifact.hostMissing': 'Este host ya no está en tu Vault.',
|
||||
'ai.chat.artifact.snippetMissing': 'Este snippet o script ya no está en tu Vault.',
|
||||
'ai.chat.artifact.openSnippets': 'Abrir Snippets',
|
||||
'ai.chat.artifact.snippetsSummary': '{count} snippets en Vault',
|
||||
'ai.chat.artifact.scriptsSummary': '{count} scripts en Vault',
|
||||
'ai.chat.artifact.snippetFallback': 'Snippet de Vault',
|
||||
'ai.chat.artifact.scriptFallback': 'Script de automatización',
|
||||
'ai.chat.artifact.scriptLanguage': 'Script de {language}',
|
||||
'ai.chat.artifact.snippetDeleted': 'Snippet eliminado',
|
||||
'ai.chat.artifact.scriptDeleted': 'Script eliminado',
|
||||
'ai.chat.artifact.snippetRan': 'Snippet ejecutado',
|
||||
'ai.chat.artifact.scriptStarted': 'Se inició la ejecución del script',
|
||||
'ai.chat.artifact.scriptRunStatus': 'Ejecución del script: {status}',
|
||||
'ai.chat.artifact.scriptRunsSummary': '{count} ejecuciones de scripts',
|
||||
'ai.chat.artifact.scriptRunStopped': 'Ejecución del script detenida',
|
||||
'ai.chat.artifact.scriptRunPaused': 'Ejecución del script en pausa',
|
||||
'ai.chat.artifact.scriptRunResumed': 'Ejecución del script reanudada',
|
||||
'ai.chat.artifact.scriptReference': 'referencia de la API de nct',
|
||||
'zmodem.waitingForRemote': 'Esperando al servidor remoto...',
|
||||
'zmodem.uploading': 'Subiendo',
|
||||
'zmodem.downloading': 'Descargando',
|
||||
'zmodem.cancelTransfer': 'Cancelar transferencia (Ctrl+C)',
|
||||
'zmodem.overwrite.title': 'El archivo remoto ya existe',
|
||||
'zmodem.overwrite.applyToRest': 'Aplicar a los conflictos restantes',
|
||||
'zmodem.overwrite.overwrite': 'Sobrescribir',
|
||||
'zmodem.overwrite.skip': 'Omitir',
|
||||
'zmodem.overwrite.cancel': 'Cancelar',
|
||||
'settings.shortcuts.resetToDefault': 'Restablecer al predeterminado',
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,133 @@
|
||||
export const esScriptsMessages = {
|
||||
'scripts.meta.name': 'Nombre',
|
||||
'scripts.meta.language': 'Lenguaje',
|
||||
'scripts.meta.description': 'Descripción',
|
||||
'scripts.meta.descriptionPlaceholder': 'Notas opcionales sobre este script',
|
||||
'scripts.meta.trigger': 'Disparador',
|
||||
'scripts.meta.triggerPattern': 'Patrón de salida (regex)',
|
||||
'scripts.meta.code': 'Script',
|
||||
'scripts.trigger.manual': 'Ejecución manual',
|
||||
'scripts.trigger.onConnect': 'Ejecutar al conectar',
|
||||
'scripts.trigger.onOutput': 'Ejecutar al coincidir con la salida',
|
||||
'scripts.trigger.onOutputHint': 'Se dispara cuando la salida del servidor coincide con el patrón (el eco de las teclas del usuario se ignora). Sin hosts de destino configurados, escucha en la sesión conectada actual; con hosts de destino, solo en esos hosts. Deshabilitado en aplicaciones de pantalla alterna como vim o htop. Se suprime mientras otro script se ejecuta en esta sesión; se vuelve a revisar cuando termina. Para esperas dentro del flujo, usa nct.screen.waitForText o nct.screen.waitForRegex en el código del script.',
|
||||
'scripts.actions.save': 'Guardar',
|
||||
'scripts.actions.runNow': 'Ejecutar ahora',
|
||||
'scripts.actions.openEditor': 'Abrir editor',
|
||||
'scripts.actions.openEditorHint': 'Editar el script en una ventana más grande',
|
||||
'scripts.editor.modalTitle': 'Editor de scripts',
|
||||
'scripts.editor.modalSubtitle': 'Edita los metadatos y el código del script en un espacio de trabajo más amplio.',
|
||||
'scripts.editor.lineCount': '{count} líneas',
|
||||
'scripts.editor.resize': 'Redimensionar el editor',
|
||||
'scripts.targets.hint': 'Los grupos seleccionados se resuelven dinámicamente, por lo que los hosts agregados después se incluyen automáticamente.',
|
||||
'scripts.targets.connectOrderHint': 'El orden de ejecución de los scripts de conexión se configura por host en Detalles del host → Automatización.',
|
||||
'scripts.targets.currentHostMismatch': 'Este script no está asignado al host actual.',
|
||||
'hostDetails.automation.groupScripts': 'Scripts de grupo heredados',
|
||||
'hostDetails.automation.groupScriptsHint': 'Estos scripts siguen al grupo del host dinámicamente y se ordenan antes de la cola específica del host.',
|
||||
'scripts.actions.runNowHint': 'Ejecutar en los destinos seleccionados, o en todos los hosts conectables cuando esa opción está habilitada.',
|
||||
'scripts.actions.runParallel': 'Ejecutar en todas las pestañas (en paralelo)',
|
||||
'scripts.actions.runSequential': 'Ejecutar en todas las pestañas (en secuencia)',
|
||||
'scripts.actions.runOnAllTabs': 'Ejecutar en todas las pestañas',
|
||||
'scripts.actions.skippedConnectingSessions': '{count} pestaña(s) aún conectándose y fueron omitidas',
|
||||
'scripts.actions.skippedSensitiveSessions': '{count} pestaña(s) omitidas (entrada de contraseña/datos sensibles)',
|
||||
'scripts.actions.noRunnableHosts': 'Ningún host conectable coincide con los destinos de este script',
|
||||
'scripts.sidePanel.library': 'Biblioteca',
|
||||
'scripts.sidePanel.running': 'En ejecución',
|
||||
'scripts.sidePanel.newScript': 'Nuevo script',
|
||||
'scripts.running.empty': 'No hay scripts en ejecución en esta sesión.',
|
||||
'scripts.running.unnamed': 'Script sin título',
|
||||
'scripts.running.status.running': 'En ejecución',
|
||||
'scripts.running.status.paused': 'En pausa',
|
||||
'scripts.running.status.completed': 'Completado',
|
||||
'scripts.running.status.failed': 'Falló',
|
||||
'scripts.running.waitingFor': 'Esperando {pattern}',
|
||||
'scripts.running.waitingForLabel': 'Esperando',
|
||||
'scripts.running.waitingForShellPrompt': 'indicador del shell (# o $)',
|
||||
'scripts.running.lastSent': 'Enviado: {command}',
|
||||
'scripts.recording.start': 'Comenzar grabación',
|
||||
'scripts.recording.active': 'Detener grabación',
|
||||
'scripts.recording.startHint': 'Graba las acciones en la terminal enfocada y genera código de script nct',
|
||||
'scripts.recording.unavailableHint': 'La grabación no está disponible aquí — usa la barra lateral de scripts a la derecha',
|
||||
'scripts.recording.activeHint': 'Grabando esta terminal. Escribe comandos como de costumbre; haz clic en Detener o usa el control REC en la barra de herramientas para finalizar y guardar.',
|
||||
'scripts.recording.started': 'Grabación iniciada — opera en la terminal',
|
||||
'scripts.recording.noSession': 'Conecta una terminal primero (pestaña única o espacio de trabajo)',
|
||||
'scripts.recording.alreadyActive': 'Otra terminal ya está grabando',
|
||||
'scripts.recording.stop': 'Detener grabación',
|
||||
'scripts.recording.pause': 'Pausar grabación',
|
||||
'scripts.recording.resume': 'Reanudar grabación',
|
||||
'scripts.recording.saveTitle': 'Guardar script grabado',
|
||||
'scripts.recording.namePlaceholder': 'Nombre del script',
|
||||
'scripts.recording.packagePlaceholder': 'Guardar en carpeta',
|
||||
'scripts.recording.rootPackage': 'Raíz',
|
||||
'scripts.recording.save': 'Guardar',
|
||||
'scripts.recording.saveAndEdit': 'Guardar y editar',
|
||||
'scripts.recording.helpTitle': 'Cómo grabar un script',
|
||||
'scripts.recording.helpIntro': 'La grabación convierte lo que haces en la terminal en un script de automatización reutilizable — útil para despliegues, verificaciones de salud y otras tareas repetitivas.',
|
||||
'scripts.recording.helpStep1': 'Conecta a un host. Una pestaña de terminal independiente o una terminal dentro de un espacio de trabajo funcionan igual — abre la barra lateral de scripts a la derecha.',
|
||||
'scripts.recording.helpStep2': 'Haz clic en Comenzar grabación. Una insignia REC roja en la barra de herramientas de la terminal indica que la grabación está activa.',
|
||||
'scripts.recording.helpStep3': 'Escribe comandos en la terminal como lo harías normalmente. Cada pulsación de Enter se captura como un paso.',
|
||||
'scripts.recording.helpStep4': 'Cuando termines, haz clic en Detener grabación, o usa el control de detención junto a REC en la barra de herramientas de la terminal.',
|
||||
'scripts.recording.helpStep5': 'Nombra el script y guárdalo. Elige Guardar y editar si quieres ajustar el código generado en el editor de scripts.',
|
||||
'scripts.recording.helpTipsTitle': 'Consejos',
|
||||
'scripts.recording.helpTip1': 'Las pausas de más de 1 segundo entre acciones se registran como tiempo de espera para que la reproducción no sea demasiado rápida.',
|
||||
'scripts.recording.helpTip2': 'Después de cada comando, la grabación espera un indicador del shell (como $ o #) antes del siguiente paso.',
|
||||
'scripts.recording.helpTip3': 'La entrada de contraseñas se marca como sensible y no se almacena en texto plano en el script.',
|
||||
'scripts.recording.helpTip4': 'Para vim, menús u otros flujos interactivos, graba primero los comandos principales y luego refina el script manualmente.',
|
||||
'scripts.dialog.title': 'Script',
|
||||
'scripts.dialog.ok': 'Aceptar',
|
||||
'scripts.dialog.required': 'Obligatorio',
|
||||
'scripts.dialog.numberInvalid': 'Ingresa un número válido',
|
||||
'scripts.dialog.numberMin': 'Debe ser al menos {min}',
|
||||
'scripts.dialog.numberMax': 'Debe ser como máximo {max}',
|
||||
'scripts.dialog.numberStep': 'Debe usar incrementos de {step}',
|
||||
'scripts.dialog.waitForTimeoutTitle': 'La espera agotó el tiempo',
|
||||
'scripts.dialog.retry': 'Reintentar',
|
||||
'scripts.dialog.skip': 'Omitir',
|
||||
'scripts.dialog.abort': 'Abortar',
|
||||
'scripts.running.stepProgress': 'Paso {current} / {total}',
|
||||
'scripts.running.determinateProgress': '{label} {current}/{total}',
|
||||
'scripts.running.progressFallback': 'Progreso',
|
||||
'scripts.running.operationsCount': '{count} operaciones',
|
||||
'scripts.running.opsPrefix': '',
|
||||
'scripts.running.opsSuffix': ' operaciones',
|
||||
'scripts.running.elapsedLabel': 'Transcurrido',
|
||||
'scripts.running.lastSentLabel': 'Enviado:',
|
||||
'scripts.running.elapsed': '{elapsed}',
|
||||
'scripts.running.completedSummary': 'Completado · {count} operaciones · {elapsed}',
|
||||
'scripts.running.dismissHint': 'Toca cerrar para descartar',
|
||||
'scripts.running.dismiss': 'Cerrar',
|
||||
'scripts.running.viewLogs': 'Ver registros',
|
||||
'scripts.running.logTitle': '{name} · Registro de ejecución',
|
||||
'scripts.running.logEmpty': 'Aún no hay salida de registro',
|
||||
'scripts.running.pause': 'Pausar',
|
||||
'scripts.running.resume': 'Reanudar',
|
||||
'scripts.running.stop': 'Detener',
|
||||
'scripts.recording.saved': 'Script guardado',
|
||||
'scripts.recording.savedNamed': 'Guardado "{name}"',
|
||||
'scripts.observer.blocked': 'El modo observador bloquea los scripts que escriben en la terminal.',
|
||||
'vault.section.scripts': 'Scripts',
|
||||
'vault.nav.scripts': 'Scripts',
|
||||
'snippets.action.newScript': 'Nuevo script de automatización',
|
||||
'hostDetails.section.automation': 'Automatización',
|
||||
'hostDetails.automation.loginScript': 'Script de inicio de sesión',
|
||||
'hostDetails.automation.loginScriptPlaceholder': 'Selecciona un script',
|
||||
'hostDetails.automation.none': 'Ninguno',
|
||||
'hostDetails.automation.outputTriggers': 'Disparadores de salida',
|
||||
'hostDetails.automation.addTrigger': 'Agregar disparador',
|
||||
'hostDetails.automation.triggerPatternPlaceholder': 'Patrón de regex',
|
||||
'hostDetails.automation.linkedScripts': 'Scripts vinculados',
|
||||
'hostDetails.automation.linkedScriptsEmpty': 'Aún no hay scripts vinculados a este host.',
|
||||
'hostDetails.automation.linkScriptPlaceholder': 'Vincular script existente…',
|
||||
'hostDetails.automation.unlink': 'Desvincular',
|
||||
'hostDetails.automation.equivalenceHint': 'Los vínculos agregan este host a la lista de destinos del script en Scripts. Ambas vistas se mantienen sincronizadas.',
|
||||
'hostDetails.automation.queueHint': 'Los scripts se ejecutan en orden cuando este host se conecta. Los scripts globales se ejecutan primero, luego esta cola.',
|
||||
'hostDetails.automation.globalScripts': 'Scripts de conexión globales',
|
||||
'hostDetails.automation.globalScriptsHint': 'Se ejecutan primero en cada conexión. Reordena según el orden de clasificación de la biblioteca de Scripts.',
|
||||
'hostDetails.automation.connectQueue': 'Cola de ejecución de este host',
|
||||
'hostDetails.automation.connectQueueEmpty': 'Aún no hay scripts de conexión en cola para este host.',
|
||||
'hostDetails.automation.addToQueuePlaceholder': 'Agregar script a la cola…',
|
||||
'hostDetails.automation.moveUp': 'Subir',
|
||||
'hostDetails.automation.moveDown': 'Bajar',
|
||||
'hostDetails.automation.removeFromQueue': 'Quitar de la cola',
|
||||
'hostDetails.automation.dragHandle': 'Arrastra para reordenar',
|
||||
'hostDetails.automation.queueDragHint': 'Arrastra los elementos para reordenar la cola de conexión.',
|
||||
};
|
||||
@@ -0,0 +1,261 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esSystemManagerMessages: Messages = {
|
||||
'terminal.layer.system': 'Sistema',
|
||||
|
||||
'systemManager.noSession': 'No hay una sesión de terminal activa.',
|
||||
'systemManager.notConnected': 'Conéctate a un host para administrar procesos y servicios.',
|
||||
'systemManager.empty': 'No hay datos disponibles.',
|
||||
'systemManager.tabs.overview': 'Resumen',
|
||||
'systemManager.tabs.processes': 'Procesos',
|
||||
'systemManager.tabs.ports': 'Puertos',
|
||||
'systemManager.tabs.services': 'Servicios',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.tabs.ariaLabel': 'Secciones del administrador del sistema',
|
||||
'systemManager.popup.loading': 'Abriendo terminal…',
|
||||
'systemManager.popup.startupFailed': 'El comando de inicio no se completó correctamente. Verifica que el destino siga disponible e inténtalo de nuevo.',
|
||||
|
||||
'systemManager.errors.loadProcesses': 'No se pudieron cargar los procesos',
|
||||
'systemManager.errors.loadTmux': 'No se pudieron cargar las sesiones de tmux',
|
||||
'systemManager.errors.loadTmuxWindows': 'No se pudieron cargar las ventanas de tmux',
|
||||
'systemManager.errors.loadTmuxPanes': 'No se pudieron cargar los paneles de tmux',
|
||||
'systemManager.errors.loadTmuxClients': 'No se pudieron cargar los clientes de tmux',
|
||||
'systemManager.errors.actionFailed': 'La acción falló',
|
||||
'systemManager.errors.loadDocker': 'No se pudieron cargar los contenedores',
|
||||
'systemManager.errors.loadDockerStats': 'No se pudieron cargar las estadísticas de los contenedores',
|
||||
'systemManager.errors.loadDockerImages': 'No se pudieron cargar las imágenes',
|
||||
'systemManager.errors.loadOverview': 'No se pudo cargar el resumen del sistema',
|
||||
'systemManager.errors.loadGpu': 'No se pudieron cargar las estadísticas de GPU / NPU',
|
||||
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
|
||||
'systemManager.errors.loadServices': 'No se pudieron cargar los servicios de systemd',
|
||||
'systemManager.errors.sshChannelUnavailable': 'El servidor se negó a abrir un nuevo canal de ejecución. Inténtalo de nuevo más tarde o vuelve a conectar este host.',
|
||||
|
||||
'systemManager.overview.empty': 'Aún no hay datos de resumen del sistema.',
|
||||
'systemManager.overview.loading': 'Cargando resumen del sistema…',
|
||||
'systemManager.overview.memory': 'Memoria',
|
||||
'systemManager.overview.disk': 'Disco',
|
||||
'systemManager.overview.network': 'Red',
|
||||
'systemManager.overview.rx': 'RX',
|
||||
'systemManager.overview.tx': 'TX',
|
||||
'systemManager.overview.cores': '{{count}} núcleos',
|
||||
'systemManager.overview.load': 'Carga',
|
||||
'systemManager.overview.uptime': 'Tiempo activo',
|
||||
'systemManager.overview.duration.daysHours': '{{days}}d {{hours}}h',
|
||||
'systemManager.overview.duration.hoursMinutes': '{{hours}}h {{minutes}}m',
|
||||
'systemManager.overview.duration.minutes': '{{minutes}}m',
|
||||
'systemManager.overview.system': 'Sistema',
|
||||
'systemManager.overview.kernel': 'Kernel',
|
||||
'systemManager.overview.swap': 'Swap',
|
||||
'systemManager.overview.latency': 'Latencia de red SSH',
|
||||
'systemManager.overview.cpuCores': 'Núcleos de CPU',
|
||||
'systemManager.overview.disks': 'Discos',
|
||||
'systemManager.overview.interfaces': 'Interfaces de red',
|
||||
'systemManager.overview.topProcesses': 'Procesos con más memoria',
|
||||
'systemManager.overview.noData': 'Sin datos',
|
||||
'systemManager.overview.noDisks': 'Sin datos de disco',
|
||||
'systemManager.overview.noInterfaces': 'Sin datos de interfaces',
|
||||
'systemManager.overview.noTopProcesses': 'Sin datos de procesos',
|
||||
|
||||
'systemManager.processes.search': 'Buscar procesos…',
|
||||
'systemManager.processes.command': 'Comando',
|
||||
'systemManager.processes.user': 'Usuario',
|
||||
'systemManager.processes.term': 'Terminar',
|
||||
'systemManager.processes.kill': 'Forzar cierre',
|
||||
'systemManager.processes.stop': 'Detener (SIGSTOP)',
|
||||
'systemManager.processes.cont': 'Continuar (SIGCONT)',
|
||||
'systemManager.processes.hup': 'Colgar (SIGHUP)',
|
||||
'systemManager.processes.renice': 'Renice',
|
||||
'systemManager.processes.renicePrompt': 'Valor de nice (-20 a 19)',
|
||||
'systemManager.processes.reniceInvalid': 'El valor de nice debe estar entre -20 y 19',
|
||||
'systemManager.processes.confirmKill': '¿Enviar SIGKILL al proceso {{pid}}?',
|
||||
'systemManager.processes.confirmSignal': '¿Enviar SIG{{signal}} al proceso {{pid}}?',
|
||||
'systemManager.processes.filter.all': 'Todos',
|
||||
'systemManager.processes.filter.running': 'En ejecución',
|
||||
'systemManager.processes.ppid': 'PID padre',
|
||||
'systemManager.processes.rss': 'RSS',
|
||||
'systemManager.processes.vsz': 'Tamaño virtual',
|
||||
'systemManager.processes.elapsed': 'Transcurrido',
|
||||
'systemManager.processes.stat': 'Estado',
|
||||
'systemManager.processes.meta': '{{count}} proceso(s)',
|
||||
'systemManager.processes.loading': 'Cargando procesos…',
|
||||
'systemManager.processes.loadingMore': 'Cargando más procesos…',
|
||||
'systemManager.processes.state.running': 'En ejecución',
|
||||
'systemManager.processes.state.sleeping': 'En reposo',
|
||||
'systemManager.processes.state.stopped': 'Detenido',
|
||||
'systemManager.processes.state.zombie': 'Zombie',
|
||||
'systemManager.processes.sort.cpu': 'CPU',
|
||||
'systemManager.processes.sort.mem': 'MEM',
|
||||
'systemManager.processes.sort.pid': 'PID',
|
||||
'systemManager.processes.sort.command': 'Comando',
|
||||
'systemManager.processes.sort.user': 'Usuario',
|
||||
|
||||
'systemManager.common.dismiss': 'Descartar',
|
||||
'systemManager.common.checkingAvailability': 'Verificando disponibilidad…',
|
||||
'systemManager.common.loading': 'Cargando…',
|
||||
'systemManager.common.loadingDetails': 'Cargando detalles…',
|
||||
'systemManager.common.loadingStats': 'Cargando estadísticas…',
|
||||
|
||||
'systemManager.tmux.new': 'Nuevo',
|
||||
'systemManager.tmux.search': 'Buscar sesiones…',
|
||||
'systemManager.tmux.newSessionTitle': 'Nueva sesión de tmux',
|
||||
'systemManager.tmux.newSessionDesc': 'Ponle nombre a la sesión y, opcionalmente, ejecuta un script al iniciar.',
|
||||
'systemManager.tmux.newSessionTabCustom': 'Comando personalizado',
|
||||
'systemManager.tmux.newSessionTabSnippet': 'Desde snippet',
|
||||
'systemManager.tmux.pickSnippet': 'Desde snippets',
|
||||
'systemManager.tmux.pickSnippetEmpty': 'Aún no hay snippets; agrega algunos en el panel de Scripts o en Vault.',
|
||||
'systemManager.tmux.selectedSnippet': 'Usando snippet: {{label}}',
|
||||
'systemManager.tmux.newSessionName': 'Nombre de la sesión',
|
||||
'systemManager.tmux.newSessionCommand': 'Comando de inicio',
|
||||
'systemManager.tmux.newSessionCommandPlaceholder': 'p. ej. htop o npm run dev (opcional)',
|
||||
'systemManager.tmux.newSessionCommandHint': 'Déjalo vacío para una sesión de shell predeterminada.',
|
||||
'systemManager.tmux.creating': 'Creando…',
|
||||
'systemManager.tmux.newSessionPlaceholder': 'mi-sesion',
|
||||
'systemManager.tmux.newSessionRequired': 'Primero ingresa un nombre de sesión',
|
||||
'systemManager.tmux.empty': 'No hay sesiones de tmux',
|
||||
'systemManager.tmux.attach': 'Conectar',
|
||||
'systemManager.tmux.attached': 'Conectada',
|
||||
'systemManager.tmux.detached': 'Desacoplada',
|
||||
'systemManager.tmux.windows': '{{count}} ventana(s)',
|
||||
'systemManager.tmux.created': 'Creada',
|
||||
'systemManager.tmux.activity': 'Actividad',
|
||||
'systemManager.tmux.rename': 'Renombrar',
|
||||
'systemManager.tmux.detach': 'Desacoplar todo',
|
||||
'systemManager.tmux.killSession': 'Terminar sesión',
|
||||
'systemManager.tmux.killServer': 'Terminar servidor',
|
||||
'systemManager.tmux.loadingDetails': 'Cargando detalles…',
|
||||
'systemManager.tmux.clients': 'Clientes conectados',
|
||||
'systemManager.tmux.windowList': 'Ventanas',
|
||||
'systemManager.tmux.newWindow': 'Nueva ventana',
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Nombre de la ventana (opcional)',
|
||||
'systemManager.tmux.noWindows': 'No hay ventanas',
|
||||
'systemManager.tmux.unavailable': 'tmux no está disponible en este host',
|
||||
'systemManager.docker.unavailable': 'Docker no está disponible en este host',
|
||||
'systemManager.tmux.windowsMismatch': 'La sesión reporta {{count}} ventana(s), pero list-windows no devolvió ninguna',
|
||||
'systemManager.tmux.lastCommand': 'último comando: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'No hay paneles',
|
||||
'systemManager.tmux.panes': '{{count}} panel(es)',
|
||||
'systemManager.tmux.active': 'activo',
|
||||
'systemManager.tmux.unnamedWindow': 'Ventana sin nombre',
|
||||
'systemManager.tmux.unnamedPane': 'Panel sin nombre',
|
||||
'systemManager.tmux.attachWindow': 'Conectar a la ventana',
|
||||
'systemManager.tmux.selectWindow': 'Seleccionar ventana',
|
||||
'systemManager.tmux.killWindow': 'Terminar ventana',
|
||||
'systemManager.tmux.killPane': 'Terminar panel',
|
||||
'systemManager.tmux.splitHorizontal': 'Dividir horizontal',
|
||||
'systemManager.tmux.splitVertical': 'Dividir vertical',
|
||||
'systemManager.tmux.sendKeys': 'Enviar teclas',
|
||||
'systemManager.tmux.sendKeysTo': 'Enviar teclas a la ventana {{window}} panel {{pane}}',
|
||||
'systemManager.tmux.sendKeysPlaceholder': 'Comando o texto…',
|
||||
'systemManager.tmux.renameSessionPrompt': 'Renombrar sesión',
|
||||
'systemManager.tmux.renameWindowPrompt': 'Renombrar ventana',
|
||||
'systemManager.tmux.windowName': 'Nombre de la ventana',
|
||||
'systemManager.tmux.confirmKillSession': '¿Terminar la sesión de tmux "{{name}}"?',
|
||||
'systemManager.tmux.confirmDetachSession': '¿Desacoplar todos los clientes de "{{name}}"?',
|
||||
'systemManager.tmux.confirmKillWindow': '¿Terminar la ventana "{{name}}"?',
|
||||
'systemManager.tmux.confirmKillPane': '¿Terminar el panel #{{index}}?',
|
||||
'systemManager.tmux.confirmKillServer': '¿Terminar el servidor de tmux? Se terminarán todas las sesiones.',
|
||||
'systemManager.tmux.meta': '{{count}} sesión(es)',
|
||||
|
||||
'systemManager.docker.title': 'Contenedores',
|
||||
'systemManager.docker.subTabs.containers': 'Contenedores',
|
||||
'systemManager.docker.subTabs.images': 'Imágenes',
|
||||
'systemManager.docker.empty': 'No se encontraron contenedores',
|
||||
'systemManager.docker.imagesEmpty': 'No se encontraron imágenes',
|
||||
'systemManager.docker.search': 'Buscar contenedores…',
|
||||
'systemManager.docker.searchImages': 'Buscar imágenes…',
|
||||
'systemManager.docker.filter.all': 'Todos',
|
||||
'systemManager.docker.filter.running': 'En ejecución',
|
||||
'systemManager.docker.filter.stopped': 'Detenidos',
|
||||
'systemManager.docker.filter.paused': 'En pausa',
|
||||
'systemManager.docker.shell': 'Shell',
|
||||
'systemManager.docker.logs': 'Registros',
|
||||
'systemManager.docker.details': 'Detalles',
|
||||
'systemManager.docker.inspect': 'Inspeccionar',
|
||||
'systemManager.docker.imageInspect': 'Inspección de imagen',
|
||||
'systemManager.docker.confirmRemove': '¿Eliminar este contenedor?',
|
||||
'systemManager.docker.confirmKill': '¿Terminar por la fuerza este contenedor?',
|
||||
'systemManager.docker.confirmRemoveImage': '¿Eliminar la imagen "{{name}}"?',
|
||||
'systemManager.docker.confirmPrune': '¿Eliminar las imágenes colgantes?',
|
||||
'systemManager.docker.confirmPruneAll': '¿Eliminar todas las imágenes no utilizadas?',
|
||||
'systemManager.docker.pause': 'Pausar',
|
||||
'systemManager.docker.unpause': 'Reanudar',
|
||||
'systemManager.docker.restart': 'Reiniciar',
|
||||
'systemManager.docker.kill': 'Terminar',
|
||||
'systemManager.docker.renamePrompt': 'Nombre del contenedor',
|
||||
'systemManager.docker.prune': 'Limpiar',
|
||||
'systemManager.docker.pruneAll': 'Limpiar todo',
|
||||
'systemManager.docker.tag': 'Etiqueta',
|
||||
'systemManager.docker.tagRepoPrompt': 'Nombre del repositorio',
|
||||
'systemManager.docker.tagNamePrompt': 'Nombre de la etiqueta',
|
||||
'systemManager.docker.meta': '{{count}} contenedor(es)',
|
||||
'systemManager.docker.imagesMeta': '{{count}} imagen(es)',
|
||||
'systemManager.docker.start': 'Iniciar',
|
||||
'systemManager.docker.stop': 'Detener',
|
||||
|
||||
'systemManager.ports.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
|
||||
'systemManager.ports.loading': 'Cargando puertos en escucha…',
|
||||
'systemManager.ports.empty': 'No se reportaron puertos en escucha.',
|
||||
'systemManager.ports.search': 'Buscar puertos…',
|
||||
'systemManager.ports.meta': '{{count}} en escucha',
|
||||
'systemManager.ports.filter.all': 'Todos',
|
||||
'systemManager.ports.unknownProcess': 'Proceso desconocido',
|
||||
'systemManager.ports.terminate': 'Terminar',
|
||||
'systemManager.ports.confirmTerminate': '¿Enviar SIGTERM al proceso {{pid}} que ocupa este puerto?',
|
||||
|
||||
'systemManager.services.unavailable': 'systemctl no está disponible en este host.',
|
||||
'systemManager.services.loading': 'Cargando servicios de systemd…',
|
||||
'systemManager.services.empty': 'No se encontraron servicios de systemd.',
|
||||
'systemManager.services.search': 'Buscar servicios…',
|
||||
'systemManager.services.meta': '{{count}} servicio(s)',
|
||||
'systemManager.services.filter.all': 'Todos',
|
||||
'systemManager.services.filter.running': 'En ejecución',
|
||||
'systemManager.services.filter.failed': 'Con error',
|
||||
'systemManager.services.filter.inactive': 'Inactivo',
|
||||
'systemManager.services.start': 'Iniciar',
|
||||
'systemManager.services.stop': 'Detener',
|
||||
'systemManager.services.restart': 'Reiniciar',
|
||||
'systemManager.services.enable': 'Habilitar',
|
||||
'systemManager.services.disable': 'Deshabilitar',
|
||||
'systemManager.services.reload': 'Recargar',
|
||||
'systemManager.services.scope.user': 'usuario',
|
||||
'systemManager.services.confirmAction': '¿{{action}} {{name}}?',
|
||||
|
||||
'systemManager.gpu.unavailable': 'No se detectaron herramientas de GPU NVIDIA o NPU Ascend en este host.',
|
||||
'systemManager.gpu.loading': 'Cargando estadísticas del acelerador…',
|
||||
'systemManager.gpu.empty': 'Las herramientas del acelerador están presentes, pero no se reportó ningún dispositivo.',
|
||||
'systemManager.gpu.meta': '{{devices}} dispositivo(s) · {{processes}} proceso(s)',
|
||||
'systemManager.gpu.devices': 'Dispositivos',
|
||||
'systemManager.gpu.processes': 'Procesos de cómputo',
|
||||
'systemManager.gpu.noProcesses': 'No se reportaron procesos de cómputo.',
|
||||
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
|
||||
'systemManager.gpu.vendor.ascend': 'Ascend',
|
||||
'systemManager.gpu.util': 'Util',
|
||||
'systemManager.gpu.memory': 'VRAM',
|
||||
'systemManager.gpu.hbm': 'HBM',
|
||||
'systemManager.gpu.temperature': 'Temperatura',
|
||||
'systemManager.gpu.power': 'Energía',
|
||||
'systemManager.gpu.fan': 'Ventilador {{value}}%',
|
||||
'systemManager.gpu.driver': 'Controlador {{version}}',
|
||||
|
||||
'systemManager.inspect.status': 'Estado',
|
||||
'systemManager.inspect.image': 'Imagen',
|
||||
'systemManager.inspect.created': 'Creado',
|
||||
'systemManager.inspect.started': 'Iniciado',
|
||||
'systemManager.inspect.restartPolicy': 'Política de reinicio',
|
||||
'systemManager.inspect.command': 'Comando',
|
||||
'systemManager.inspect.ports': 'Puertos',
|
||||
'systemManager.inspect.networks': 'Redes',
|
||||
'systemManager.inspect.mounts': 'Montajes',
|
||||
'systemManager.inspect.env': 'Entorno',
|
||||
'systemManager.inspect.labels': 'Etiquetas',
|
||||
'systemManager.inspect.tags': 'Etiquetas',
|
||||
'systemManager.inspect.digests': 'Digests',
|
||||
'systemManager.inspect.size': 'Tamaño',
|
||||
'systemManager.inspect.platform': 'Plataforma',
|
||||
'systemManager.inspect.workdir': 'Directorio de trabajo',
|
||||
'systemManager.inspect.exposedPorts': 'Puertos expuestos',
|
||||
'systemManager.inspect.showRaw': 'JSON',
|
||||
'systemManager.inspect.hideRaw': 'Ocultar JSON',
|
||||
};
|
||||
@@ -0,0 +1,843 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': 'Presiona Enter para pegar la contraseña guardada',
|
||||
'terminal.passwordPicker.title': 'Contraseñas guardadas',
|
||||
'terminal.passwordPicker.empty': 'No hay contraseñas guardadas',
|
||||
// Network Device Mode auto-detection tip (session header)
|
||||
'terminal.networkDevice.tip.message': 'Esto parece un dispositivo de red. Activa el Modo de Dispositivo de Red para enviar comandos tal cual (sin envoltorio de shell).',
|
||||
'terminal.networkDevice.tip.action': 'Activar',
|
||||
'terminal.networkDevice.tip.dismiss': 'Descartar',
|
||||
'terminal.networkDevice.tip.enabled': 'Modo de Dispositivo de Red activado para {host}',
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Abrir SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': 'Disponible después de conectar',
|
||||
'terminal.toolbar.sendYmodem': 'Enviar con YMODEM',
|
||||
'terminal.toolbar.receiveYmodem': 'Recibir con YMODEM',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': 'Más acciones',
|
||||
'terminal.toolbar.scripts': 'Scripts',
|
||||
'terminal.toolbar.history': 'Historial de comandos',
|
||||
'terminal.toolbar.configureOsc7': 'Configurar seguimiento de directorio',
|
||||
'history.scope.label': 'Alcance del historial',
|
||||
'history.tab.host': 'Host',
|
||||
'history.tab.global': 'Global',
|
||||
'history.searchPlaceholder': 'Buscar en el historial...',
|
||||
'history.loading': 'Cargando historial remoto...',
|
||||
'history.meta.count': '{count} comandos',
|
||||
'history.empty.noSession': 'Abre una sesión remota para ver su historial de comandos.',
|
||||
'history.empty.unsupportedProtocol': 'El historial de comandos solo está disponible para sesiones SSH/Mosh/ET.',
|
||||
'history.empty.noHistory': 'No se encontró historial de comandos en este host.',
|
||||
'history.empty.noGlobalHistory': 'Aún no hay historial global de comandos. Los comandos que ejecutes aparecerán aquí.',
|
||||
'history.action.refresh': 'Actualizar',
|
||||
'history.action.retry': 'Reintentar',
|
||||
'history.action.paste': 'Pegar en la terminal',
|
||||
'history.action.run': 'Ejecutar en la terminal',
|
||||
'history.action.saveAsSnippet': 'Guardar como snippet',
|
||||
'history.action.delete': 'Eliminar del historial',
|
||||
'terminal.toolbar.library': 'Biblioteca',
|
||||
'terminal.toolbar.noSnippets': 'No hay snippets disponibles',
|
||||
'terminal.toolbar.terminalSettings': 'Configuración de la terminal',
|
||||
'terminal.toolbar.searchTerminal': 'Buscar en la terminal',
|
||||
'terminal.toolbar.search': 'Buscar',
|
||||
'terminal.toolbar.startSessionLog': 'Iniciar registro de sesión',
|
||||
'terminal.toolbar.stopSessionLog': 'Detener registro de sesión',
|
||||
'terminal.toolbar.timestampsEnable': 'Mostrar marcas de tiempo',
|
||||
'terminal.toolbar.timestampsDisable': 'Ocultar marcas de tiempo',
|
||||
'terminal.toolbar.broadcast': 'Difundir',
|
||||
'terminal.toolbar.broadcastEnable': 'Activar Modo de Difusión',
|
||||
'terminal.toolbar.broadcastDisable': 'Desactivar Modo de Difusión',
|
||||
'terminal.toolbar.composeBar': 'Barra de redacción',
|
||||
'terminal.composeBar.placeholder': 'Escribe el comando aquí y presiona Enter para enviar...',
|
||||
'terminal.composeBar.send': 'Enviar',
|
||||
'terminal.composeBar.close': 'Cerrar barra de redacción',
|
||||
'terminal.composeBar.broadcasting': 'Difundiendo a todas las sesiones',
|
||||
'terminal.composeBar.resize': 'Cambiar la altura de la barra de redacción',
|
||||
'terminal.composeBar.manageSnippets': 'Administrar snippets rápidos',
|
||||
'terminal.composeBar.searchSnippets': 'Buscar snippets...',
|
||||
'terminal.composeBar.noPinnedSnippets': 'Fija snippets con + para acceso rápido',
|
||||
'terminal.composeBar.noMatchingSnippets': 'No hay snippets que coincidan',
|
||||
'terminal.composeBar.pinnedCount': '{count} fijados',
|
||||
'terminal.composeBar.unpinSnippet': 'Quitar {label} de la barra rápida',
|
||||
'terminal.composeBar.snippetClickHint': 'Clic para insertar · Shift+Clic para enviar',
|
||||
'terminal.toolbar.focus': 'Enfocar',
|
||||
'terminal.toolbar.focusMode': 'Modo de Enfoque',
|
||||
'terminal.toolbar.detach': 'Desacoplar a pestaña independiente',
|
||||
'terminal.toolbar.dragPane': 'Arrastrar panel de terminal',
|
||||
'terminal.toolbar.showActions': 'Mostrar acciones de la terminal',
|
||||
'terminal.toolbar.encoding': 'Codificación de la terminal',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
'terminal.toolbar.closeSession': 'Cerrar sesión',
|
||||
'terminal.toolbar.hostHighlight.title': 'Resaltado de palabras clave del host',
|
||||
'terminal.toolbar.hostHighlight.noRules': 'No hay reglas de resaltado personalizadas definidas para este host',
|
||||
'terminal.toolbar.hostHighlight.addRule': 'Agregar nueva regla',
|
||||
'terminal.toolbar.hostHighlight.labelPlaceholder': 'Etiqueta (p. ej., Error)',
|
||||
'terminal.toolbar.hostHighlight.patternPlaceholder': 'Patrón regex (p. ej., \\bfailed\\b)',
|
||||
'terminal.toolbar.hostHighlight.invalidPattern': 'Patrón regex no válido',
|
||||
'terminal.toolbar.hostHighlight.clearAll': 'Borrar todo',
|
||||
'terminal.toolbar.hostHighlight.changeColor': 'Cambiar el color de resaltado de',
|
||||
'terminal.toolbar.hostHighlight.selectColor': 'Seleccionar el color de la nueva regla',
|
||||
'terminal.statusbar.copyHostname.label': 'Copiar dirección del host',
|
||||
'terminal.statusbar.copyHostname.tooltip': 'Copiar dirección del host ({hostname})',
|
||||
'terminal.statusbar.copyHostname.toast': 'Dirección del host copiada: {hostname}',
|
||||
'terminal.statusbar.copyHostname.error': 'No se pudo copiar la dirección del host al portapapeles',
|
||||
'terminal.statusbar.disconnect.label': 'Desconectar',
|
||||
'terminal.statusbar.disconnect.tooltip': 'Desconectar esta sesión sin cerrar la pestaña',
|
||||
'terminal.statusbar.reconnect.label': 'Reconectar',
|
||||
'terminal.statusbar.reconnect.tooltip': 'Reconectar esta sesión',
|
||||
'terminal.serverStats.cpu': 'Uso de CPU',
|
||||
'terminal.serverStats.cpuCores': 'Uso de núcleos de CPU',
|
||||
'terminal.serverStats.memory': 'Uso de memoria',
|
||||
'terminal.serverStats.memoryDetails': 'Detalles de memoria',
|
||||
'terminal.serverStats.memUsed': 'Usada',
|
||||
'terminal.serverStats.memBuffers': 'Búfers',
|
||||
'terminal.serverStats.memCached': 'Caché',
|
||||
'terminal.serverStats.memFree': 'Libre',
|
||||
'terminal.serverStats.swap': 'Swap',
|
||||
'terminal.serverStats.swapUsed': 'Swap usada',
|
||||
'terminal.serverStats.swapFree': 'Swap libre',
|
||||
'terminal.serverStats.swapTotal': 'Total',
|
||||
'terminal.serverStats.topProcesses': 'Principales procesos por memoria',
|
||||
'terminal.serverStats.disk': 'Uso de disco',
|
||||
'terminal.serverStats.diskDetails': 'Discos montados',
|
||||
'terminal.serverStats.network': 'Velocidad de red',
|
||||
'terminal.serverStats.latency': 'Latencia de red SSH',
|
||||
'terminal.serverStats.networkDetails': 'Interfaces de red',
|
||||
'terminal.serverStats.noData': 'No hay datos disponibles',
|
||||
'terminal.dragDrop.localTitle': 'Soltar para insertar rutas',
|
||||
'terminal.dragDrop.localMessage': 'Las rutas de los archivos se insertarán en la terminal',
|
||||
'terminal.dragDrop.remoteTitle': 'Soltar para subir archivos',
|
||||
'terminal.dragDrop.remoteZmodemMessage': 'Los archivos se subirán mediante ZMODEM (PTY)',
|
||||
'terminal.dragDrop.remoteSftpMessage': 'Los archivos se subirán mediante SFTP',
|
||||
'terminal.dragDrop.noFiles': 'No hay archivos para subir',
|
||||
'terminal.dragDrop.notConnected': 'No se pueden soltar archivos: la terminal no está conectada',
|
||||
'terminal.dragDrop.errorTitle': 'Error al soltar',
|
||||
'terminal.dragDrop.errorMessage': 'No se pudieron procesar los archivos soltados',
|
||||
'terminal.search.placeholder': 'Buscar...',
|
||||
'terminal.search.noResults': 'Sin resultados',
|
||||
'terminal.search.prevMatch': 'Coincidencia anterior (Shift+Enter)',
|
||||
'terminal.search.nextMatch': 'Coincidencia siguiente (Enter)',
|
||||
'terminal.menu.copy': 'Copiar',
|
||||
'terminal.menu.paste': 'Pegar',
|
||||
'terminal.menu.uploadClipboardImage': 'Subir imagen del portapapeles',
|
||||
'terminal.menu.addSelectionToAI': 'Agregar a la conversación',
|
||||
'terminal.menu.pasteSelection': 'Pegar selección',
|
||||
'terminal.menu.selectAll': 'Seleccionar todo',
|
||||
'terminal.menu.reconnect': 'Reconectar',
|
||||
'terminal.menu.sendYmodem': 'Enviar con YMODEM',
|
||||
'terminal.menu.receiveYmodem': 'Recibir con YMODEM',
|
||||
'terminal.menu.splitHorizontal': 'Dividir en horizontal',
|
||||
'terminal.menu.splitVertical': 'Dividir en vertical',
|
||||
'terminal.menu.clearBuffer': 'Limpiar búfer',
|
||||
'terminal.menu.closeTerminal': 'Cerrar terminal',
|
||||
'terminal.menu.rename': 'Renombrar',
|
||||
'terminal.menu.detach': 'Desacoplar del espacio de trabajo',
|
||||
'terminal.menu.detachSession': 'Desacoplar {name}',
|
||||
'terminal.clipboardImageUpload.noImage': 'El portapapeles no contiene una imagen',
|
||||
'terminal.clipboardImageUpload.failed': 'No se pudo subir la imagen del portapapeles',
|
||||
'terminal.osc7Setup.title': 'Configurar seguimiento de directorio',
|
||||
'terminal.osc7Setup.desc': 'Netcatty agregará hooks de prompt OSC 7 para el usuario remoto actual. Esto ayuda a que SFTP siga el directorio de la terminal después de sudo o su.',
|
||||
'terminal.osc7Setup.targets': 'Posibles archivos a actualizar',
|
||||
'terminal.osc7Setup.command': 'Comando a ejecutar',
|
||||
'terminal.osc7Setup.run': 'Ejecutar configuración',
|
||||
'terminal.osc7Setup.running': 'Configurando...',
|
||||
'terminal.osc7Setup.configured': 'Seguimiento de directorio configurado',
|
||||
'terminal.osc7Setup.failed': 'Falló la configuración del seguimiento de directorio',
|
||||
'terminal.osc7Setup.sent': 'Configuración de seguimiento de directorio enviada a la terminal',
|
||||
'terminal.ymodem.selectFile': 'Selecciona el archivo a enviar',
|
||||
'terminal.ymodem.allFiles': 'Todos los archivos',
|
||||
'terminal.ymodem.started': 'YMODEM enviando {fileName}',
|
||||
'terminal.ymodem.complete': 'YMODEM envió {fileName}',
|
||||
'terminal.ymodem.failed': 'Falló el envío con YMODEM',
|
||||
'terminal.ymodem.selectReceiveDirectory': 'Selecciona la carpeta para guardar los archivos recibidos',
|
||||
'terminal.ymodem.receiveStarted': 'YMODEM recibiendo...',
|
||||
'terminal.ymodem.receiveComplete': 'YMODEM recibió {fileName}',
|
||||
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM recibió {count} archivos',
|
||||
'terminal.ymodem.receiveEmpty': 'No se recibieron archivos YMODEM',
|
||||
'terminal.ymodem.receiveFailed': 'Falló la recepción con YMODEM',
|
||||
'terminal.ymodem.unavailable': 'YMODEM no está disponible',
|
||||
'terminal.selection.addToAI': 'Agregar a la conversación',
|
||||
'terminal.selection.addToAIDesc': 'Adjuntar la salida seleccionada de la terminal al borrador de IA',
|
||||
'terminal.auth.password': 'Contraseña',
|
||||
'terminal.auth.sshKey': 'Clave SSH',
|
||||
'terminal.auth.username': 'Nombre de usuario',
|
||||
'terminal.auth.username.placeholder': 'root',
|
||||
'terminal.auth.passwordLabel': 'Contraseña',
|
||||
'terminal.auth.password.placeholder': 'Ingresa la contraseña',
|
||||
'terminal.auth.passphrase': 'Frase de contraseña',
|
||||
'terminal.auth.passphrase.placeholder': 'Frase de contraseña opcional para la clave privada seleccionada',
|
||||
'terminal.auth.certificate': 'Certificado',
|
||||
'terminal.auth.selectKey': 'Seleccionar clave',
|
||||
'terminal.auth.retryMessage': 'Falló la autenticación. Verifica tus credenciales e inténtalo de nuevo.',
|
||||
'terminal.auth.retryLog': 'Falló la autenticación. Inténtalo de nuevo.',
|
||||
'terminal.auth.noKeysHint': 'No hay claves disponibles. Agrega claves en Keychain.',
|
||||
'terminal.auth.continueSave': 'Continuar y guardar',
|
||||
'terminal.auth.credentialsUnavailable': 'Las credenciales guardadas no se pueden descifrar en este dispositivo. Vuelve a ingresarlas y guárdalas de nuevo.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'Un host de salto tiene credenciales guardadas que no se pueden descifrar en este dispositivo. Abre la configuración del host y vuelve a ingresarlas.',
|
||||
'terminal.auth.proxyCredentialsUnavailable': 'Las credenciales del proxy no se pueden descifrar en este dispositivo. Abre la configuración del host y vuelve a ingresar la contraseña del proxy.',
|
||||
'terminal.auth.keyUnavailableFallbackPassword': 'La clave SSH guardada no está disponible en este dispositivo. Se usará autenticación por contraseña como respaldo.',
|
||||
'terminal.progress.timeoutIn': 'Tiempo de espera en {seconds}s',
|
||||
'terminal.progress.waitingForUserInput': 'Esperando entrada del usuario',
|
||||
'terminal.progress.disconnected': 'Desconectado',
|
||||
'terminal.progress.cancelling': 'Cancelando...',
|
||||
'terminal.progress.startOver': 'Empezar de nuevo',
|
||||
'terminal.progress.enterReconnectHint': 'Presiona Enter para reconectar',
|
||||
'terminal.progress.autoReconnectScheduled': 'Conexión perdida. Reconectando en {seconds}s (intento {attempt}).',
|
||||
'terminal.progress.autoReconnectAttempt': 'Intento de reconexión automática {attempt}...',
|
||||
'terminal.connection.dismissDisconnectedDialog': 'Descartar aviso de desconexión',
|
||||
'terminal.connection.chainOf': 'Cadena {current} de {total}',
|
||||
'terminal.connection.showLogs': 'Mostrar registros',
|
||||
'terminal.connection.hideLogs': 'Ocultar registros',
|
||||
'terminal.connection.protocol.ssh': 'SSH',
|
||||
'terminal.connection.protocol.telnet': 'Telnet',
|
||||
'terminal.connection.protocol.mosh': 'Mosh',
|
||||
'terminal.connection.protocol.et': 'EternalTerminal',
|
||||
'terminal.connection.protocol.plugin': 'Conexión de plugin',
|
||||
'terminal.et.proxyUnsupported': 'EternalTerminal no admite actualmente la configuración de proxy de Netcatty. Usa SSH o quita el proxy de este host.',
|
||||
'terminal.et.multiJumpUnsupported': 'EternalTerminal admite actualmente como máximo un host de salto en Netcatty.',
|
||||
'terminal.connection.protocol.serial': 'Serial',
|
||||
'terminal.connection.protocol.local': 'Shell local',
|
||||
'terminal.hostKey.unknownTitle': 'Confirmar esta clave de host',
|
||||
'terminal.hostKey.changedTitle': 'La clave del host cambió',
|
||||
'terminal.hostKey.unknownDescription': 'Aún no se puede establecer la autenticidad de {host}.',
|
||||
'terminal.hostKey.changedDescription': 'La clave guardada de {host} ya no coincide con este servidor.',
|
||||
'terminal.hostKey.fingerprintLabel': 'La huella digital de {keyType} es SHA256:',
|
||||
'terminal.hostKey.savedFingerprintLabel': 'Huella guardada',
|
||||
'terminal.hostKey.unknownHint': 'Recuérdala si esta huella pertenece al servidor que esperabas.',
|
||||
'terminal.hostKey.changedHint': 'Continúa solo si esperabas que este host cambiara.',
|
||||
'terminal.hostKey.addAndContinue': 'Agregar y continuar',
|
||||
'terminal.hostKey.updateAndContinue': 'Actualizar y continuar',
|
||||
'terminal.themeModal.title': 'Apariencia de la terminal',
|
||||
'terminal.themeModal.tab.theme': 'Tema',
|
||||
'terminal.themeModal.tab.font': 'Fuente',
|
||||
'terminal.themeModal.tab.custom': 'Personalizado',
|
||||
'terminal.themeModal.globalTheme': 'Tema global',
|
||||
'terminal.themeModal.globalFont': 'Fuente global',
|
||||
'terminal.themeModal.fontSize': 'Tamaño de fuente',
|
||||
'terminal.themeModal.fontWeight': 'Grosor de fuente',
|
||||
'terminal.themeModal.livePreview': 'Vista previa en vivo',
|
||||
'terminal.themeModal.themeType': 'Tema {type}',
|
||||
'terminal.hiddenTheme.title': 'Tema oculto actual',
|
||||
'terminal.hiddenTheme.desc': 'Este tema está oculto de la selección manual y se reemplazará cuando elijas otro tema.',
|
||||
'topTabs.toggleTheme.systemExitTitle': 'El tema del sistema está activo',
|
||||
'topTabs.toggleTheme.systemExitMessage': 'Abre Configuración para elegir un tema fijo de Claro u Oscuro.',
|
||||
'topTabs.toggleTheme.openSettings': 'Abrir Configuración',
|
||||
|
||||
// Custom Themes
|
||||
'terminal.customTheme.section': 'Temas personalizados',
|
||||
'terminal.customTheme.yourThemes': 'Tus temas',
|
||||
'terminal.customTheme.new': 'Nuevo tema',
|
||||
'terminal.customTheme.newDesc': 'Clonar el tema actual y personalizarlo',
|
||||
'terminal.customTheme.newTitle': 'Nuevo tema personalizado',
|
||||
'terminal.customTheme.editTitle': 'Editar tema',
|
||||
'terminal.customTheme.import': 'Importar .itermcolors',
|
||||
'terminal.customTheme.importDesc': 'Importar desde archivo de esquema de color de iTerm2',
|
||||
'terminal.customTheme.importError': 'No se pudo analizar el archivo seleccionado. Asegúrate de que sea un archivo XML .itermcolors válido.',
|
||||
'terminal.customTheme.delete': 'Eliminar tema',
|
||||
'terminal.customTheme.confirmDelete': 'Confirmar eliminación',
|
||||
'terminal.customTheme.name': 'Nombre',
|
||||
'terminal.customTheme.namePlaceholder': 'Mi tema personalizado',
|
||||
'terminal.customTheme.type': 'Tipo',
|
||||
'terminal.customTheme.group.general': 'General',
|
||||
'terminal.customTheme.group.normal': 'Colores normales',
|
||||
'terminal.customTheme.group.bright': 'Colores brillantes',
|
||||
'terminal.customTheme.color.background': 'Fondo',
|
||||
'terminal.customTheme.color.foreground': 'Primer plano',
|
||||
'terminal.customTheme.color.cursor': 'Cursor',
|
||||
'terminal.customTheme.color.selection': 'Selección',
|
||||
'terminal.customTheme.color.black': 'Negro',
|
||||
'terminal.customTheme.color.red': 'Rojo',
|
||||
'terminal.customTheme.color.green': 'Verde',
|
||||
'terminal.customTheme.color.yellow': 'Amarillo',
|
||||
'terminal.customTheme.color.blue': 'Azul',
|
||||
'terminal.customTheme.color.magenta': 'Magenta',
|
||||
'terminal.customTheme.color.cyan': 'Cian',
|
||||
'terminal.customTheme.color.white': 'Blanco',
|
||||
'terminal.customTheme.color.brightBlack': 'Negro brillante',
|
||||
'terminal.customTheme.color.brightRed': 'Rojo brillante',
|
||||
'terminal.customTheme.color.brightGreen': 'Verde brillante',
|
||||
'terminal.customTheme.color.brightYellow': 'Amarillo brillante',
|
||||
'terminal.customTheme.color.brightBlue': 'Azul brillante',
|
||||
'terminal.customTheme.color.brightMagenta': 'Magenta brillante',
|
||||
'terminal.customTheme.color.brightCyan': 'Cian brillante',
|
||||
'terminal.customTheme.color.brightWhite': 'Blanco brillante',
|
||||
|
||||
// Cloud Sync Settings
|
||||
'cloudSync.gate.title': 'Sincronización cifrada de extremo a extremo',
|
||||
'cloudSync.gate.desc':
|
||||
'Tus datos se cifran localmente antes de sincronizarse. Los proveedores de la nube nunca ven tus datos en texto plano. Configura una clave maestra para habilitar la sincronización segura.',
|
||||
'cloudSync.gate.masterKey': 'Clave maestra',
|
||||
'cloudSync.gate.confirmMasterKey': 'Confirmar clave maestra',
|
||||
'cloudSync.gate.placeholder': 'Ingresa una contraseña segura',
|
||||
'cloudSync.gate.confirmPlaceholder': 'Confirma tu contraseña',
|
||||
'cloudSync.gate.mismatch': 'Las contraseñas no coinciden',
|
||||
'cloudSync.gate.warning':
|
||||
'Entiendo que, si olvido mi clave maestra, mis datos no se podrán recuperar. No hay restablecimiento de contraseña.',
|
||||
'cloudSync.gate.enableVault': 'Habilitar bóveda cifrada',
|
||||
'cloudSync.gate.enabledToast': 'Bóveda cifrada habilitada',
|
||||
'cloudSync.gate.setupFailed': 'No se pudo configurar la clave maestra',
|
||||
'cloudSync.passwordStrength.tooShort': 'Demasiado corta',
|
||||
'cloudSync.passwordStrength.weak': 'Débil',
|
||||
'cloudSync.passwordStrength.moderate': 'Moderada',
|
||||
'cloudSync.passwordStrength.strong': 'Segura',
|
||||
'cloudSync.passwordStrength.veryStrong': 'Muy segura',
|
||||
'cloudSync.provider.notConnected': 'No conectado',
|
||||
'cloudSync.provider.sync': 'Sincronizar',
|
||||
'cloudSync.provider.connect': 'Conectar',
|
||||
'cloudSync.provider.connecting': 'Conectando...',
|
||||
'cloudSync.provider.webdav': 'WebDAV',
|
||||
'cloudSync.provider.webdav.desc': 'Conectar a un endpoint WebDAV autoalojado',
|
||||
'cloudSync.provider.s3': 'Compatible con S3',
|
||||
'cloudSync.provider.s3.desc': 'Conectar a almacenamiento de objetos compatible con S3',
|
||||
'cloudSync.provider.comingSoon': 'Próximamente',
|
||||
'cloudSync.webdav.title': 'Configuración de WebDAV',
|
||||
'cloudSync.webdav.desc': 'Configura un endpoint WebDAV para la sincronización cifrada.',
|
||||
'cloudSync.webdav.endpoint': 'URL del endpoint',
|
||||
'cloudSync.webdav.authType': 'Tipo de autenticación',
|
||||
'cloudSync.webdav.auth.basic': 'Básica',
|
||||
'cloudSync.webdav.auth.digest': 'Digest',
|
||||
'cloudSync.webdav.auth.token': 'Token',
|
||||
'cloudSync.webdav.username': 'Nombre de usuario',
|
||||
'cloudSync.webdav.password': 'Contraseña',
|
||||
'cloudSync.webdav.token': 'Token',
|
||||
'cloudSync.webdav.showSecret': 'Mostrar secreto',
|
||||
'cloudSync.webdav.allowInsecure': 'Permitir conexión insegura (ignorar errores de certificado)',
|
||||
'cloudSync.webdav.validation.endpoint': 'Ingresa un endpoint WebDAV válido.',
|
||||
'cloudSync.webdav.validation.credentials': 'El nombre de usuario y la contraseña son obligatorios.',
|
||||
'cloudSync.webdav.validation.token': 'El token es obligatorio.',
|
||||
'cloudSync.s3.title': 'Configuración de S3',
|
||||
'cloudSync.s3.desc': 'Conéctate al almacenamiento de objetos compatible con S3 para la sincronización cifrada.',
|
||||
'cloudSync.s3.endpoint': 'URL del endpoint',
|
||||
'cloudSync.s3.region': 'Región',
|
||||
'cloudSync.s3.bucket': 'Bucket',
|
||||
'cloudSync.s3.accessKeyId': 'ID de clave de acceso',
|
||||
'cloudSync.s3.secretAccessKey': 'Clave de acceso secreta',
|
||||
'cloudSync.s3.sessionToken': 'Token de sesión (opcional)',
|
||||
'cloudSync.s3.prefix': 'Prefijo de clave (opcional)',
|
||||
'cloudSync.s3.forcePathStyle': 'Forzar URLs con estilo de ruta (para MinIO/R2, etc.)',
|
||||
'cloudSync.s3.allowInsecure': 'Permitir conexión insegura (ignorar errores de certificado)',
|
||||
'cloudSync.s3.showSecret': 'Mostrar secretos',
|
||||
'cloudSync.s3.validation.required': 'Endpoint, región, bucket, clave de acceso y secreto son obligatorios.',
|
||||
'cloudSync.smb.title': 'Configuración de SMB',
|
||||
'cloudSync.smb.desc': 'Conéctate a un recurso compartido de archivos SMB/CIFS para la sincronización cifrada.',
|
||||
'cloudSync.smb.share': 'Ruta del recurso compartido',
|
||||
'cloudSync.smb.username': 'Nombre de usuario',
|
||||
'cloudSync.smb.password': 'Contraseña',
|
||||
'cloudSync.smb.domain': 'Dominio (opcional)',
|
||||
'cloudSync.smb.domainPlaceholder': 'p. ej., WORKGROUP',
|
||||
'cloudSync.smb.port': 'Puerto (opcional)',
|
||||
'cloudSync.smb.showSecret': 'Mostrar contraseña',
|
||||
'cloudSync.smb.validation.share': 'La ruta del recurso compartido es obligatoria.',
|
||||
'cloudSync.smb.validation.port': 'El puerto debe ser un número entre 1 y 65535.',
|
||||
'cloudSync.connect.smb.success': 'SMB se conectó correctamente',
|
||||
'cloudSync.connect.smb.failedTitle': 'Falló la conexión a SMB',
|
||||
'cloudSync.provider.smb': 'Recurso compartido SMB',
|
||||
'cloudSync.connect.webdav.success': 'WebDAV se conectó correctamente',
|
||||
'cloudSync.connect.webdav.failedTitle': 'Falló la conexión a WebDAV',
|
||||
'cloudSync.connect.s3.success': 'S3 se conectó correctamente',
|
||||
'cloudSync.connect.s3.failedTitle': 'Falló la conexión a S3',
|
||||
'cloudSync.connect.plugin.success': 'El proveedor de sincronización de plugin se conectó correctamente',
|
||||
'cloudSync.connect.plugin.failedTitle': 'Falló la conexión del plugin de sincronización',
|
||||
'cloudSync.pluginConfig.title': 'Configurar {name}',
|
||||
'cloudSync.pluginConfig.desc': 'Ingresa la configuración JSON requerida por este proveedor de sincronización de plugin.',
|
||||
'cloudSync.pluginConfig.label': 'Configuración del proveedor (JSON)',
|
||||
'cloudSync.pluginConfig.invalidJson': 'La configuración debe ser JSON válido.',
|
||||
'cloudSync.pluginConfig.schemaInvalid': 'La configuración no coincide con el esquema del proveedor.',
|
||||
'cloudSync.lastSync.never': 'Nunca',
|
||||
'cloudSync.lastSync.justNow': 'Justo ahora',
|
||||
'cloudSync.lastSync.minutesAgo': 'Hace {minutes} min',
|
||||
'cloudSync.changeKey': 'Cambiar clave',
|
||||
'cloudSync.providers.title': 'Proveedores de nube',
|
||||
'cloudSync.syncAll': 'Sincronizar todos los proveedores conectados',
|
||||
'cloudSync.autoSync.title': 'Sincronización automática',
|
||||
'cloudSync.autoSync.desc': 'Sincronizar automáticamente cuando se realicen cambios',
|
||||
'cloudSync.strategy.title': 'Estrategia de sincronización',
|
||||
'cloudSync.strategy.desc': 'Elige qué sucede cuando cambian tanto los datos locales como los de la nube.',
|
||||
'cloudSync.strategy.smartMerge': 'Fusión inteligente (recomendada)',
|
||||
'cloudSync.strategy.smartMergeDesc': 'Combina los cambios de ambos lados cuando sea posible; si Netcatty no puede decidir con seguridad, te pedirá que elijas.',
|
||||
'cloudSync.strategy.preferCloud': 'Gana la nube',
|
||||
'cloudSync.strategy.preferCloudDesc': 'Cuando ambos lados cambiaron, descarga la versión de la nube y reemplaza los cambios locales.',
|
||||
'cloudSync.strategy.preferLocal': 'Gana lo local',
|
||||
'cloudSync.strategy.preferLocalDesc': 'Cuando ambos lados cambiaron, sube la versión local y reemplaza los cambios de la nube.',
|
||||
'cloudSync.convergent.title': 'Sincronización convergente de múltiples dispositivos',
|
||||
'cloudSync.convergent.experimental': 'Experimental',
|
||||
'cloudSync.convergent.desc': 'Usa una réplica CRDT cifrada para preservar ediciones sin conexión, eliminaciones concurrentes y cambios de cada proveedor conectado.',
|
||||
'cloudSync.convergent.active': 'CRDT v2 está activo. Las escrituras del proveedor se verifican después de la subida.',
|
||||
'cloudSync.convergent.paused': 'CRDT v2 está en pausa en este dispositivo; los metadatos de la nube se conservan.',
|
||||
'cloudSync.convergent.enabled': 'Sincronización convergente habilitada',
|
||||
'cloudSync.convergent.preview.title': 'Vista previa de migración',
|
||||
'cloudSync.convergent.preview.entities': 'Entidades',
|
||||
'cloudSync.convergent.preview.providers': 'Proveedores',
|
||||
'cloudSync.convergent.preview.conflicts': 'Conflictos',
|
||||
'cloudSync.convergent.preview.compatibility': 'Una instantánea v1 completa permanece en cada payload cifrado para clientes más antiguos.',
|
||||
'cloudSync.convergent.preview.confirm': 'Crear réplica CRDT',
|
||||
'cloudSync.convergent.preview.status.ready': 'Listo',
|
||||
'cloudSync.convergent.preview.status.empty': 'Vacío',
|
||||
'cloudSync.convergent.preview.status.unavailable': 'No disponible',
|
||||
'cloudSync.convergent.preview.status.blocked': 'Bloqueado',
|
||||
'cloudSync.convergent.preview.schema': 'esquema',
|
||||
'cloudSync.convergent.field.presence': 'presencia',
|
||||
'cloudSync.convergent.field.position': 'posición',
|
||||
'cloudSync.convergent.conflicts.title': 'Conflictos de campo ({count})',
|
||||
'cloudSync.convergent.conflict.empty': 'Vacío / eliminado',
|
||||
'cloudSync.convergent.conflict.secretSet': 'El secreto está establecido',
|
||||
'cloudSync.convergent.conflict.current': 'ganador actual',
|
||||
'cloudSync.convergent.conflict.choose': 'Elegir',
|
||||
'cloudSync.convergent.conflict.resolved': 'Conflicto resuelto y sincronizado',
|
||||
'cloudSync.convergent.downgrade.desc': 'Reemplaza los archivos v2 de cada proveedor conectado con una instantánea heredada.',
|
||||
'cloudSync.convergent.downgrade.button': 'Degradar',
|
||||
'cloudSync.convergent.downgrade.confirm': '¿Degradar cada proveedor conectado a la sincronización heredada? Esto elimina los metadatos CRDT después de la verificación de escritura.',
|
||||
'cloudSync.convergent.downgrade.done': 'Sincronización convergente degradada',
|
||||
'cloudSync.status.title': 'Estado de sincronización',
|
||||
'cloudSync.status.localVersion': 'Versión local',
|
||||
'cloudSync.status.remoteVersion': 'Versión remota',
|
||||
'cloudSync.history.title': 'Historial de sincronización',
|
||||
'cloudSync.history.upload': 'Subida',
|
||||
'cloudSync.history.download': 'Descarga',
|
||||
'cloudSync.history.resolved': 'Resuelto',
|
||||
'cloudSync.history.error': 'Error',
|
||||
'cloudSync.localBackups.title': 'Historial de copias de seguridad locales',
|
||||
'cloudSync.localBackups.desc': 'Netcatty conserva puntos de restauración locales antes de los cambios de versión de la app y antes de las restauraciones de la bóveda.',
|
||||
'cloudSync.localBackups.retentionTitle': 'Retención de copias de seguridad',
|
||||
'cloudSync.localBackups.retentionDesc': 'Elige cuántas copias de seguridad locales debe conservar Netcatty.',
|
||||
'cloudSync.localBackups.maxCount': 'Copias máximas',
|
||||
'cloudSync.localBackups.maxSaved': 'Retención de copia guardada: {count}',
|
||||
'cloudSync.localBackups.maxInvalid': 'Ingresa un número entre 1 y 100.',
|
||||
'cloudSync.localBackups.empty': 'Aún no hay copias de seguridad locales.',
|
||||
'cloudSync.localBackups.reason.appVersionChange': 'Antes del cambio de versión de la app',
|
||||
'cloudSync.localBackups.reason.beforeRestore': 'Antes de la restauración',
|
||||
'cloudSync.localBackups.versionChange': '{from} -> {to}',
|
||||
'cloudSync.localBackups.counts': '{hosts} hosts, {keys} claves, {snippets} snippets, {notes} notas',
|
||||
'cloudSync.localBackups.restore': 'Restaurar',
|
||||
'cloudSync.localBackups.restoreSuccess': 'Copia de seguridad local restaurada.',
|
||||
'cloudSync.localBackups.restoreFailedTitle': 'Falló la restauración',
|
||||
'cloudSync.localBackups.restoreMissing': 'No se encontró la copia de seguridad.',
|
||||
'cloudSync.localBackups.protectiveBackupFailed': 'No se pudo crear la copia de seguridad de protección, por lo que la restauración se canceló para proteger tus datos actuales. Resuelve el problema subyacente (p. ej., acceso al keychain) e inténtalo de nuevo. Detalles: {message}',
|
||||
'cloudSync.localBackups.restoreConfirmTitle': '¿Restaurar esta copia de seguridad?',
|
||||
'cloudSync.localBackups.restoreConfirmDesc': 'Tus hosts, claves, snippets y configuraciones actuales se reemplazarán con el contenido de esta copia de seguridad. Se crea automáticamente una instantánea de protección de tus datos actuales primero.',
|
||||
'cloudSync.localBackups.restoreConfirmButton': 'Restaurar',
|
||||
'cloudSync.localBackups.restoreConfirmCancel': 'Cancelar',
|
||||
'cloudSync.localBackups.unavailableTitle': 'Copias de seguridad locales no disponibles',
|
||||
'cloudSync.localBackups.unavailableDesc': 'Esta plataforma no expone un keychain seguro a Netcatty, por lo que las copias de seguridad locales no se pueden escribir de forma segura. Instala Netcatty en un sistema con un keychain compatible para habilitar el historial de copias de seguridad locales.',
|
||||
'cloudSync.localBackups.lockedTitle': 'Se requiere clave maestra',
|
||||
'cloudSync.localBackups.lockedDesc': 'Configura o desbloquea tu clave maestra antes de restaurar una copia de seguridad, para que las credenciales restauradas permanezcan cifradas.',
|
||||
'cloudSync.revisionHistory.viewButton': 'Historial',
|
||||
'cloudSync.revisionHistory.title': 'Historial de versiones de la bóveda',
|
||||
'cloudSync.revisionHistory.description': 'Explora y restaura versiones anteriores de tu bóveda desde el historial de revisiones de Gist.',
|
||||
'cloudSync.revisionHistory.empty': 'No se encontraron revisiones.',
|
||||
'cloudSync.revisionHistory.current': 'Actual',
|
||||
'cloudSync.revisionHistory.revision': 'Revisión',
|
||||
'cloudSync.revisionHistory.revisionPreview': 'Contenido de la revisión',
|
||||
'cloudSync.revisionHistory.device': 'Dispositivo',
|
||||
'cloudSync.revisionHistory.hosts': 'Hosts',
|
||||
'cloudSync.revisionHistory.keys': 'Claves',
|
||||
'cloudSync.revisionHistory.snippets': 'Snippets',
|
||||
'cloudSync.revisionHistory.notes': 'Notas',
|
||||
'cloudSync.revisionHistory.identities': 'Identidades',
|
||||
'cloudSync.revisionHistory.restoreButton': 'Restaurar esta versión',
|
||||
'cloudSync.revisionHistory.restored': 'Bóveda restaurada desde la revisión seleccionada.',
|
||||
'cloudSync.revisionHistory.revisionNotFound': 'Revisión no encontrada o no contiene datos de la bóveda.',
|
||||
'cloudSync.revisionHistory.decryptFailed': 'No se puede descifrar esta revisión. Puede que se haya cifrado con una contraseña maestra diferente.',
|
||||
'cloudSync.changeKey.title': 'Cambiar clave maestra',
|
||||
'cloudSync.changeKey.current': 'Clave maestra actual',
|
||||
'cloudSync.changeKey.new': 'Nueva clave maestra',
|
||||
'cloudSync.changeKey.confirmNew': 'Confirmar nueva clave maestra',
|
||||
'cloudSync.changeKey.currentPlaceholder': 'Ingresa la clave maestra actual',
|
||||
'cloudSync.changeKey.newPlaceholder': 'Ingresa la nueva clave maestra',
|
||||
'cloudSync.changeKey.confirmPlaceholder': 'Confirma la nueva clave maestra',
|
||||
'cloudSync.changeKey.fillAll': 'Completa todos los campos',
|
||||
'cloudSync.changeKey.minLength': 'La nueva clave maestra debe tener al menos 8 caracteres',
|
||||
'cloudSync.changeKey.notMatch': 'Las nuevas claves maestras no coinciden',
|
||||
'cloudSync.changeKey.incorrectCurrent': 'Clave maestra actual incorrecta',
|
||||
'cloudSync.changeKey.failed': 'No se pudo cambiar la clave maestra',
|
||||
'cloudSync.changeKey.desc': 'Esto volverá a cifrar tu bóveda. Asegúrate de recordar la nueva clave.',
|
||||
'cloudSync.changeKey.showKeys': 'Mostrar claves',
|
||||
'cloudSync.changeKey.updatedToast': 'Clave maestra actualizada',
|
||||
'cloudSync.changeKey.updateButton': 'Actualizar clave',
|
||||
'cloudSync.unlock.title': 'Ingresa la clave maestra',
|
||||
'cloudSync.unlock.masterKey': 'Clave maestra',
|
||||
'cloudSync.unlock.desc':
|
||||
'Ingresa tu clave maestra una vez para habilitar la sincronización cifrada. Se almacenará de forma segura usando el keychain de tu sistema operativo.',
|
||||
'cloudSync.unlock.placeholder': 'Ingresa tu clave maestra',
|
||||
'cloudSync.unlock.empty': 'Ingresa tu clave maestra',
|
||||
'cloudSync.unlock.incorrect': 'Clave maestra incorrecta',
|
||||
'cloudSync.unlock.failed': 'No se pudo desbloquear la bóveda',
|
||||
'cloudSync.unlock.showKey': 'Mostrar clave',
|
||||
'cloudSync.unlock.notNow': 'Ahora no',
|
||||
'cloudSync.unlock.readyToast': 'Bóveda lista',
|
||||
'cloudSync.unlock.unlockButton': 'Desbloquear',
|
||||
'cloudSync.header.vaultReady': 'Bóveda lista',
|
||||
'cloudSync.header.preparingVault': 'Preparando bóveda...',
|
||||
'cloudSync.header.providersConnected': '{count} proveedor(es) conectado(s)',
|
||||
'cloudSync.githubFlow.title': 'Conectar con GitHub',
|
||||
'cloudSync.githubFlow.desc': 'Copia el código de abajo e ingrésalo en GitHub para autorizar a Netcatty.',
|
||||
'cloudSync.githubFlow.copyCode': 'Copiar código',
|
||||
'cloudSync.githubFlow.copied': '¡Copiado!',
|
||||
'cloudSync.githubFlow.openGitHub': 'Abrir GitHub',
|
||||
'cloudSync.githubFlow.waiting': 'Esperando autorización...',
|
||||
'cloudSync.conflict.title': 'Se detectó un conflicto de versiones',
|
||||
'cloudSync.conflict.desc': 'Elige qué versión conservar',
|
||||
'cloudSync.conflict.local': 'LOCAL',
|
||||
'cloudSync.conflict.cloud': 'NUBE',
|
||||
'cloudSync.conflict.detailsTitle': 'Datos cambiados',
|
||||
'cloudSync.conflict.detailsCounts': 'Local {local} · Nube {cloud} · Conflictos {conflicts}',
|
||||
'cloudSync.conflict.entity.hosts': 'Hosts',
|
||||
'cloudSync.conflict.entity.keys': 'Claves',
|
||||
'cloudSync.conflict.entity.identities': 'Identidades',
|
||||
'cloudSync.conflict.entity.proxyProfiles': 'Perfiles de proxy',
|
||||
'cloudSync.conflict.entity.snippets': 'Snippets',
|
||||
'cloudSync.conflict.entity.notes': 'Notas',
|
||||
'cloudSync.conflict.entity.noteGroups': 'Grupos de notas',
|
||||
'cloudSync.conflict.entity.customGroups': 'Grupos',
|
||||
'cloudSync.conflict.entity.snippetPackages': 'Paquetes de snippets',
|
||||
'cloudSync.conflict.entity.portForwardingRules': 'Reenvío de puertos',
|
||||
'cloudSync.conflict.entity.groupConfigs': 'Configuración de grupos',
|
||||
'cloudSync.conflict.entity.settings': 'Configuración',
|
||||
'cloudSync.conflict.keepLocal': 'Sobrescribir la nube (conservar local)',
|
||||
'cloudSync.conflict.useCloud': 'Descargar la nube (sobrescribir local)',
|
||||
'cloudSync.connect.browserContinue': 'Completar la autorización en el navegador',
|
||||
'cloudSync.connect.browserCancelled': 'La autorización anterior del navegador fue cancelada',
|
||||
'cloudSync.connect.github.success': 'GitHub se conectó correctamente',
|
||||
'cloudSync.connect.github.failedTitle': 'Falló la conexión a GitHub',
|
||||
'cloudSync.connect.github.timeout': 'Se agotó el tiempo de espera de la conexión a GitHub. Verifica tu red o la configuración del proxy.',
|
||||
'cloudSync.connect.github.networkError': 'No se puede acceder a GitHub. Verifica tu red o la configuración del proxy.',
|
||||
'cloudSync.connect.google.failedTitle': 'Falló la conexión a Google',
|
||||
'cloudSync.connect.onedrive.failedTitle': 'Falló la conexión a OneDrive',
|
||||
'cloudSync.sync.success': 'Sincronizado con {provider}',
|
||||
'cloudSync.sync.failed': 'Falló la sincronización',
|
||||
'cloudSync.sync.failedTitle': 'Falló la sincronización',
|
||||
'cloudSync.sync.errorTitle': 'Error de sincronización',
|
||||
'cloudSync.resolve.downloaded': 'Datos de la nube descargados',
|
||||
'cloudSync.resolve.uploaded': 'Datos locales subidos',
|
||||
'cloudSync.resolve.failedTitle': 'Falló la resolución del conflicto',
|
||||
'cloudSync.clearLocal.title': 'Borrar datos locales',
|
||||
'cloudSync.clearLocal.desc': 'Restablece la versión local y el historial de sincronización. La próxima sincronización descargará desde la nube.',
|
||||
'cloudSync.clearLocal.button': 'Borrar',
|
||||
'cloudSync.clearLocal.dialog.title': '¿Borrar los datos locales de la bóveda?',
|
||||
'cloudSync.clearLocal.dialog.desc': 'Esto restablecerá la versión local a 0 y borrará el historial de sincronización. Tu próxima sincronización descargará los datos de la nube, reemplazando los datos locales.',
|
||||
'cloudSync.clearLocal.dialog.cancel': 'Cancelar',
|
||||
'cloudSync.clearLocal.dialog.confirm': 'Borrar datos locales',
|
||||
'cloudSync.clearLocal.toast.title': 'Datos locales borrados',
|
||||
'cloudSync.clearLocal.toast.desc': 'Versión local restablecida a 0. Sincroniza para descargar desde la nube.',
|
||||
|
||||
// Keychain
|
||||
'keychain.filter.key': 'CLAVE',
|
||||
'keychain.filter.certificate': 'CERTIFICADO',
|
||||
'keychain.action.generateKey': 'Generar clave',
|
||||
'keychain.action.importKey': 'Importar clave',
|
||||
'keychain.action.newIdentity': 'Nueva identidad',
|
||||
'keychain.action.importCertificate': 'Importar certificado',
|
||||
'keychain.view.grid': 'Cuadrícula',
|
||||
'keychain.view.list': 'Lista',
|
||||
'keychain.section.keys': 'Claves',
|
||||
'keychain.section.identities': 'Identidades',
|
||||
'keychain.count.items': '{count} elementos',
|
||||
'keychain.empty.title': 'Configura tus claves',
|
||||
'keychain.empty.desc': 'Importa o genera claves SSH para una autenticación segura.',
|
||||
'keychain.panel.generateKey': 'Generar clave',
|
||||
'keychain.panel.newKey': 'Nueva clave',
|
||||
'keychain.panel.keyDetails': 'Detalles de la clave',
|
||||
'keychain.panel.editKey': 'Editar clave',
|
||||
'keychain.panel.editIdentity': 'Editar identidad',
|
||||
'keychain.panel.newIdentity': 'Nueva identidad',
|
||||
'keychain.panel.keyExport': 'Exportación de clave',
|
||||
'keychain.validation.labelRequired': 'Ingresa una etiqueta para la clave',
|
||||
'keychain.validation.labelAndPrivateKeyRequired': 'La etiqueta y la clave privada son obligatorias',
|
||||
'keychain.validation.labelAndUsernameRequired': 'La etiqueta y el nombre de usuario son obligatorios',
|
||||
'keychain.error.generationUnavailable':
|
||||
'La generación de claves no está disponible: asegúrate de que la app se esté ejecutando en Electron',
|
||||
'keychain.error.generateKeyPairFailed': 'No se pudo generar el par de claves',
|
||||
'keychain.error.generateKeyFailed': 'No se pudo generar la clave',
|
||||
'keychain.error.keyGenerationTitle': 'Generación de claves',
|
||||
'keychain.export.exportTo': 'Exportar a *',
|
||||
'keychain.export.selectHost': 'Seleccionar host',
|
||||
'keychain.export.location': 'Ubicación ~ $1 *',
|
||||
'keychain.export.filename': 'Nombre de archivo ~ $2 *',
|
||||
'keychain.export.note':
|
||||
'La exportación de claves actualmente solo admite sistemas {unix}. Usa la sección {advanced} para personalizar el script de exportación.',
|
||||
'keychain.export.script': 'Script *',
|
||||
'keychain.export.scriptPlaceholder': 'Script de exportación...',
|
||||
'keychain.export.missingCredentials':
|
||||
'El host no tiene contraseña ni clave guardadas. Primero agrega credenciales de contraseña al host.',
|
||||
'keychain.export.successTitle': 'Exportación exitosa',
|
||||
'keychain.export.successMessage': 'Clave pública exportada y adjuntada a {host}',
|
||||
'keychain.export.failedTitle': 'Falló la exportación',
|
||||
'keychain.export.failedMessage': 'No se pudo exportar la clave: {error}',
|
||||
'keychain.export.failedPrefix': 'Falló la exportación: {error}',
|
||||
'keychain.export.exitCode': 'El comando terminó con el código {code}',
|
||||
'keychain.export.exporting': 'Exportando...',
|
||||
'keychain.export.exportAndAttach': 'Exportar y adjuntar',
|
||||
'keychain.export.title': 'Exportación de clave',
|
||||
'keychain.export.exportToRequired': 'Exportar a *',
|
||||
'keychain.export.selectHostPlaceholder': 'Selecciona un host...',
|
||||
'keychain.export.locationLabel': 'Ubicación ~ $1 *',
|
||||
'keychain.export.filenameLabel': 'Nombre de archivo ~ $2 *',
|
||||
'keychain.export.advanced': 'Avanzado',
|
||||
'keychain.export.note.supportsOnly': 'La exportación de claves actualmente solo admite',
|
||||
'keychain.export.note.systems': 'sistemas.',
|
||||
'keychain.export.note.use': 'Usa la',
|
||||
'keychain.export.note.customize': 'sección para personalizar el script de exportación.',
|
||||
'keychain.export.scriptRequired': 'Script *',
|
||||
'keychain.export.exportToHost': 'Exportar al host',
|
||||
'keychain.export.failedGeneric': 'Falló la exportación: {message}',
|
||||
'keychain.field.label': 'Etiqueta',
|
||||
'keychain.field.labelRequired': 'Etiqueta *',
|
||||
'keychain.field.labelPlaceholder': 'Etiqueta de la clave',
|
||||
'keychain.field.privateKeyRequired': 'Clave privada *',
|
||||
'keychain.field.publicKey': 'Clave pública',
|
||||
'keychain.field.certificatePlaceholder': 'Contenido del certificado (opcional)',
|
||||
'keychain.generate.keyType': 'Tipo de clave',
|
||||
'keychain.generate.keySize': 'Tamaño de clave',
|
||||
'keychain.generate.labelPlaceholder': 'Etiqueta de la clave',
|
||||
'keychain.generate.passphrasePlaceholder': 'Frase de contraseña (opcional)',
|
||||
'keychain.generate.savePassphrase': 'Guardar frase de contraseña',
|
||||
'keychain.generate.generate': 'Generar',
|
||||
'keychain.generate.generateSave': 'Generar y guardar',
|
||||
'keychain.import.dropHint': 'Suelta un archivo de clave aquí',
|
||||
'keychain.import.importFromFile': 'Importar desde archivo',
|
||||
'keychain.import.saveKey': 'Guardar clave',
|
||||
'keychain.import.importedKeyLabel': 'Clave importada',
|
||||
'keychain.identity.usernameRequired': 'Nombre de usuario *',
|
||||
'keychain.identity.method.passwordOnly': 'Contraseña',
|
||||
'keychain.identity.summary.password': 'Contraseña de autenticación',
|
||||
'keychain.identity.summary.key': 'Clave de autenticación',
|
||||
'keychain.identity.summary.certificate': 'Certificado de autenticación',
|
||||
'keychain.identity.summary.passwordAndKey': 'Contraseña y clave de autenticación',
|
||||
'keychain.identity.summary.passwordAndCertificate': 'Contraseña y certificado de autenticación',
|
||||
'keychain.identity.summary.none': 'Sin credenciales',
|
||||
'keychain.identity.selectCredential': 'Seleccionar {kind}',
|
||||
'keychain.identity.save': 'Guardar',
|
||||
'keychain.identity.update': 'Actualizar',
|
||||
'keychain.keyDialog.newTitle': 'Nueva clave',
|
||||
'keychain.keyDialog.newDesc': 'Agregar una nueva clave SSH',
|
||||
'keychain.keyDialog.editTitle': 'Editar clave',
|
||||
'keychain.keyDialog.editDesc': 'Actualizar esta clave SSH',
|
||||
'keychain.keyDialog.updateKey': 'Actualizar clave',
|
||||
|
||||
// Tabs
|
||||
'tabs.closeSessionAria': 'Cerrar sesión',
|
||||
'tabs.closeLogViewAria': 'Cerrar vista de registro',
|
||||
'tabs.closePluginViewAria': 'Cerrar {title}',
|
||||
'tabs.logPrefix': 'Registro:',
|
||||
'tabs.logLocal': 'Local',
|
||||
'tabs.copyTab': 'Copiar pestaña',
|
||||
'tabs.copyTabToNewWindow': 'Copiar pestaña a una nueva ventana',
|
||||
'tabs.copyTabToNewWindowFailed': 'No se pudo abrir la pestaña en una nueva ventana',
|
||||
'tabs.closeOthers': 'Cerrar las demás',
|
||||
'tabs.closeToRight': 'Cerrar pestañas a la derecha',
|
||||
'tabs.closeAll': 'Cerrar todas',
|
||||
'keychain.edit.labelRequired': 'Etiqueta *',
|
||||
'keychain.edit.keyLabelPlaceholder': 'Etiqueta de la clave',
|
||||
'keychain.edit.privateKeyRequired': 'Clave privada *',
|
||||
'keychain.edit.publicKey': 'Clave pública',
|
||||
'keychain.edit.certificate': 'Certificado',
|
||||
'keychain.edit.certificatePlaceholder': 'Contenido del certificado (opcional)',
|
||||
'keychain.edit.filePath': 'Ruta del archivo',
|
||||
'keychain.edit.keyExport': 'Exportación de clave',
|
||||
'keychain.edit.exportToHost': 'Exportar al host',
|
||||
|
||||
// Snippets
|
||||
'snippets.searchPlaceholder': 'Buscar scripts...',
|
||||
'snippets.action.newSnippet': 'Nuevo snippet',
|
||||
'snippets.action.newPackage': 'Nuevo paquete de scripts',
|
||||
'snippets.action.import': 'Importar',
|
||||
'snippets.action.selectSnippets': 'Seleccionar snippets',
|
||||
'snippets.panel.newTitle': 'Nuevo snippet',
|
||||
'snippets.panel.editTitle': 'Editar snippet',
|
||||
'snippets.panel.newAutomationTitle': 'Nuevo script de automatización',
|
||||
'snippets.panel.editAutomationTitle': 'Editar script de automatización',
|
||||
'snippets.panel.resizeWidth': 'Cambiar el ancho del panel',
|
||||
'snippets.field.description': 'Descripción de la acción',
|
||||
'snippets.field.descriptionPlaceholder': 'Ejemplo: verificar la carga de la red',
|
||||
'snippets.field.package': 'Agregar un paquete de scripts',
|
||||
'snippets.field.packagePlaceholder': 'Selecciona o crea un paquete de scripts',
|
||||
'snippets.field.createPackage': 'Crear paquete de scripts',
|
||||
'snippets.field.scriptRequired': 'Script *',
|
||||
'snippets.scriptEditor.expand': 'Abrir en diálogo',
|
||||
'snippets.scriptEditor.resize': 'Cambiar la altura del editor',
|
||||
'snippets.scriptEditor.modalTitle': 'Editar script',
|
||||
'snippets.targets.title': 'Destinos',
|
||||
'snippets.targets.add': 'Agregar destinos',
|
||||
'snippets.targets.selectHosts': 'Hosts',
|
||||
'snippets.targets.selectGroups': 'Grupos',
|
||||
'snippets.targets.noGroups': 'No se encontraron grupos',
|
||||
'snippets.targets.allHosts': 'Aplicar a todos los hosts',
|
||||
'snippets.targets.allHostsShort': 'Todos los hosts',
|
||||
'snippets.targets.allHostsActive': 'Se aplica a cada host conectable.',
|
||||
'snippets.history.title': 'Historial de Shell',
|
||||
'snippets.history.subtitle': '{count} comandos',
|
||||
'snippets.history.emptyTitle': 'Aún no hay historial de Shell',
|
||||
'snippets.history.emptyDesc': 'Los comandos que ejecutes aparecerán aquí',
|
||||
'snippets.history.loadMore': 'Cargar más',
|
||||
'snippets.history.separator': '•',
|
||||
'snippets.history.labelPlaceholder': 'Establece una etiqueta para este snippet',
|
||||
'snippets.history.saveAsSnippet': 'Guardar como snippet',
|
||||
'snippets.history.time.justNow': 'justo ahora',
|
||||
'snippets.history.time.minutesAgo': 'hace {count}m',
|
||||
'snippets.history.time.hoursAgo': 'hace {count}h',
|
||||
'snippets.history.time.daysAgo': 'hace {count}d',
|
||||
'snippets.breadcrumb.allPackages': 'Todos los paquetes de scripts',
|
||||
'snippets.breadcrumb.separator': '›',
|
||||
'snippets.empty.title': 'Crea scripts',
|
||||
'snippets.empty.desc': 'Guarda comandos comunes como snippets de código, o escribe scripts de automatización para operaciones repetibles.',
|
||||
'snippets.search.noResults.title': 'Sin coincidencias',
|
||||
'snippets.search.noResults.desc': 'Ningún script o paquete de scripts coincide con "{query}". Prueba con otro término de búsqueda o limpia la búsqueda para explorar.',
|
||||
'snippets.section.packages': 'Paquetes de scripts',
|
||||
'snippets.section.snippets': 'Scripts',
|
||||
'snippets.kind.codeSnippet': 'Snippet de código',
|
||||
'snippets.kind.automationScript': 'Script de automatización',
|
||||
'snippets.package.count': '{count} script(s)',
|
||||
'snippets.commandFallback': 'Comando',
|
||||
'snippets.view.grid': 'Cuadrícula',
|
||||
'snippets.view.list': 'Lista',
|
||||
'snippets.selection.selected': '{count} seleccionados',
|
||||
'snippets.selection.selectVisible': 'Seleccionar visibles',
|
||||
'snippets.selection.deselectAll': 'Deseleccionar todo',
|
||||
'snippets.selection.exportSelected': 'Exportar seleccionados ({count})',
|
||||
'snippets.selection.deleteSelected': 'Eliminar ({count})',
|
||||
'snippets.selection.deleteConfirmTitle': '¿Eliminar los elementos seleccionados ({count})?',
|
||||
'snippets.selection.deleteConfirmDesc': 'Los elementos seleccionados se eliminarán de forma permanente. Esta acción no se puede deshacer.',
|
||||
'snippets.selection.deleteSuccess': 'Elementos seleccionados eliminados: {count}.',
|
||||
'snippets.export.snippet': 'Exportar snippet',
|
||||
'snippets.export.package': 'Exportar paquete de scripts',
|
||||
'snippets.export.toast.empty': 'No hay snippets para exportar.',
|
||||
'snippets.export.toast.successTitle': 'Exportación lista',
|
||||
'snippets.export.toast.success': 'Se exportaron {count} snippet(s).',
|
||||
'snippets.import.toast.empty': 'No se encontraron snippets importables.',
|
||||
'snippets.import.toast.failedTitle': 'Falló la importación',
|
||||
'snippets.import.toast.invalidDesc': 'Este no es un archivo de snippets válido de Netcatty.',
|
||||
'snippets.import.toast.successTitle': 'Importación completada',
|
||||
'snippets.import.toast.summary': 'Importados {imported}, sobrescritos {overwritten}, omitidos {skipped}.',
|
||||
'snippets.import.modal.title': 'Importar snippets',
|
||||
'snippets.import.modal.desc': 'Elige uno o más archivos JSON de snippets de Netcatty. Netcatty los mostrará en vista previa antes de importar cualquier cosa.',
|
||||
'snippets.import.modal.exampleTitle': 'Ejemplo de JSON',
|
||||
'snippets.import.modal.noFile': 'Aún no se seleccionó ningún archivo. Usa el formato de ejemplo de abajo, o un arreglo JSON simple de snippets, y luego elige uno o más archivos.',
|
||||
'snippets.import.modal.chooseFile': 'Elegir archivo',
|
||||
'snippets.import.modal.downloadExamples': 'Descargar ejemplos',
|
||||
'snippets.import.modal.multipleFiles': '{count} archivos seleccionados',
|
||||
'snippets.import.modal.parsedSummary': '{files} archivo(s), {total} script(s), {packages} paquete(s) de scripts, {conflicts} comando(s) duplicado(s). Las vinculaciones de host se ignoran.',
|
||||
'snippets.import.modal.confirm': 'Confirmar importación',
|
||||
'snippets.import.conflict.title': '¿Importar snippets?',
|
||||
'snippets.import.conflict.desc': '{file} contiene {total} snippet(s). Ya existen {conflicts} comando(s) duplicado(s).',
|
||||
'snippets.import.conflict.hostBindingsNote': 'Las vinculaciones de host no se importan ni se exportan con los snippets.',
|
||||
'snippets.import.conflict.skip': 'Omitir duplicados',
|
||||
'snippets.import.conflict.overwrite': 'Sobrescribir duplicados',
|
||||
'snippets.packageDialog.title': 'Nuevo paquete de scripts',
|
||||
'snippets.packageDialog.parent': 'Padre: {parent}',
|
||||
'snippets.packageDialog.root': 'Raíz',
|
||||
'snippets.packageDialog.placeholder': 'p. ej., ops/maintenance',
|
||||
'snippets.packageDialog.hint': 'Usa "/" para crear paquetes de scripts anidados.',
|
||||
|
||||
// Snippets Rename Dialog
|
||||
'snippets.renameDialog.title': 'Renombrar paquete de scripts',
|
||||
'snippets.renameDialog.currentPath': 'Ruta actual: {path}',
|
||||
'snippets.renameDialog.placeholder': 'Ingresa el nuevo nombre',
|
||||
'snippets.renameDialog.error.empty': 'El nombre del paquete de scripts no puede estar vacío',
|
||||
'snippets.renameDialog.error.duplicate': 'Ya existe un paquete de scripts con este nombre',
|
||||
'snippets.renameDialog.error.invalidChars': 'El nombre del paquete de scripts solo puede contener letras, números, guiones y guiones bajos',
|
||||
|
||||
'snippets.field.noAutoRun': 'Solo pegar (no auto-ejecutar)',
|
||||
'snippets.field.multiLineRunMode': 'Ejecución multilínea',
|
||||
'snippets.field.multiLineRunMode.paste': 'Enviar todo de una vez',
|
||||
'snippets.field.multiLineRunMode.lineDelay': 'Enviar línea por línea',
|
||||
'snippets.field.multiLineRunModeHint': 'Usa línea por línea para inicios de sesión que piden credenciales (prompt) o macros de dispositivos.',
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': 'Atajo de teclado',
|
||||
'snippets.shortkey.placeholder': 'Haz clic para configurar el atajo',
|
||||
'snippets.shortkey.recording': 'Presiona una combinación de teclas...',
|
||||
'snippets.shortkey.hint': 'Presiona este atajo en la terminal para enviar el comando rápidamente.',
|
||||
'snippets.shortkey.clear': 'Borrar atajo',
|
||||
'snippets.shortkey.error.systemConflict': 'Este atajo entra en conflicto con {name}',
|
||||
'snippets.shortkey.error.snippetConflict': 'Este atajo ya lo usa el snippet: {name}',
|
||||
|
||||
'snippets.variables.dialogTitle': 'Variables del snippet',
|
||||
'snippets.variables.dialogDesc': 'Completa los valores de "{label}" antes de ejecutar.',
|
||||
'snippets.variables.hint': 'Los valores se insertan tal cual en el script (sin escapado de shell).',
|
||||
'snippets.variables.preview': 'Vista previa',
|
||||
'snippets.variables.placeholder': 'Ingresa un valor',
|
||||
'snippets.variables.placeholderDefault': 'Predeterminado: {value}',
|
||||
'snippets.variables.required': 'Esta variable es obligatoria',
|
||||
'snippets.variables.run': 'Ejecutar',
|
||||
'snippets.field.variablesHelp': 'Usa {{name}} o {{name:default}} para los marcadores de posición en el script.',
|
||||
'snippets.field.variablesDetected': 'Variables',
|
||||
'snippets.field.variableDefault': 'predeterminado {value}',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': 'Serial',
|
||||
'serial.modal.title': 'Conectar a puerto serial',
|
||||
'serial.modal.desc': 'Configura los ajustes de conexión del puerto serial',
|
||||
'serial.field.port': 'Puerto serial',
|
||||
'serial.field.selectPort': 'Selecciona un puerto...',
|
||||
'serial.field.baudRate': 'Velocidad de baudios',
|
||||
'serial.field.dataBits': 'Bits de datos',
|
||||
'serial.field.stopBits': 'Bits de parada',
|
||||
'serial.field.stopBits15Warning': 'Los 1.5 bits de parada pueden no ser compatibles con todos los dispositivos Windows',
|
||||
'serial.field.parity': 'Paridad',
|
||||
'serial.field.flowControl': 'Control de flujo',
|
||||
'serial.noPorts': 'No se detectaron puertos seriales. Conecta un dispositivo y actualiza.',
|
||||
'serial.field.customPort': 'Ruta de puerto personalizada',
|
||||
'serial.field.customPortPlaceholder': 'p. ej., /dev/ttys001 o COM1',
|
||||
'serial.type.hardware': 'Hardware',
|
||||
'serial.type.pseudo': 'Pseudoterminal',
|
||||
'serial.type.custom': 'Personalizado',
|
||||
'serial.parity.none': 'Ninguna',
|
||||
'serial.parity.even': 'Par',
|
||||
'serial.parity.odd': 'Impar',
|
||||
'serial.parity.mark': 'Marca',
|
||||
'serial.parity.space': 'Espacio',
|
||||
'serial.flowControl.none': 'Ninguno',
|
||||
'serial.flowControl.xon/xoff': 'XON/XOFF (Software)',
|
||||
'serial.flowControl.rts/cts': 'RTS/CTS (Hardware)',
|
||||
'serial.field.localEcho': 'Forzar eco local',
|
||||
'serial.field.localEchoDesc': 'Hacer eco de los caracteres escritos localmente (para dispositivos sin eco remoto)',
|
||||
'serial.field.lineMode': 'Modo de línea',
|
||||
'serial.field.lineModeDesc': 'Almacenar en búfer la entrada y enviarla al presionar Enter (en lugar de carácter por carácter)',
|
||||
'serial.field.backspaceBehavior': 'Tecla de retroceso',
|
||||
'serial.field.backspaceBehaviorDesc': 'Usa Ctrl+H para dispositivos de red que no responden al código de Retroceso predeterminado.',
|
||||
'serial.backspace.default': 'Predeterminado (DEL, 0x7F)',
|
||||
'serial.backspace.ctrlH': 'Ctrl+H (BS, 0x08)',
|
||||
'serial.field.charset': 'Codificación de caracteres',
|
||||
'serial.connectionError': 'No se pudo conectar al puerto serial',
|
||||
'serial.field.baudRatePlaceholder': 'Selecciona o ingresa la velocidad de baudios...',
|
||||
'serial.field.baudRateEmpty': 'Ingresa una velocidad de baudios personalizada',
|
||||
'serial.field.customBaudRate': 'Usando velocidad de baudios personalizada',
|
||||
'serial.field.saveConfig': 'Guardar configuración',
|
||||
'serial.field.saveConfigDesc': 'Guarda esta configuración serial en hosts para acceso rápido',
|
||||
'serial.field.configLabel': 'Nombre de configuración',
|
||||
'serial.field.configLabelPlaceholder': 'p. ej., Arduino Uno',
|
||||
'serial.connectAndSave': 'Conectar y guardar',
|
||||
'serial.edit.title': 'Configuración del puerto serial',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': 'Se requiere autenticación',
|
||||
'keyboard.interactive.desc': 'El servidor requiere autenticación adicional.',
|
||||
'keyboard.interactive.descWithHost': 'El servidor {hostname} requiere autenticación adicional.',
|
||||
'keyboard.interactive.response': 'Respuesta',
|
||||
'keyboard.interactive.enterCode': 'Ingresa el código de verificación',
|
||||
'keyboard.interactive.enterResponse': 'Ingresa la respuesta',
|
||||
'keyboard.interactive.submit': 'Enviar',
|
||||
'keyboard.interactive.verifying': 'Verificando...',
|
||||
'keyboard.interactive.savePassword': 'Guardar contraseña',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'Frase de contraseña de la clave SSH',
|
||||
'passphrase.desc': 'Ingresa la frase de contraseña de {keyName}',
|
||||
'passphrase.descWithHost': 'Ingresa la frase de contraseña de {keyName} para conectarte a {hostname}',
|
||||
'passphrase.label': 'Frase de contraseña',
|
||||
'passphrase.keyPath': 'Clave',
|
||||
'passphrase.unlock': 'Desbloquear',
|
||||
'passphrase.unlocking': 'Desbloqueando...',
|
||||
'passphrase.skip': 'Omitir',
|
||||
'passphrase.remember': 'Recordar esta frase de contraseña',
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': 'Ajuste de línea',
|
||||
'sftp.editor.maximize': 'Maximizar',
|
||||
'sftp.editor.unsavedTitle': 'Cambios sin guardar',
|
||||
'sftp.editor.unsavedMessage': '{fileName} tiene cambios sin guardar. ¿Guardar antes de cerrar?',
|
||||
'sftp.editor.discardChanges': 'Descartar',
|
||||
'sftp.editor.saveAndClose': 'Guardar y cerrar',
|
||||
'sftp.editor.quitBlockedByDirty': 'Editores sin guardar: guarda o descarta antes de salir',
|
||||
|
||||
};
|
||||
@@ -0,0 +1,987 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esVaultMessages: Messages = {
|
||||
// Notes
|
||||
'notes.search.placeholder': 'Buscar notas, hosts, etiquetas...',
|
||||
'notes.action.newNote': 'Nueva nota',
|
||||
'notes.action.newGroup': 'Nueva carpeta',
|
||||
'notes.action.importMarkdown': 'Importar Markdown',
|
||||
'notes.action.exportNote': 'Exportar nota',
|
||||
'notes.action.exportGroup': 'Exportar carpeta',
|
||||
'notes.action.exportAll': 'Exportar notas',
|
||||
'notes.import.toast.success': 'Se importaron {count} nota(s)',
|
||||
'notes.import.toast.noNotes': 'No se importaron archivos Markdown.',
|
||||
'notes.import.toast.failed': 'No se pudieron importar los archivos Markdown.',
|
||||
'notes.import.toast.inProgress': 'Ya hay una importación de Markdown en curso.',
|
||||
'notes.import.toast.skipped': 'Se omitieron {count} archivo(s) no compatible(s).',
|
||||
'notes.export.toast.success': 'Se exportaron {count} nota(s)',
|
||||
'notes.export.toast.empty': 'No hay notas para exportar.',
|
||||
'notes.export.toast.failed': 'No se pudieron exportar las notas.',
|
||||
'notes.codeBlock.copied': 'Copiado',
|
||||
'notes.codeBlock.copyFailed': 'No se pudo copiar el bloque de código.',
|
||||
'notes.group.prompt': 'Ruta de la carpeta',
|
||||
'notes.group.all': 'Cuaderno',
|
||||
'notes.group.ungrouped': 'Sin grupo',
|
||||
'notes.section.notebooks': 'Cuaderno',
|
||||
'notes.section.notes': 'Notas',
|
||||
'notes.mode.edit': 'Editar',
|
||||
'notes.mode.split': 'Dividir',
|
||||
'notes.mode.preview': 'Vista previa',
|
||||
'notes.title.placeholder': 'Título de la nota',
|
||||
'notes.editor.placeholder': 'Escribe notas Markdown aquí...',
|
||||
'notes.preview.empty': 'Aún no hay nada para previsualizar.',
|
||||
'notes.empty.title': 'Aún no hay notas',
|
||||
'notes.empty.desc': 'Crea notas Markdown para runbooks, recordatorios y detalles de traspaso de servidores.',
|
||||
'notes.empty.group': 'No hay notas en este cuaderno.',
|
||||
'notes.search.noResults': 'No se encontraron notas.',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': 'Agrega datos a tu Vault',
|
||||
'vault.import.desc':
|
||||
'Transfiere tus conexiones desde clientes populares. Selecciona un formato de archivo para comenzar la migración.',
|
||||
'vault.import.chooseFormat': 'Selecciona un formato de archivo',
|
||||
'vault.import.destination.title': '¿Dónde deberían ir los hosts importados?',
|
||||
'vault.import.destination.preserve': 'Conservar los grupos originales',
|
||||
'vault.import.destination.existing': 'Elegir un grupo',
|
||||
'vault.import.destination.new': 'Crear un grupo',
|
||||
'vault.import.destination.noGroups': 'No hay grupos disponibles',
|
||||
'vault.import.destination.newPlaceholder': 'Nombre del grupo nuevo',
|
||||
'vault.import.destination.settings': 'Ubicación de importación',
|
||||
'vault.import.destination.settingsHint': 'Elige dónde se colocarán los hosts nuevos',
|
||||
'vault.import.destination.done': 'Listo',
|
||||
'vault.import.securecrt.directoryHint': 'Selecciona la carpeta de sesiones de SecureCRT.',
|
||||
'vault.import.securecrt.chooseSource': 'Elige una fuente de importación de SecureCRT.',
|
||||
'vault.import.securecrt.sourceQuestion': '¿Qué te gustaría importar?',
|
||||
'vault.import.securecrt.promptTitle': 'Importar desde SecureCRT',
|
||||
'vault.import.securecrt.promptDesc':
|
||||
'Elige la carpeta de sesiones de SecureCRT para importar todas las sesiones conservando la estructura de carpetas, o selecciona un solo archivo de sesión .ini.',
|
||||
'vault.import.securecrt.folder': 'Carpeta de sesiones',
|
||||
'vault.import.securecrt.folderDesc': 'Importa todas las sesiones y conserva la estructura de carpetas.',
|
||||
'vault.import.securecrt.file': 'Archivo de sesión único',
|
||||
'vault.import.securecrt.fileDesc': 'Importa una sesión .ini de SecureCRT.',
|
||||
'vault.import.csv.tip': 'Importación masiva: usa la plantilla CSV.',
|
||||
'vault.import.csv.downloadTemplate': 'Descargar plantilla CSV',
|
||||
'vault.import.toast.start': 'Importando desde {format}...',
|
||||
'vault.import.toast.completedTitle': 'Importación completada',
|
||||
'vault.import.toast.failedTitle': 'Importación fallida',
|
||||
'vault.import.toast.noEntries': 'No se encontraron entradas importables en {format}.',
|
||||
'vault.import.toast.noNewHosts': 'No se importaron hosts nuevos desde {format}.',
|
||||
'vault.import.toast.summary':
|
||||
'Se importaron {count} hosts (omitidos {skipped}, duplicados {duplicates}).',
|
||||
'vault.import.toast.firstIssue': 'Primer problema: {issue}',
|
||||
'vault.import.progress.title': 'Importando hosts',
|
||||
'vault.import.progress.reading': 'Leyendo archivo',
|
||||
'vault.import.progress.parsing': 'Analizando hosts',
|
||||
'vault.import.progress.preparing': 'Preparando cambios',
|
||||
'vault.import.progress.saving': 'Guardando hosts',
|
||||
'vault.import.progress.complete': 'Importación completa',
|
||||
'vault.import.progress.failed': 'Importación fallida',
|
||||
'vault.import.progress.persistFailed': 'No se pudieron guardar los hosts importados. Libera algo de espacio de almacenamiento local e inténtalo de nuevo.',
|
||||
'vault.import.progress.rollbackFailed': 'La importación no se pudo revertir de forma segura. Es posible que queden algunos hosts importados; revisa el Vault antes de reintentar.',
|
||||
'vault.import.progress.summary':
|
||||
'Se importaron {count} hosts; omitidos {skipped}; duplicados {duplicates}.',
|
||||
'vault.import.progress.keepOpen': 'Mantén Netcatty abierto mientras la importación se ejecuta en segundo plano.',
|
||||
'vault.import.progress.fileSummary': '{name} · {count} archivos',
|
||||
'vault.import.progress.fileCount': '{completed} de {total} archivos',
|
||||
'vault.import.mobaxterm.chooseEncoding': 'Elige cómo leer tu archivo de MobaXterm.',
|
||||
'vault.import.mobaxterm.encodingQuestion': '¿Qué codificación de texto usa este archivo?',
|
||||
'vault.import.mobaxterm.auto': 'Detección automática',
|
||||
'vault.import.mobaxterm.autoDesc': 'Recomendado. Usa UTF-8 cuando sea válido; de lo contrario, la codificación de Windows en chino.',
|
||||
'vault.import.mobaxterm.utf8': 'UTF-8',
|
||||
'vault.import.mobaxterm.utf8Desc': 'Úsalo para archivos guardados o convertidos como UTF-8.',
|
||||
'vault.import.mobaxterm.gb18030': 'Windows en chino',
|
||||
'vault.import.mobaxterm.gb18030Desc': 'Úsalo para archivos antiguos exportados en Windows en chino.',
|
||||
'vault.import.sshConfig.chooseMode': 'Elige cómo importar tu archivo de configuración SSH.',
|
||||
'vault.import.sshConfig.modeQuestion': '¿Cómo te gustaría importar?',
|
||||
'vault.import.sshConfig.importOnly': 'Solo importar',
|
||||
'vault.import.sshConfig.importOnlyDesc': 'Importación única. Los cambios no se sincronizarán de vuelta al archivo.',
|
||||
'vault.import.sshConfig.managed': 'Sincronización administrada',
|
||||
'vault.import.sshConfig.managedDesc': 'Mantén la sincronización. Los cambios se guardarán de vuelta en el archivo.',
|
||||
'vault.import.sshConfig.managedDestinationHint': 'La sincronización administrada siempre usa su propio grupo administrado; el destino anterior aplica solo a la importación única.',
|
||||
'vault.import.sshConfig.managedGroup': 'ssh config',
|
||||
'vault.import.sshConfig.managedSuccess': 'Se importaron {count} hosts. El archivo ahora está administrado.',
|
||||
'vault.import.sshConfig.alreadyManaged': 'Este archivo ya está siendo administrado.',
|
||||
'vault.import.sshConfig.alreadyManagedDesc': 'Este archivo ya está administrado en el grupo "{group}". Elimina primero la fuente administrada existente si quieres volver a importar.',
|
||||
'vault.import.sshConfig.noFilePath': 'No se puede administrar este archivo.',
|
||||
'vault.import.sshConfig.noFilePathDesc': 'No se pudo determinar la ruta del archivo. La sincronización administrada requiere acceso al sistema de archivos.',
|
||||
|
||||
// Known Hosts
|
||||
'knownHosts.search.placeholder': 'Buscar hosts conocidos...',
|
||||
'knownHosts.action.scanSystem': 'Escanear sistema',
|
||||
'knownHosts.action.importFile': 'Importar archivo',
|
||||
'knownHosts.action.browseFile': 'Explorar archivo',
|
||||
'knownHosts.empty.title': 'No hay hosts conocidos',
|
||||
'knownHosts.empty.desc':
|
||||
"Los hosts conocidos son servidores SSH a los que te conectaste antes. Importa desde el archivo known_hosts de tu sistema para comenzar.",
|
||||
'knownHosts.results.showingLimited':
|
||||
'Mostrando {shown} de {total} hosts. Usa la búsqueda para encontrar hosts específicos.',
|
||||
'knownHosts.toast.scanUnavailable': 'El escaneo del sistema no está disponible en esta plataforma.',
|
||||
'knownHosts.toast.scanNoFile': 'No se encontró un archivo known_hosts del sistema.',
|
||||
'knownHosts.toast.scanNoEntries': 'No se encontraron entradas utilizables en known_hosts.',
|
||||
'knownHosts.toast.scanImported': 'Se importaron {count} hosts nuevos.',
|
||||
'knownHosts.toast.scanNoNew': 'No se encontraron hosts nuevos.',
|
||||
'knownHosts.toast.scanFailed': 'No se pudo escanear el known_hosts del sistema.',
|
||||
|
||||
// Port Forwarding
|
||||
'pf.empty.title': 'Configura el reenvío de puertos',
|
||||
'pf.empty.desc': 'Guarda el reenvío de puertos para acceder a bases de datos, aplicaciones web y otros servicios.',
|
||||
'pf.title': 'Reenvío de puertos',
|
||||
'pf.rulesCount': '{count} reglas',
|
||||
'pf.wizard.editTitle': 'Editar reenvío de puertos',
|
||||
'pf.wizard.newTitle': 'Nuevo reenvío de puertos',
|
||||
'pf.wizard.saveChanges': 'Guardar cambios',
|
||||
'pf.wizard.done': 'Listo',
|
||||
'pf.wizard.continue': 'Continuar',
|
||||
'pf.wizard.cancel': 'Cancelar',
|
||||
'pf.wizard.skipWizard': 'Omitir asistente',
|
||||
'pf.error.hostNotFound': 'No se encontró el host',
|
||||
'pf.toast.titleWithLabel': 'Reenvío de puertos: {label}',
|
||||
'pf.type.local': 'Local',
|
||||
'pf.type.remote': 'Remoto',
|
||||
'pf.type.dynamic': 'Dinámico',
|
||||
'pf.type.menu.local': 'Reenvío local',
|
||||
'pf.type.menu.remote': 'Reenvío remoto',
|
||||
'pf.type.menu.dynamic': 'Reenvío dinámico',
|
||||
'pf.type.local.desc': 'El reenvío local te permite acceder al puerto de escucha de un servidor remoto como si fuera local.',
|
||||
'pf.type.remote.desc': 'El reenvío remoto abre un puerto en la máquina remota y reenvía las conexiones al host local (actual).',
|
||||
'pf.type.dynamic.desc': 'El reenvío dinámico de puertos convierte a Netcatty en un servidor proxy SOCKS.',
|
||||
'pf.wizard.type.title': 'Selecciona el tipo de reenvío de puertos:',
|
||||
'pf.wizard.localConfig.title': 'Configura el puerto local y la dirección de enlace:',
|
||||
'pf.wizard.localConfig.desc': 'Este puerto estará abierto en el dispositivo local (actual) y recibirá el tráfico.',
|
||||
'pf.wizard.localConfig.localPort': 'Número de puerto local *',
|
||||
'pf.wizard.bindAddress': 'Dirección de enlace',
|
||||
'pf.wizard.remoteHost.title': 'Selecciona el host remoto:',
|
||||
'pf.wizard.remoteHost.desc': 'Selecciona un host donde estará abierto el puerto. El tráfico de este puerto se reenviará al host de destino.',
|
||||
'pf.wizard.remoteConfig.title': 'Configura el puerto y la dirección de enlace:',
|
||||
'pf.wizard.remoteConfig.desc': 'El tráfico se reenviará desde el puerto especificado y la dirección de interfaz del host seleccionado.',
|
||||
'pf.wizard.remoteConfig.remotePort': 'Número de puerto remoto *',
|
||||
'pf.wizard.destination.title': 'Selecciona el host de destino:',
|
||||
'pf.wizard.destination.desc.local': 'Ingresa el destino remoto al que quieres acceder a través del túnel.',
|
||||
'pf.wizard.destination.desc.remote': 'La dirección y el puerto de destino hacia donde se reenviará el tráfico.',
|
||||
'pf.wizard.destination.address': 'Dirección de destino *',
|
||||
'pf.wizard.destination.addressPlaceholder': 'p. ej. 127.0.0.1 o 192.168.1.100',
|
||||
'pf.wizard.destination.port': 'Número de puerto de destino *',
|
||||
'pf.wizard.sshServer.title': 'Selecciona el servidor SSH:',
|
||||
'pf.wizard.sshServer.desc.dynamic': 'Selecciona el servidor SSH que actuará como tu proxy SOCKS.',
|
||||
'pf.wizard.sshServer.desc.default': 'Selecciona el servidor SSH que tunelizará tu tráfico hacia el destino.',
|
||||
'pf.wizard.label.title': 'Selecciona la etiqueta:',
|
||||
'pf.wizard.label.placeholder.dynamic': 'p. ej. Proxy SOCKS',
|
||||
'pf.wizard.label.placeholder.default': 'p. ej. MySQL Producción',
|
||||
'pf.wizard.label.placeholder.remoteRule': 'p. ej. Regla remota',
|
||||
'pf.wizard.placeholders.portExample': 'p. ej. {port}',
|
||||
'pf.action.newForwarding': 'Nuevo reenvío',
|
||||
'pf.form.labelPlaceholder': 'Etiqueta de la regla',
|
||||
'pf.form.intermediateHost': 'Host intermedio *',
|
||||
'pf.form.createRule': 'Crear regla',
|
||||
'pf.form.openWizard': 'Abrir asistente',
|
||||
'pf.form.openWizardTitle': 'Abrir el asistente de reenvío de puertos',
|
||||
'pf.view.grid': 'Cuadrícula',
|
||||
'pf.view.list': 'Lista',
|
||||
'pf.rule.summary.local': 'Local {bindAddress}:{localPort} -> Túnel SSH -> {remoteHost}:{remotePort}',
|
||||
'pf.rule.summary.remote': 'Remoto {bindAddress}:{localPort} -> Túnel SSH -> local {remoteHost}:{remotePort}',
|
||||
'pf.rule.summary.dynamic': 'SOCKS en {bindAddress}:{localPort}',
|
||||
'pf.tooltip.relayHost': 'Host de relevo',
|
||||
'pf.tooltip.hostLabel': 'Host',
|
||||
'pf.tooltip.hostAddress': 'Dirección',
|
||||
'pf.tooltip.noHost': 'No hay host de relevo configurado',
|
||||
'pf.tooltip.localDesc': 'Reenvío de puerto local: accede a servicios remotos a través del túnel SSH',
|
||||
'pf.tooltip.remoteDesc': 'Reenvío de puerto remoto: expone servicios locales al host remoto',
|
||||
'pf.tooltip.dynamicDesc': 'Proxy SOCKS dinámico: enruta el tráfico a través del túnel SSH',
|
||||
'pf.deleteActive.title': '¿Eliminar el reenvío de puertos activo?',
|
||||
'pf.deleteActive.desc': 'Esta regla de reenvío de puertos "{label}" está activa actualmente. Eliminarla primero detendrá el túnel.',
|
||||
'pf.deleteActive.confirm': 'Detener y eliminar',
|
||||
'pf.form.autoStart': 'Inicio automático',
|
||||
'pf.form.autoStartDesc': 'Inicia automáticamente esta regla cuando se abra la aplicación',
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': 'Nueva carpeta',
|
||||
'sftp.newFile': 'Nuevo archivo',
|
||||
'sftp.filter': 'Filtrar',
|
||||
'sftp.filter.placeholder': 'Filtrar por nombre de archivo...',
|
||||
'sftp.bookmark.add': 'Marcar esta ruta',
|
||||
'sftp.bookmark.remove': 'Quitar marcador',
|
||||
'sftp.bookmark.list': 'Rutas marcadas',
|
||||
'sftp.bookmark.addGlobal': '+Global',
|
||||
'sftp.bookmark.addGlobalTooltip': 'Guardar como marcador global (compartido entre todos los hosts)',
|
||||
'sftp.bookmark.empty': 'Aún no hay marcadores',
|
||||
'sftp.columns.name': 'Nombre',
|
||||
'sftp.columns.modified': 'Modificado',
|
||||
'sftp.columns.size': 'Tamaño',
|
||||
'sftp.columns.kind': 'Tipo',
|
||||
'sftp.columns.configure': 'Elegir columnas visibles',
|
||||
'sftp.columns.actions': 'Acciones',
|
||||
'sftp.sort.directoriesFirst': 'Carpetas primero',
|
||||
'sftp.emptyDirectory': 'Directorio vacío',
|
||||
'sftp.nav.up': 'Subir un nivel',
|
||||
'sftp.nav.home': 'Ir al inicio',
|
||||
'sftp.nav.refresh': 'Actualizar',
|
||||
'sftp.upload': 'Subir',
|
||||
'sftp.uploadFiles': 'Subir archivos',
|
||||
'sftp.uploadFolder': 'Subir carpeta',
|
||||
'sftp.dragDropToUpload': 'Arrastra y suelta archivos aquí para subirlos',
|
||||
'sftp.retry': 'Reintentar',
|
||||
'sftp.context.open': 'Abrir',
|
||||
'sftp.context.navigateTo': 'Navegar a',
|
||||
'sftp.context.moveTo': 'Mover a...',
|
||||
'sftp.context.moveToParent': 'Mover al directorio principal',
|
||||
'sftp.moveTo.title': 'Mover a directorio',
|
||||
'sftp.moveTo.placeholder': 'Ingresa la ruta del directorio de destino',
|
||||
'sftp.moveTo.confirm': 'Mover',
|
||||
'sftp.moveTo.pathNotFound': 'Directorio no encontrado o inaccesible',
|
||||
'sftp.context.download': 'Descargar',
|
||||
'sftp.context.extract': 'Extraer aquí',
|
||||
'sftp.extract.extracting': 'Extrayendo {fileName}...',
|
||||
'sftp.extract.success': 'Se extrajo {fileName}',
|
||||
'sftp.extract.error': 'No se pudo extraer {fileName}: {error}',
|
||||
'sftp.context.copyToOtherPane': 'Copiar al otro panel',
|
||||
'sftp.copyToOtherPane.unavailable': 'Abre la vista SFTP de dos paneles y conecta el panel de destino antes de copiar archivos.',
|
||||
'sftp.copyCurrentPath': 'Copiar ruta actual',
|
||||
'sftp.copyCurrentPath.success': 'Ruta actual copiada',
|
||||
'sftp.copyCurrentPath.error': 'No se pudo copiar la ruta actual',
|
||||
'sftp.viewMode.label': 'Modo de vista',
|
||||
'sftp.viewMode.list': 'Vista de lista',
|
||||
'sftp.viewMode.tree': 'Vista de árbol',
|
||||
'sftp.viewMode.switchToList': 'Cambiar a vista de lista',
|
||||
'sftp.viewMode.switchToTree': 'Cambiar a vista de árbol',
|
||||
'sftp.tree.loadError': 'No se pudo cargar el directorio',
|
||||
'sftp.tree.loading': 'Cargando...',
|
||||
'sftp.kind.folder': 'Carpeta',
|
||||
'sftp.context.rename': 'Renombrar',
|
||||
'sftp.context.permissions': 'Permisos',
|
||||
'sftp.context.delete': 'Eliminar',
|
||||
'sftp.context.refresh': 'Actualizar',
|
||||
'sftp.context.uploadFiles': 'Subir archivo(s)...',
|
||||
'sftp.context.uploadFilesHere': 'Subir archivo(s) aquí...',
|
||||
'sftp.context.uploadFolder': 'Subir carpeta...',
|
||||
'sftp.context.uploadFolderHere': 'Subir carpeta aquí...',
|
||||
'sftp.context.downloadSelected': 'Descargar seleccionados ({count})',
|
||||
'sftp.context.deleteSelected': 'Eliminar seleccionados ({count})',
|
||||
'sftp.dropFilesHere': 'Suelta los archivos aquí',
|
||||
'sftp.itemsCount': '{count} elementos',
|
||||
'sftp.selectedCount': '{count} seleccionados',
|
||||
'sftp.path.doubleClickToEdit': 'Haz doble clic para editar la ruta',
|
||||
'sftp.showHiddenPaths': 'Rutas ocultas',
|
||||
'sftp.task.waiting': 'Esperando...',
|
||||
'sftp.transfer.preparing': 'preparando...',
|
||||
'sftp.status.loading': 'Cargando...',
|
||||
'sftp.status.uploading': 'Subiendo...',
|
||||
'sftp.status.ready': 'Listo',
|
||||
'sftp.transfers': 'Transferencias',
|
||||
'sftp.transfers.active': '{count} activas',
|
||||
'sftp.transfers.clearCompleted': 'Limpiar completadas',
|
||||
'sftp.transfers.calculatingTotal': 'Calculando el tamaño total...',
|
||||
'sftp.transfers.filesCount': '{count} archivos',
|
||||
'sftp.transfers.filesProgress': '{current}/{total} archivos',
|
||||
// Progressive folder walk: total is "found so far", not a fixed grand total.
|
||||
'sftp.transfers.filesDiscoveredProgress': '{completed} completados · {discovered} encontrados',
|
||||
'sftp.transfers.expandChildren': 'Mostrar archivos',
|
||||
'sftp.transfers.collapseChildren': 'Ocultar archivos',
|
||||
'sftp.transfers.expandChildList': 'Mostrar detalle',
|
||||
'sftp.transfers.collapseChildList': 'Ocultar',
|
||||
'sftp.transfers.retryAction': 'Reintentar',
|
||||
'sftp.transfers.dismissAction': 'Descartar',
|
||||
'sftp.transfers.openTargetFolder': 'Abrir carpeta de destino',
|
||||
'sftp.transfers.openTargetFolderError': 'No se pudo abrir la carpeta de destino',
|
||||
'sftp.transfers.copyTargetPath': 'Copiar ruta de destino',
|
||||
'sftp.transfers.copyTargetPathSuccess': 'Ruta de destino copiada',
|
||||
'sftp.transfers.copyTargetPathError': 'No se pudo copiar la ruta de destino',
|
||||
'sftp.transfers.pathConflict.title': 'Transferencia bloqueada',
|
||||
'sftp.transfers.pathConflict.paused': 'Otra transferencia de "{name}" está en pausa. Reanúdala o cancélala primero.',
|
||||
'sftp.transfers.pathConflict.inProgress': 'Otra transferencia de "{name}" ya está en curso.',
|
||||
'sftp.transferCenter.title': 'Transferencias de archivos',
|
||||
'sftp.transferCenter.quitConfirm': '{count} transferencia(s) de archivos quedaron sin terminar. El progreso se guardará y deberá reanudarse manualmente la próxima vez. ¿Salir de todos modos?',
|
||||
'sftp.transferCenter.phase.scanning': 'Escaneando',
|
||||
'sftp.transferCenter.phase.compressing': 'Comprimiendo',
|
||||
'sftp.transferCenter.phase.uploading': 'Subiendo',
|
||||
'sftp.transferCenter.phase.transferring': 'Transfiriendo',
|
||||
'sftp.transferCenter.phase.extracting': 'Extrayendo',
|
||||
'sftp.transferCenter.phase.verifying': 'Verificando',
|
||||
'sftp.transferCenter.bucket.active': 'En progreso',
|
||||
'sftp.transferCenter.bucket.all': 'Todas',
|
||||
'sftp.transferCenter.bucket.queued': 'En cola',
|
||||
'sftp.transferCenter.bucket.paused': 'En pausa',
|
||||
'sftp.transferCenter.bucket.failed': 'Fallidas',
|
||||
'sftp.transferCenter.bucket.attention': 'Requieren atención',
|
||||
'sftp.transferCenter.bucket.completed': 'Completadas',
|
||||
'sftp.transferCenter.status.pending': 'Preparando',
|
||||
'sftp.transferCenter.status.resuming': 'Reconectando y reanudando…',
|
||||
'sftp.transferCenter.status.queued': 'En cola',
|
||||
'sftp.transferCenter.status.transferring': 'Transfiriendo',
|
||||
'sftp.transferCenter.status.pausing': 'Pausando',
|
||||
'sftp.transferCenter.status.paused': 'En pausa',
|
||||
'sftp.transferCenter.status.attention': 'Requiere atención',
|
||||
'sftp.transferCenter.status.interrupted': 'Interrumpida: reanúdala manualmente',
|
||||
'sftp.transferCenter.status.completed': 'Completada',
|
||||
'sftp.transferCenter.status.failed': 'Fallida',
|
||||
'sftp.transferCenter.status.cancelled': 'Cancelada',
|
||||
'sftp.transferCenter.background': 'Segundo plano',
|
||||
'sftp.transferCenter.pause': 'Pausar',
|
||||
'sftp.transferCenter.resume': 'Reanudar',
|
||||
'sftp.transferCenter.prioritize': 'Transferir a continuación',
|
||||
'sftp.transferCenter.pauseAll': 'Pausar todas',
|
||||
'sftp.transferCenter.resumeAll': 'Reanudar todas',
|
||||
'sftp.transferCenter.empty': 'No hay transferencias en esta categoría',
|
||||
'sftp.transferCenter.showBackground': 'Mostrar {count} transferencias en segundo plano',
|
||||
'sftp.transferCenter.hideBackground': 'Ocultar transferencias en segundo plano',
|
||||
'sftp.transferCenter.clear': 'Limpiar registros',
|
||||
'sftp.transferCenter.applyAll': 'Aplicar a todas',
|
||||
'sftp.transfers.resizeNameColumn': 'Redimensionar la columna de nombre de archivo',
|
||||
'sftp.transfers.dragToResize': 'Arrastra para redimensionar',
|
||||
'sftp.goUp': 'Subir un nivel',
|
||||
'sftp.goToTerminalCwd': 'Ir al directorio de la terminal',
|
||||
'sftp.locatePathInTerminal': 'Abrir ruta en la terminal',
|
||||
'sftp.followTerminalCwd': 'Seguir el directorio de la terminal',
|
||||
'sftp.followTerminalCwd.enable': 'Habilitar el seguimiento del directorio de la terminal',
|
||||
'sftp.followTerminalCwd.disable': 'Deshabilitar el seguimiento del directorio de la terminal',
|
||||
'sftp.encoding.label': 'Codificación de nombres de archivo',
|
||||
'sftp.encoding.auto': 'Auto',
|
||||
'sftp.encoding.utf8': 'UTF-8',
|
||||
'sftp.encoding.gb18030': 'GB18030',
|
||||
'sftp.goHome': 'Ir al inicio',
|
||||
'sftp.folderName': 'Nombre de la carpeta',
|
||||
'sftp.folderName.placeholder': 'Ingresa el nombre de la carpeta',
|
||||
'sftp.fileName': 'Nombre del archivo',
|
||||
'sftp.fileName.placeholder': 'Ingresa el nombre del archivo',
|
||||
'sftp.prompt.newFolderName': '¿Nombre de la carpeta nueva?',
|
||||
'sftp.rename.title': 'Renombrar',
|
||||
'sftp.rename.newName': 'Nombre nuevo',
|
||||
'sftp.rename.placeholder': 'Ingresa el nombre nuevo',
|
||||
'sftp.confirm.deleteOne': '¿Eliminar "{name}"?',
|
||||
'sftp.deleteConfirm.single': '¿Eliminar "{name}"?',
|
||||
'sftp.deleteConfirm.title': '¿Eliminar {count} elemento(s)?',
|
||||
'sftp.deleteConfirm.desc': 'Esta acción no se puede deshacer. Se eliminará lo siguiente:',
|
||||
'sftp.deleteConfirm.descSingle': 'Esta acción no se puede deshacer.',
|
||||
'sftp.deleteConfirm.host': 'Host',
|
||||
'sftp.deleteConfirm.path': 'Ruta',
|
||||
'sftp.error.loadFailed': 'No se pudo cargar el directorio',
|
||||
'sftp.error.downloadFailed': 'No se pudo descargar',
|
||||
'sftp.error.uploadFailed': 'No se pudo subir',
|
||||
'sftp.error.deleteFailed': 'No se pudo eliminar',
|
||||
'sftp.error.createFolderFailed': 'No se pudo crear la carpeta',
|
||||
'sftp.error.createFileFailed': 'No se pudo crear el archivo',
|
||||
'sftp.error.invalidFileName': 'El nombre de archivo contiene caracteres no válidos: {chars}',
|
||||
'sftp.error.reservedName': 'Este nombre de archivo está reservado por el sistema',
|
||||
'sftp.overwrite.title': 'El archivo ya existe',
|
||||
'sftp.overwrite.desc': 'Ya existe un archivo llamado "{name}". ¿Quieres reemplazarlo?',
|
||||
'sftp.overwrite.confirm': 'Reemplazar',
|
||||
'sftp.error.renameFailed': 'No se pudo renombrar',
|
||||
'sftp.picker.title': 'Seleccionar host',
|
||||
'sftp.picker.desc': 'Elige un host para el panel {side}',
|
||||
'sftp.picker.searchPlaceholder': 'Buscar hosts...',
|
||||
'sftp.picker.local.title': 'Sistema de archivos local',
|
||||
'sftp.picker.local.desc': 'Explorar archivos locales',
|
||||
'sftp.picker.local.badge': 'Local',
|
||||
'sftp.picker.connected.section': 'Conectados',
|
||||
'sftp.picker.connected.badge': 'En vivo',
|
||||
'sftp.picker.connected.connecting': 'Conectando',
|
||||
'sftp.picker.noMatch': 'No hay hosts que coincidan',
|
||||
'sftp.permissions.title': 'Editar permisos',
|
||||
'sftp.permissions.owner': 'Propietario',
|
||||
'sftp.permissions.group': 'Grupo',
|
||||
'sftp.permissions.others': 'Otros',
|
||||
'sftp.permissions.octal': 'Octal',
|
||||
'sftp.permissions.symbolic': 'Simbólico',
|
||||
'sftp.permissions.success': 'Permisos actualizados correctamente',
|
||||
'sftp.permissions.failed': 'No se pudieron actualizar los permisos',
|
||||
'sftp.pane.local': 'Local',
|
||||
'sftp.pane.remote': 'Remoto',
|
||||
'sftp.pane.selectHost': 'Seleccionar host',
|
||||
'sftp.pane.selectHostToStart': 'Selecciona un host para comenzar',
|
||||
'sftp.pane.chooseFilesystem': 'Elige un sistema de archivos local o remoto para explorar',
|
||||
'sftp.tabs.addTab': 'Agregar pestaña nueva',
|
||||
'sftp.tabs.closeTab': 'Cerrar pestaña',
|
||||
'sftp.tabs.newTab': 'Pestaña nueva',
|
||||
'sftp.tabs.copyDefaultPath': 'Copiar pestaña (ruta predeterminada)',
|
||||
'sftp.tabs.copyCurrentPath': 'Copiar e ir a la ruta actual',
|
||||
'sftp.conflict.title': 'Conflicto de archivos',
|
||||
'sftp.conflict.desc': 'Ya existe un archivo con el mismo nombre en el destino',
|
||||
'sftp.conflict.alreadyExistsSuffix': 'ya existe',
|
||||
'sftp.conflict.existingFile': 'Archivo existente',
|
||||
'sftp.conflict.newFile': 'Archivo nuevo',
|
||||
'sftp.conflict.size': 'Tamaño:',
|
||||
'sftp.conflict.modified': 'Modificado:',
|
||||
'sftp.conflict.applyToAll': 'Aplicar esta acción a los {count} conflictos restantes',
|
||||
'sftp.conflict.action.stop': 'Detener',
|
||||
'sftp.conflict.action.skip': 'Omitir',
|
||||
'sftp.conflict.action.keepBoth': 'Conservar ambos',
|
||||
'sftp.conflict.action.duplicate': 'Duplicar',
|
||||
'sftp.conflict.action.merge': 'Combinar',
|
||||
'sftp.conflict.action.replace': 'Reemplazar',
|
||||
|
||||
// SFTP Upload Phases
|
||||
'sftp.upload.phase.compressing': 'Comprimiendo',
|
||||
'sftp.upload.phase.uploading': 'Subiendo',
|
||||
'sftp.upload.phase.extracting': 'Extrayendo',
|
||||
'sftp.upload.phase.compressed': 'Comprimido',
|
||||
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': 'Copiar ruta del archivo',
|
||||
'sftp.context.openWithDefault': 'Abrir con la aplicación predeterminada del sistema',
|
||||
'sftp.context.openWith': 'Abrir con...',
|
||||
'sftp.context.edit': 'Editar',
|
||||
'sftp.context.preview': 'Vista previa',
|
||||
'sftp.opener.title': 'Abrir con',
|
||||
'sftp.opener.desc': 'Elige una aplicación para abrir este archivo',
|
||||
'sftp.opener.builtInEditor': 'Editor integrado',
|
||||
'sftp.opener.editDescription': 'Editar archivos de texto',
|
||||
'sftp.opener.builtInImageViewer': 'Visor de imágenes integrado',
|
||||
'sftp.opener.previewDescription': 'Previsualizar imágenes',
|
||||
'sftp.opener.systemApp': 'Elegir aplicación...',
|
||||
'sftp.opener.systemAppDescription': 'Selecciona una aplicación de tu computadora',
|
||||
'sftp.opener.onlySystemApp': 'Este archivo solo se puede abrir con una aplicación externa',
|
||||
'sftp.opener.noAppsAvailable': 'No hay aplicaciones disponibles',
|
||||
'sftp.opener.noExtension': 'archivos sin extensión',
|
||||
'sftp.opener.setDefault': 'Usar siempre esto para archivos {ext}',
|
||||
'sftp.opener.confirmTitle': '¿Establecer como predeterminado?',
|
||||
'sftp.opener.confirmDescription': '¿Quieres usar siempre {app} para archivos {ext}?',
|
||||
'sftp.opener.yesRemember': 'Sí, recordar esta elección',
|
||||
'sftp.opener.justOnce': 'Solo esta vez',
|
||||
'sftp.opener.confirm.title': 'Establecer aplicación predeterminada',
|
||||
'sftp.opener.confirm.desc': '¿Quieres abrir siempre los archivos .{ext} con esta aplicación?',
|
||||
'sftp.editor.title': 'Editor de texto',
|
||||
'sftp.editor.save': 'Guardar en remoto',
|
||||
'sftp.editor.saving': 'Guardando...',
|
||||
'sftp.editor.saved': 'Guardado correctamente',
|
||||
'sftp.editor.saveFailed': 'No se pudo guardar el archivo',
|
||||
'sftp.editor.unsavedChanges': 'Tienes cambios sin guardar. ¿Cerrar de todos modos?',
|
||||
'sftp.editor.syntaxHighlight': 'Resaltado de sintaxis',
|
||||
'sftp.preview.title': 'Vista previa de imagen',
|
||||
'sftp.preview.zoomIn': 'Acercar',
|
||||
'sftp.preview.zoomOut': 'Alejar',
|
||||
'sftp.preview.resetZoom': 'Restablecer zoom',
|
||||
'sftp.preview.fitToWindow': 'Ajustar a la ventana',
|
||||
|
||||
// Settings > SFTP File Associations
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftp.transferConcurrency': 'Concurrencia de transferencias',
|
||||
'settings.sftp.transferConcurrency.desc': 'Cantidad máxima de archivos transferidos en paralelo para el mismo endpoint, incluidos los contenidos de carpetas y los archivos seleccionados por separado. Los valores más altos pueden mejorar la velocidad, pero pueden abrumar a algunos servidores.',
|
||||
'settings.sftp.transferPoolIdleTtl': 'Mantener activa la conexión de transferencia',
|
||||
'settings.sftp.transferPoolIdleTtl.desc': 'Cuánto tiempo las conexiones SFTP dedicadas de transferencia permanecen activas después de estar inactivas. Los valores más largos reducen la demora de arranque en frío antes de la siguiente subida o descarga. Las transferencias ocupadas nunca se cierran.',
|
||||
'settings.sftp.transferPoolIdleTtl.1m': '1 minuto',
|
||||
'settings.sftp.transferPoolIdleTtl.5m': '5 minutos',
|
||||
'settings.sftp.transferPoolIdleTtl.15m': '15 minutos',
|
||||
'settings.sftp.transferPoolIdleTtl.30m': '30 minutos',
|
||||
'settings.sftp.transferPoolIdleTtl.never': 'Hasta que se cierre la aplicación',
|
||||
'settings.ssh.transportIdleTtl': 'Mantener activa la conexión SSH',
|
||||
'settings.ssh.transportIdleTtl.desc': 'Cuánto tiempo una conexión SSH compartida permanece activa después de liberar el último terminal, la exploración SFTP, la transferencia masiva o el arrendamiento de reenvío de puertos. Mientras esté activa, reconectarse al mismo host puede omitir la reautenticación (incluida la MFA). Las sesiones y transferencias activas nunca se cierran.',
|
||||
'settings.ssh.transportIdleTtl.1m': '1 minuto',
|
||||
'settings.ssh.transportIdleTtl.5m': '5 minutos',
|
||||
'settings.ssh.transportIdleTtl.15m': '15 minutos',
|
||||
'settings.ssh.transportIdleTtl.30m': '30 minutos',
|
||||
'settings.ssh.transportIdleTtl.never': 'Hasta que se cierre la aplicación',
|
||||
'settings.sftp.defaultOpener': 'Aplicación predeterminada para abrir archivos',
|
||||
'settings.sftp.defaultOpener.desc': 'Elige la aplicación predeterminada para abrir archivos sin una asociación de archivos específica',
|
||||
'settings.sftp.defaultOpener.ask': 'Preguntar siempre',
|
||||
'settings.sftp.defaultOpener.askDesc': 'Muestra un diálogo para elegir una aplicación cada vez',
|
||||
'settings.sftp.defaultOpener.builtInDesc': 'Abrir archivos de texto en el editor integrado por defecto',
|
||||
'settings.sftp.defaultOpener.systemApp': 'Elegir aplicación...',
|
||||
'settings.sftp.defaultOpener.systemAppDesc': 'Abrir archivos con una aplicación específica por defecto',
|
||||
'settings.sftpFileAssociations.title': 'Asociaciones de archivos SFTP',
|
||||
'settings.sftpFileAssociations.desc': 'Configura las aplicaciones predeterminadas para abrir archivos por extensión',
|
||||
'settings.sftpFileAssociations.extension': 'Extensión',
|
||||
'settings.sftpFileAssociations.application': 'Aplicación',
|
||||
'settings.sftpFileAssociations.noAssociations': 'No hay asociaciones de archivos configuradas',
|
||||
'settings.sftpFileAssociations.remove': 'Quitar',
|
||||
'settings.sftpFileAssociations.removeConfirm': '¿Quitar la asociación para .{ext}?',
|
||||
|
||||
// Settings > SFTP Behavior
|
||||
'settings.sftp.doubleClickBehavior': 'Comportamiento del doble clic',
|
||||
'settings.sftp.doubleClickBehavior.desc': 'Elige la acción al hacer doble clic en un archivo en la vista SFTP',
|
||||
'settings.sftp.doubleClickBehavior.open': 'Abrir archivo',
|
||||
'settings.sftp.doubleClickBehavior.transfer': 'Transferir al otro panel',
|
||||
'settings.sftp.doubleClickBehavior.openDesc': 'Abrir el archivo en la aplicación predeterminada',
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': 'Transferir el archivo al host activo del otro panel',
|
||||
'settings.sshDeepLink.title': 'Enlaces SSH y Telnet',
|
||||
'settings.sshDeepLink.enable': 'Abrir enlaces ssh:// y telnet:// con Netcatty',
|
||||
'settings.sshDeepLink.enableDesc': 'Permite que Netcatty maneje los enlaces ssh:// y telnet:// de los navegadores y otras aplicaciones, además de lanzamientos al estilo PuTTY como -ssh user@host -P 22 -pw password.',
|
||||
'settings.explorerContextMenu.title': 'Explorador de Windows',
|
||||
'settings.explorerContextMenu.enable': 'Mostrar "Abrir en Netcatty" en los menús contextuales de carpetas',
|
||||
'settings.explorerContextMenu.enableDesc': 'Agrega un elemento de clic derecho para las carpetas y los fondos de carpeta, para que puedas abrir una terminal local allí. Se aplica de inmediato; no es necesario reinstalar.',
|
||||
'settings.jmsDeepLink.title': 'Enlaces de JumpServer',
|
||||
'settings.jmsDeepLink.enable': 'Manejar enlaces jms://',
|
||||
'settings.jmsDeepLink.enableDesc': 'Registra a Netcatty como el controlador de los enlaces jms:// del cliente de JumpServer. Puede entrar en conflicto con el cliente oficial de JumpServer.',
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': 'Sincronización automática con el remoto',
|
||||
'settings.sftp.autoSync.desc': 'Sincroniza automáticamente los cambios de archivos de vuelta al servidor remoto al abrir archivos con aplicaciones externas',
|
||||
'settings.sftp.autoSync.enable': 'Habilitar la sincronización automática',
|
||||
'settings.sftp.autoSync.enableDesc': 'Cuando guardas un archivo en una aplicación externa, los cambios se subirán automáticamente al servidor remoto',
|
||||
|
||||
// Settings > SFTP Auto Open Sidebar
|
||||
'settings.sftp.autoOpenSidebar': 'Abrir la barra lateral automáticamente al conectar',
|
||||
'settings.sftp.autoOpenSidebar.desc': 'Abre automáticamente la barra lateral del explorador de archivos SFTP al conectar a un host',
|
||||
'settings.sftp.autoOpenSidebar.enable': 'Habilitar la apertura automática de la barra lateral',
|
||||
'settings.sftp.autoOpenSidebar.enableDesc': 'La barra lateral de SFTP se abrirá automáticamente cuando una sesión de terminal se conecte a un host remoto',
|
||||
'settings.sftp.followTerminalCwd': 'Seguir el directorio de la terminal',
|
||||
'settings.sftp.followTerminalCwd.desc': 'Sincroniza automáticamente el explorador SFTP de la barra lateral con el directorio de trabajo de la terminal (alternar en la barra de herramientas)',
|
||||
'settings.sftp.followTerminalCwd.enable': 'Habilitar el seguimiento del directorio de la terminal por defecto',
|
||||
'settings.sftp.followTerminalCwd.enableDesc': 'Cuando la barra lateral de SFTP está abierta, el modo de seguimiento permanece activado por defecto y se actualiza después de los comandos cd de la terminal',
|
||||
|
||||
'settings.sftp.defaultViewMode': 'Modo de vista predeterminado',
|
||||
'settings.sftp.defaultViewMode.desc': 'Elige el modo de vista predeterminado al abrir una pestaña SFTP nueva. Las preferencias por host anulan esta configuración.',
|
||||
'settings.sftp.defaultViewMode.list': 'Vista de lista',
|
||||
'settings.sftp.defaultViewMode.listDesc': 'Muestra los archivos en una lista plana del directorio actual',
|
||||
'settings.sftp.defaultViewMode.tree': 'Vista de árbol',
|
||||
'settings.sftp.defaultViewMode.treeDesc': 'Muestra los archivos en una estructura de árbol jerárquica',
|
||||
|
||||
'sftp.autoSync.success': 'Archivo sincronizado con el remoto: {fileName}',
|
||||
'sftp.autoSync.error': 'No se pudo sincronizar el archivo: {error}',
|
||||
|
||||
// SFTP Folder Upload Progress
|
||||
'sftp.upload.progress': 'Subiendo {current} de {total} archivos...',
|
||||
'sftp.upload.uploading': 'Subiendo...',
|
||||
'sftp.upload.compressing': 'Comprimiendo...',
|
||||
'sftp.upload.extracting': 'Extrayendo...',
|
||||
'sftp.upload.scanning': 'Escaneando archivos...',
|
||||
'sftp.upload.completed': 'Completado',
|
||||
'sftp.upload.compressed': 'Transferencia comprimida',
|
||||
'sftp.upload.currentFile': 'Actual: {fileName}',
|
||||
'sftp.upload.cancelled': 'Subida cancelada',
|
||||
'sftp.upload.noFiles': 'No hay archivos para subir',
|
||||
'sftp.upload.partialSuccess': 'Se subieron {success} archivo(s); {failed} fallaron',
|
||||
'sftp.upload.cancel': 'Cancelar',
|
||||
'sftp.upload.completedToPath': 'Subido a {path}',
|
||||
|
||||
// SFTP Download
|
||||
'sftp.download.completed': 'Descargado',
|
||||
'sftp.download.cancelled': 'Descarga cancelada',
|
||||
|
||||
// SFTP Reconnecting
|
||||
'sftp.reconnecting.title': 'Reconectando...',
|
||||
'sftp.reconnecting.desc': 'Se perdió la conexión, intentando reconectar',
|
||||
'sftp.reconnected': 'Conexión restablecida',
|
||||
'sftp.error.reconnectFailed': 'No se pudo reconectar. Inténtalo de nuevo.',
|
||||
'sftp.error.connectionLostManual': 'Se perdió la conexión. Vuelve a conectar manualmente.',
|
||||
'sftp.error.connectionLostReconnecting': 'Se perdió la conexión. Reconectando...',
|
||||
'sftp.error.sessionLost': 'Se perdió la sesión SFTP. Vuelve a conectar.',
|
||||
|
||||
// Settings > SFTP Show Hidden Files
|
||||
'settings.sftp.showHiddenFiles': 'Mostrar archivos ocultos',
|
||||
'settings.sftp.showHiddenFiles.desc': 'Muestra los archivos ocultos (dotfiles en Unix/macOS y archivos con el atributo oculto en Windows) en el explorador de archivos SFTP.',
|
||||
'settings.sftp.showHiddenFiles.enable': 'Mostrar archivos ocultos',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': 'Muestra los archivos ocultos al explorar sistemas de archivos locales y remotos',
|
||||
|
||||
// Settings > SFTP Compressed Upload
|
||||
'settings.sftp.compressedUpload': 'Transferencia de carpetas comprimidas',
|
||||
'settings.sftp.compressedUpload.desc': 'Comprime las carpetas antes de subirlas para reducir significativamente el tiempo de transferencia.',
|
||||
'settings.sftp.compressedUpload.enable': 'Habilitar la compresión de carpetas',
|
||||
'settings.sftp.compressedUpload.enableDesc': 'Comprime automáticamente las carpetas con tar antes de la transferencia. Requiere soporte de tar en el servidor. Si no está disponible, se recurre a una transferencia normal.',
|
||||
|
||||
'settings.sftp.skipUnchanged.enable': 'Omitir archivos sin cambios',
|
||||
'settings.sftp.skipUnchanged.enableDesc': 'Omite los archivos cuyo tamaño y hora de modificación ya coinciden con el destino (estilo rsync). Deshabilitado al reemplazar una carpeta en una ruta de staging.',
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Buscar hosts o pestañas',
|
||||
'qs.jumpTo': 'Ir a',
|
||||
'qs.localTerminal': 'Terminal local',
|
||||
'qs.localShells': 'Shells locales',
|
||||
'qs.default': 'Predeterminado',
|
||||
|
||||
// Select Host panel
|
||||
'selectHost.title': 'Seleccionar host',
|
||||
'selectHost.noHostsFound': 'No se encontraron hosts',
|
||||
'selectHost.newHost': 'Host nuevo',
|
||||
'selectHost.continue': 'Continuar',
|
||||
'selectHost.continueWithCount': 'Continuar ({count} seleccionados)',
|
||||
'selectHost.toggleGroup': 'Alternar todos los hosts en {name}',
|
||||
'selectHost.toggleHost': 'Alternar {name}',
|
||||
|
||||
// Quick Connect
|
||||
'quickConnect.knownHost.title': '¿Estás seguro de que quieres conectarte?',
|
||||
'quickConnect.knownHost.authenticity': 'No se puede establecer la autenticidad de {hostname}.',
|
||||
'quickConnect.knownHost.fingerprintLabel': 'La huella digital de {keyType} es SHA256:',
|
||||
'quickConnect.knownHost.addQuestion': '¿Quieres agregarlo a la lista de hosts conocidos?',
|
||||
'quickConnect.knownHost.addAndContinue': 'Agregar y continuar',
|
||||
'quickConnect.addKey': 'Agregar clave',
|
||||
'quickConnect.warning.unparsedOptions': 'Se ignoraron algunos argumentos SSH: {options}',
|
||||
'quickConnect.identity.label': 'Preajuste de credenciales',
|
||||
'quickConnect.identity.placeholder': 'Elige una identidad guardada',
|
||||
'quickConnect.identity.empty': 'No se encontraron identidades guardadas',
|
||||
'quickConnect.identity.hint': 'Elige una identidad para completar automáticamente su nombre de usuario y autenticación.',
|
||||
'quickConnect.identity.selectedHint': 'Listo para conectar como {username}.',
|
||||
'quickConnect.identity.method.password': 'Contraseña',
|
||||
'quickConnect.identity.method.key': 'Clave SSH',
|
||||
'quickConnect.identity.method.certificate': 'Certificado',
|
||||
'quickConnect.identity.unavailable': 'A esta identidad le faltan credenciales utilizables en este dispositivo.',
|
||||
'quickConnect.identity.useCustom': 'Usar credenciales personalizadas',
|
||||
'quickConnect.identity.connect': 'Conectar con preajuste',
|
||||
'quickConnect.connectTitle': 'Conectar a {host}',
|
||||
|
||||
// Terminal
|
||||
'terminal.connectionErrorTitle': 'Error de conexión',
|
||||
|
||||
// Protocol select dialog
|
||||
'protocolSelect.chooseProtocol': 'Elegir protocolo',
|
||||
'protocolSelect.port': 'puerto:',
|
||||
|
||||
// Host Details
|
||||
'hostDetails.title.details': 'Detalles del host',
|
||||
'hostDetails.title.new': 'Host nuevo',
|
||||
'vault.panel.resizeWidth': 'Redimensionar el ancho del panel',
|
||||
'hostDetails.saveAria': 'Guardar',
|
||||
'hostDetails.section.address': 'Dirección',
|
||||
'hostDetails.hostname.placeholder': 'IP o nombre de host',
|
||||
'hostDetails.section.general': 'General',
|
||||
'hostDetails.section.sftp': 'Configuración de SFTP',
|
||||
'hostDetails.sftp.fileProtocol': 'Protocolo de archivos',
|
||||
'hostDetails.sftp.fileProtocol.desc': 'Auto intenta SFTP y luego recurre al modo SCP cuando el subsistema SFTP no está disponible (p. ej., algunos NAS). Fuerza SCP para hosts que solo admiten SCP.',
|
||||
'hostDetails.sftp.fileProtocol.auto': 'Auto',
|
||||
'hostDetails.sftp.fileProtocol.sftp': 'Solo SFTP',
|
||||
'hostDetails.sftp.fileProtocol.scp': 'Modo SCP',
|
||||
'hostDetails.sftp.sudo': 'Modo Sudo',
|
||||
'hostDetails.sftp.sudo.desc': 'Adquiere automáticamente privilegios de root usando la contraseña guardada',
|
||||
'hostDetails.sftp.sudo.passwordWarning': 'El modo Sudo requiere una contraseña. Configura una arriba, o asegúrate de que el servidor permita sudo sin contraseña.',
|
||||
'hostDetails.sftp.encoding': 'Codificación de nombres de archivo',
|
||||
'hostDetails.sftp.encoding.desc': 'Selecciona la codificación utilizada para decodificar y enviar los nombres de archivo SFTP.',
|
||||
'hostDetails.label.placeholder': 'Etiqueta (p. ej., servidor de producción)',
|
||||
'hostDetails.notes.label': 'Notas',
|
||||
'hostDetails.notes.placeholder': 'Hardware, proyecto, cliente, región, rol...',
|
||||
'hostDetails.notes.help': 'Admite Markdown. No guardes contraseñas ni claves privadas aquí.',
|
||||
'hostDetails.notes.tab.edit': 'Editar',
|
||||
'hostDetails.notes.tab.preview': 'Vista previa',
|
||||
'hostDetails.notes.preview.empty': 'Aún no hay nada para previsualizar.',
|
||||
'hostDetails.notes.toggle.show': 'Mostrar el editor de notas',
|
||||
'hostDetails.notes.toggle.hide': 'Ocultar el editor de notas',
|
||||
'hostDetails.group.placeholder': 'Grupo padre',
|
||||
'hostDetails.section.credentials': 'Credenciales',
|
||||
'hostDetails.section.portCredentials': 'Puerto y credenciales',
|
||||
'hostDetails.section.appearance': 'Apariencia',
|
||||
'hostDetails.distro.title': 'Distribución de Linux',
|
||||
'hostDetails.distro.desc': 'Detecta automáticamente al conectar, o anula el ícono de la distro manualmente.',
|
||||
'hostDetails.icon.title': 'Ícono del host',
|
||||
'hostDetails.icon.sectionTitle': 'Color e ícono',
|
||||
'hostDetails.icon.desc': 'Elige la fuente y el color del ícono para este host.',
|
||||
'hostDetails.icon.source': 'Fuente',
|
||||
'hostDetails.icon.manualLabel': 'Ícono manual',
|
||||
'hostDetails.icon.tab.brand': 'Marca',
|
||||
'hostDetails.icon.tab.type': 'Tipo',
|
||||
'hostDetails.icon.mode.auto': 'Automático',
|
||||
'hostDetails.icon.mode.custom': 'Personalizado',
|
||||
'hostDetails.icon.reset': 'Restablecer el ícono del host',
|
||||
'hostDetails.icon.colorLabel': 'Color del ícono',
|
||||
'hostDetails.icon.colorDefault': 'Predeterminado',
|
||||
'hostDetails.icon.colorAuto': 'Automático',
|
||||
'hostDetails.icon.colorManual': 'Manual',
|
||||
'hostDetails.icon.colorChoice': 'Color',
|
||||
'hostDetails.icon.autoColorLabel': 'Color actual',
|
||||
'hostDetails.icon.preview': 'Vista previa del ícono',
|
||||
'hostDetails.icon.showLibrary': 'Mostrar la biblioteca de íconos',
|
||||
'hostDetails.icon.hideLibrary': 'Ocultar la biblioteca de íconos',
|
||||
'hostDetails.icon.autoUsesDistro': 'Usa el ícono de la distribución de Linux y el color seleccionado para este host.',
|
||||
'hostDetails.icon.customOverridesDistro': 'El ícono integrado reemplaza la distribución de Linux para este host.',
|
||||
'hostDetails.icon.option.server': 'Servidor',
|
||||
'hostDetails.icon.option.terminal': 'Terminal',
|
||||
'hostDetails.icon.option.database': 'Base de datos',
|
||||
'hostDetails.icon.option.cloud': 'Nube',
|
||||
'hostDetails.icon.option.router': 'Router',
|
||||
'hostDetails.icon.option.shield': 'Escudo',
|
||||
'hostDetails.icon.option.code': 'Código',
|
||||
'hostDetails.icon.option.box': 'Caja',
|
||||
'hostDetails.icon.option.globe': 'Globo',
|
||||
'hostDetails.icon.option.cpu': 'CPU',
|
||||
'hostDetails.icon.option.hard-drive': 'Almacenamiento',
|
||||
'hostDetails.icon.option.network': 'Red',
|
||||
'hostDetails.icon.option.wifi': 'Inalámbrico',
|
||||
'hostDetails.icon.option.lock': 'Bloqueo',
|
||||
'hostDetails.icon.option.key': 'Clave',
|
||||
'hostDetails.icon.option.monitor': 'Monitor',
|
||||
'hostDetails.icon.option.container': 'Contenedor',
|
||||
'hostDetails.icon.option.activity': 'Actividad',
|
||||
'hostDetails.icon.option.zap': 'Rápido',
|
||||
'hostDetails.icon.option.server-cog': 'Configuración del servidor',
|
||||
'hostDetails.icon.color.blue': 'Azul',
|
||||
'hostDetails.icon.color.green': 'Verde',
|
||||
'hostDetails.icon.color.red': 'Rojo',
|
||||
'hostDetails.icon.color.amber': 'Ámbar',
|
||||
'hostDetails.icon.color.purple': 'Púrpura',
|
||||
'hostDetails.icon.color.cyan': 'Cian',
|
||||
'hostDetails.icon.color.orange': 'Naranja',
|
||||
'hostDetails.icon.color.slate': 'Pizarra',
|
||||
'hostDetails.icon.color.violet': 'Violeta',
|
||||
'hostDetails.icon.color.pink': 'Rosa',
|
||||
'hostDetails.icon.color.rose': 'Rosado',
|
||||
'hostDetails.icon.color.lime': 'Lima',
|
||||
'hostDetails.icon.color.teal': 'Verde azulado',
|
||||
'hostDetails.icon.color.sky': 'Celeste',
|
||||
'hostDetails.icon.color.indigo': 'Índigo',
|
||||
'hostDetails.icon.color.zinc': 'Zinc',
|
||||
'hostDetails.icon.color.custom': 'Color personalizado',
|
||||
'hostDetails.distro.mode': 'Fuente',
|
||||
'hostDetails.distro.mode.auto': 'Detección automática',
|
||||
'hostDetails.distro.mode.manual': 'Anulación manual',
|
||||
'hostDetails.distro.detectedLabel': 'Actual',
|
||||
'hostDetails.distro.manualLabel': 'Anulación',
|
||||
'hostDetails.distro.pending': 'Detectar después de la primera conexión',
|
||||
'hostDetails.distro.unknown': 'Desconocido',
|
||||
'hostDetails.distro.option.linux': 'Linux genérico',
|
||||
'hostDetails.distro.option.ubuntu': 'Ubuntu',
|
||||
'hostDetails.distro.option.debian': 'Debian',
|
||||
'hostDetails.distro.option.centos': 'CentOS',
|
||||
'hostDetails.distro.option.rocky': 'Rocky Linux',
|
||||
'hostDetails.distro.option.fedora': 'Fedora',
|
||||
'hostDetails.distro.option.arch': 'Arch Linux',
|
||||
'hostDetails.distro.option.alpine': 'Alpine',
|
||||
'hostDetails.distro.option.amazon': 'Amazon Linux',
|
||||
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
|
||||
'hostDetails.distro.option.openeuler': 'openEuler',
|
||||
'hostDetails.distro.option.freebsd': 'FreeBSD',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': 'Cisco',
|
||||
'hostDetails.distro.option.juniper': 'Juniper Networks',
|
||||
'hostDetails.distro.option.huawei': 'Huawei',
|
||||
'hostDetails.distro.option.h3c': 'H3C',
|
||||
'hostDetails.distro.option.hpe': 'HPE',
|
||||
'hostDetails.distro.option.mikrotik': 'MikroTik',
|
||||
'hostDetails.distro.option.fortinet': 'Fortinet',
|
||||
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
|
||||
'hostDetails.distro.option.zyxel': 'ZyXEL',
|
||||
'hostDetails.distro.option.ruijie': 'Ruijie',
|
||||
'hostDetails.section.mosh': 'Mosh',
|
||||
'hostDetails.section.et': 'EternalTerminal',
|
||||
'hostDetails.et.port': 'Puerto del servidor ET',
|
||||
'hostDetails.et.port.desc': 'Puerto en el que escucha etserver (predeterminado 2022)',
|
||||
'hostDetails.username.placeholder': 'Nombre de usuario',
|
||||
'hostDetails.password.placeholder': 'Contraseña',
|
||||
'hostDetails.password.show': 'Mostrar contraseña',
|
||||
'hostDetails.password.hide': 'Ocultar contraseña',
|
||||
'hostDetails.password.save': 'Guardar contraseña',
|
||||
'hostDetails.auth.method': 'Método de inicio de sesión',
|
||||
'hostDetails.auth.auto': 'Automático',
|
||||
'hostDetails.auth.auto.desc': 'Intenta con el agente SSH y las claves locales, y luego usa la contraseña guardada.',
|
||||
'hostDetails.auth.passwordOnly': 'Solo contraseña',
|
||||
'hostDetails.auth.password.desc': 'Usa solo la contraseña guardada y las solicitudes interactivas de contraseña.',
|
||||
'hostDetails.auth.key': 'Clave',
|
||||
'hostDetails.auth.key.desc': 'Usa la clave seleccionada, con la contraseña guardada como respaldo cuando esté presente.',
|
||||
'hostDetails.auth.certificate': 'Certificado',
|
||||
'hostDetails.auth.certificate.desc': 'Usa el certificado SSH seleccionado, con la contraseña guardada como respaldo cuando esté presente.',
|
||||
'hostDetails.auth.mfaFirst': 'Preferir autenticación interactiva',
|
||||
'hostDetails.auth.mfaFirst.desc': 'Para servidores que requieren contraseña y MFA a través de keyboard-interactive.',
|
||||
'hostDetails.auth.credentialRequired': 'Selecciona una clave o un certificado antes de guardar este método de inicio de sesión.',
|
||||
'hostDetails.identity.suggestions': 'Identidades',
|
||||
'hostDetails.identity.missing': 'Identidad no encontrada',
|
||||
'hostDetails.credential.keyCertificate': 'Clave, certificado, archivo de clave local',
|
||||
'hostDetails.credential.key': 'Clave',
|
||||
'hostDetails.credential.certificate': 'Certificado',
|
||||
'hostDetails.credential.localKeyFile': 'Archivo de clave local',
|
||||
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
|
||||
'hostDetails.credential.browseKeyFile': 'Explorar...',
|
||||
'hostDetails.credential.missing': 'Credencial no encontrada',
|
||||
'hostDetails.section.systemSshAgent': 'Agente SSH del sistema',
|
||||
'hostDetails.systemSshAgent': 'Usar el agente SSH del sistema para iniciar sesión',
|
||||
'hostDetails.systemSshAgent.desc': 'Usa claves ya desbloqueadas por macOS, Linux, Windows, 1Password u otro agente SSH compatible',
|
||||
'hostDetails.systemSshAgent.socket': 'Socket del agente',
|
||||
'hostDetails.systemSshAgent.socket.desc': 'Déjalo vacío para usar SSH_AUTH_SOCK',
|
||||
'hostDetails.systemSshAgent.identitiesOnly': 'Usar solo las claves configuradas',
|
||||
'hostDetails.systemSshAgent.identitiesOnly.desc': 'No intentar con claves no relacionadas actualmente cargadas en el agente',
|
||||
'hostDetails.systemSshAgent.notRunning': 'El agente SSH del sistema no está disponible',
|
||||
'hostDetails.systemSshAgent.notRunningHint': 'Inicia o desbloquea tu agente SSH, o proporciona la ruta de su socket arriba.',
|
||||
'hostDetails.keys.search': 'Buscar claves...',
|
||||
'hostDetails.keys.empty': 'No hay claves disponibles',
|
||||
'hostDetails.certs.search': 'Buscar certificados...',
|
||||
'hostDetails.certs.empty': 'No hay certificados disponibles',
|
||||
'hostDetails.agentForwarding': 'Reenviar el agente SSH',
|
||||
'hostDetails.agentForwarding.desc': 'Permite que el servidor remoto use tus claves SSH locales (p. ej., para operaciones de git)',
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'El agente SSH no está disponible',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': 'No se detectó ningún agente SSH. Habilita el agente de autenticación de OpenSSH en los servicios de Windows, o usa un agente compatible como Bitwarden, 1Password o gpg-agent.',
|
||||
'hostDetails.section.agentForwarding': 'Agente SSH',
|
||||
'hostDetails.x11Forwarding': 'Reenviar aplicaciones X11',
|
||||
'hostDetails.x11Forwarding.desc': 'Muestra las aplicaciones gráficas remotas en tu escritorio local cuando se ejecuta un servidor X local.',
|
||||
'hostDetails.section.x11Forwarding': 'Reenvío X11',
|
||||
'hostDetails.section.deviceType': 'Tipo de dispositivo',
|
||||
'hostDetails.deviceType': 'Modo de dispositivo de red',
|
||||
'hostDetails.deviceType.desc': 'Habilítalo para equipos de red (switches, routers, firewalls) conectados mediante SSH. Los comandos se envían tal cual, sin envoltura de shell, compatible con las CLI de proveedores como Huawei VRP y Cisco IOS.',
|
||||
'hostDetails.deviceType.warning': 'Los comandos del agente de IA se enviarán directamente sin seguimiento del código de salida. Habilítalo solo para dispositivos que no ejecutan un shell estándar.',
|
||||
'hostDetails.section.sshAlgorithms': 'Algoritmos SSH',
|
||||
'hostDetails.section.terminalBehavior': 'Comportamiento de la terminal',
|
||||
'hostDetails.lineTimestamps': 'Mostrar marcas de tiempo de salida',
|
||||
'hostDetails.lineTimestamps.desc': 'Muestra la hora local junto a las líneas de salida visibles de este host sin cambiar el texto de la terminal.',
|
||||
'hostDetails.legacyAlgorithms': 'Permitir algoritmos heredados',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Habilita algoritmos SSH obsoletos (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) para conectar con equipos de red más antiguos.',
|
||||
'hostDetails.legacyAlgorithms.warning': 'Estos algoritmos tienen debilidades de seguridad conocidas. Habilítalos solo para dispositivos heredados que no admiten criptografía moderna.',
|
||||
'hostDetails.skipEcdsaHostKey': 'Omitir la clave de host ECDSA',
|
||||
'hostDetails.skipEcdsaHostKey.desc': 'Algunos switches antiguos de Huawei / Cisco generan firmas de clave de host ECDSA no estándar que provocan "signature verification failed". Al activar esto, se eliminan todos los ecdsa-sha2-* de la oferta del cliente para que la negociación recurra a RSA / Ed25519.',
|
||||
'hostDetails.algorithms.advanced': 'Anulaciones avanzadas de algoritmos',
|
||||
'hostDetails.algorithms.advanced.desc': 'Reemplaza la lista de algoritmos ofrecidos para cualquier categoría por cada host. Dejar una categoría sin cambios usa la predeterminada; seleccionar un subconjunto reemplaza por completo la lista predeterminada. Los valores incorrectos pueden hacer que el host sea inalcanzable.',
|
||||
'hostDetails.algorithms.inheritedNotice': 'El grupo actual tiene anulaciones de algoritmos configuradas para: {categories}. El botón "Restablecer" aquí recurre a las listas del grupo, no a las predeterminadas de Netcatty. Para ignorar la restricción del grupo, borra la anulación en la configuración de algoritmos del grupo.',
|
||||
'hostDetails.algorithms.customized': 'personalizado',
|
||||
'hostDetails.algorithms.reset': 'Restablecer',
|
||||
'hostDetails.algorithms.category.kex': 'Intercambio de claves (KEX)',
|
||||
'hostDetails.algorithms.category.cipher': 'Cifrado',
|
||||
'hostDetails.algorithms.category.hmac': 'MAC (HMAC)',
|
||||
'hostDetails.algorithms.category.serverHostKey': 'Clave de host',
|
||||
'hostDetails.algorithms.category.compress': 'Compresión',
|
||||
'hostDetails.section.keepalive': 'Keepalive',
|
||||
'hostDetails.keepalive.override': 'Anular el keepalive global',
|
||||
'hostDetails.keepalive.desc': 'Usa una política de keepalive personalizada para este host en lugar de la configuración global. Útil para routers o switches antiguos cuyo servidor SSH no responde a las solicitudes keepalive@openssh.com: establece el intervalo en 0 para deshabilitar el keepalive por completo en este host.',
|
||||
'hostDetails.keepalive.interval': 'Intervalo (segundos)',
|
||||
'hostDetails.keepalive.countMax': 'Máximo de keepalives sin respuesta',
|
||||
'hostDetails.keepalive.disabledHint': 'Intervalo = 0 deshabilita el keepalive para este host. La sesión dependerá de los tiempos de espera a nivel de TCP para detectar una conexión caída.',
|
||||
'hostDetails.section.sshTimeouts': 'Tiempos de espera de conexión SSH',
|
||||
'hostDetails.sshTimeouts.tcpConnect': 'Tiempo de espera de TCP',
|
||||
'hostDetails.sshTimeouts.tcpConnect.desc': 'Segundos de espera para la conexión de red a este host.',
|
||||
'hostDetails.sshTimeouts.authReady': 'Tiempo de espera de autenticación SSH',
|
||||
'hostDetails.sshTimeouts.authReady.desc': 'Segundos de espera después de conectar para que terminen la negociación y la autenticación SSH.',
|
||||
'hostDetails.backspaceBehavior': 'Comportamiento de la tecla Retroceso',
|
||||
'hostDetails.backspaceBehavior.default': 'Predeterminado',
|
||||
'hostDetails.jumpHosts': 'Proxy mediante hosts',
|
||||
'hostDetails.jumpHosts.hops': '{count} salto(s)',
|
||||
'hostDetails.jumpHosts.direct': 'Directo',
|
||||
'hostDetails.jumpHosts.configure': 'Configurar hosts proxy',
|
||||
'hostDetails.proxy': 'Proxy mediante HTTP/SOCKS5/Comando',
|
||||
'hostDetails.proxy.none': 'Ninguno',
|
||||
'hostDetails.proxy.edit': 'Editar proxy',
|
||||
'hostDetails.proxy.configure': 'Configurar proxy',
|
||||
'hostDetails.proxyPanel.title': 'Proxy mediante HTTP/SOCKS5/Comando',
|
||||
'hostDetails.proxyPanel.hostPlaceholder': 'Host del proxy',
|
||||
'hostDetails.proxyPanel.command': 'ProxyCommand',
|
||||
'hostDetails.proxyPanel.commandPlaceholder': 'cloudflared access ssh --hostname %h',
|
||||
'hostDetails.proxyPanel.commandHelp': 'Usa %h para el host de destino, %p para el puerto de destino y %% para un porcentaje literal.',
|
||||
'hostDetails.proxyPanel.credentials': 'Credenciales',
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': 'Nombre de usuario',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': 'Contraseña',
|
||||
'hostDetails.proxyPanel.identities': 'Identidades',
|
||||
'hostDetails.proxyPanel.keychainIdentity': 'Identidad del llavero',
|
||||
'hostDetails.proxyPanel.manualCredentials': 'Credenciales manuales',
|
||||
'hostDetails.proxyPanel.remove': 'Quitar proxy',
|
||||
'hostDetails.proxyPanel.savedProxy': 'Proxy guardado',
|
||||
'hostDetails.proxyPanel.selectSaved': 'Seleccionar proxy guardado',
|
||||
'hostDetails.proxyPanel.customProxy': 'Proxy personalizado',
|
||||
'hostDetails.proxyPanel.missing': 'Faltante',
|
||||
'hostDetails.proxyPanel.missingSaved': 'Falta el proxy guardado',
|
||||
'hostDetails.proxyPanel.missingIdentity': 'Falta la identidad del llavero',
|
||||
'hostDetails.proxyPanel.incompleteIdentity': 'La identidad del proxy necesita un nombre de usuario y una contraseña',
|
||||
'hostDetails.proxyPanel.unreadableIdentity': 'No se puede leer la contraseña de la identidad del proxy en este dispositivo',
|
||||
'hostDetails.proxyPanel.error.required': 'Se requieren el host y el puerto del proxy, o un ProxyCommand.',
|
||||
'hostDetails.envVars': 'Variables de entorno',
|
||||
'hostDetails.envVars.add': 'Agregar variable de entorno',
|
||||
'hostDetails.envVars.title': 'Variables de entorno',
|
||||
'hostDetails.envVars.desc': 'Establece una variable de entorno para {host}.',
|
||||
'hostDetails.envVars.note':
|
||||
'Algunos servidores SSH por defecto solo permiten variables con el prefijo LC_ y LANG_.',
|
||||
'hostDetails.envVars.variable': 'Variable',
|
||||
'hostDetails.envVars.value': 'Valor',
|
||||
'hostDetails.envVars.newVariable': 'Variable nueva',
|
||||
'hostDetails.envVars.variableName': 'Nombre de la variable',
|
||||
'hostDetails.chain.title': 'Editar cadena',
|
||||
'hostDetails.chain.desc': 'Agregar otro host creará una conexión a {host}.',
|
||||
'hostDetails.chain.addHost': 'Agregar un host',
|
||||
'hostDetails.chain.target': 'Destino',
|
||||
'hostDetails.chain.availableHosts': 'Hosts disponibles',
|
||||
'hostDetails.chain.clear': 'Limpiar',
|
||||
'hostDetails.group.title': 'Grupo nuevo',
|
||||
'hostDetails.group.general': 'General',
|
||||
'hostDetails.group.namePlaceholder': 'Nombre del grupo',
|
||||
'hostDetails.group.parentPlaceholder': 'Grupo padre',
|
||||
'hostDetails.group.cloudSync': 'Sincronización en la nube',
|
||||
'hostDetails.group.addProtocol': 'Agregar protocolo',
|
||||
'hostDetails.startupCommand': 'Comando de inicio',
|
||||
'hostDetails.startupCommand.placeholder': 'Comando para ejecutar al conectar (p. ej., cd /app && ls)',
|
||||
'hostDetails.startupCommand.help':
|
||||
'Este comando se ejecutará automáticamente después de que se establezca la conexión SSH.',
|
||||
'hostDetails.startupCommand.runMode': 'Ejecución multilínea',
|
||||
'hostDetails.startupCommand.runMode.help': 'Elige cómo se envían los comandos de inicio multilínea después de la conexión.',
|
||||
'hostDetails.startupCommand.runMode.lineDelay': 'Enviar línea por línea',
|
||||
'hostDetails.startupCommand.runMode.paste': 'Enviar todo de una vez',
|
||||
'hostDetails.otherProtocols': 'Otros protocolos',
|
||||
'hostDetails.telnetOn': 'Telnet activado',
|
||||
'hostDetails.port': 'puerto',
|
||||
'hostDetails.telnet.credentials': 'Credenciales',
|
||||
'hostDetails.telnet.identity.placeholder': 'Identidad manual',
|
||||
'hostDetails.telnet.username': 'Nombre de usuario de Telnet',
|
||||
'hostDetails.telnet.password': 'Contraseña de Telnet',
|
||||
'hostDetails.charset.placeholder': 'Codificación (p. ej., UTF-8)',
|
||||
'hostDetails.telnet.add': 'Agregar protocolo Telnet',
|
||||
'hostDetails.telnet.setDefault': 'Conectar con Telnet por defecto',
|
||||
'hostDetails.tags': 'Etiquetas',
|
||||
'hostDetails.group': 'Grupo',
|
||||
'hostDetails.selectGroup': 'Seleccionar grupo',
|
||||
'hostDetails.addTag': 'Agregar una etiqueta...',
|
||||
'hostDetails.createTag': 'Crear etiqueta',
|
||||
'hostDetails.createGroup': 'Crear grupo',
|
||||
'hostDetails.plugin.title': 'Conexión de plugin',
|
||||
'hostDetails.plugin.provider.placeholder': 'Selecciona un proveedor de conexión',
|
||||
'hostDetails.plugin.provider.empty': 'No hay proveedores de conexión instalados',
|
||||
'hostDetails.plugin.authentication.placeholder': 'Selecciona un proveedor de autenticación',
|
||||
'hostDetails.plugin.authentication.none': 'Administrada por el proveedor o sin autenticación',
|
||||
'hostDetails.plugin.credential.placeholder': 'Selecciona una credencial del host',
|
||||
'hostDetails.plugin.credential.none': 'No hay credencial guardada',
|
||||
'hostDetails.plugin.credential.password': 'Identidad de contraseña',
|
||||
'hostDetails.plugin.credential.privateKey': 'Clave privada',
|
||||
'hostDetails.plugin.credential.unavailable': 'Credencial guardada no disponible',
|
||||
'hostDetails.plugin.configuration.label': 'Configuración del proveedor (JSON)',
|
||||
'hostDetails.plugin.configuration.invalid': 'La configuración debe ser un JSON válido.',
|
||||
'hostDetails.plugin.configuration.schemaInvalid': 'La configuración no coincide con el esquema del proveedor.',
|
||||
'hostDetails.plugin.unavailable': 'El proveedor configurado no está disponible. La configuración se conserva hasta que se restaure el plugin.',
|
||||
'hostDetails.plugin.useSsh': 'Usar SSH en su lugar',
|
||||
|
||||
// Host form (legacy modal)
|
||||
'hostForm.title.edit': 'Editar host',
|
||||
'hostForm.title.new': 'Host nuevo',
|
||||
'hostForm.desc.edit': 'Actualiza los detalles de conexión de este host',
|
||||
'hostForm.desc.new': 'Crea una nueva entrada de host SSH',
|
||||
'hostForm.field.label': 'Etiqueta',
|
||||
'hostForm.placeholder.label': 'Mi servidor de producción',
|
||||
'hostForm.field.hostname': 'Nombre de host / IP',
|
||||
'hostForm.placeholder.hostname': '192.168.1.1',
|
||||
'hostForm.field.port': 'Puerto',
|
||||
'hostForm.field.username': 'Nombre de usuario',
|
||||
'hostForm.field.osType': 'Tipo de SO',
|
||||
'hostForm.placeholder.selectOs': 'Seleccionar SO',
|
||||
'hostForm.field.group': 'Grupo',
|
||||
'hostForm.placeholder.group': 'p. ej., AWS, DigitalOcean',
|
||||
'hostForm.field.tags': 'Etiquetas',
|
||||
'hostForm.placeholder.addTag': 'Agregar una etiqueta...',
|
||||
'hostForm.auth.method': 'Método de autenticación',
|
||||
'hostForm.auth.password': 'Contraseña',
|
||||
'hostForm.auth.sshKey': 'Clave SSH',
|
||||
'hostForm.auth.selectKey': 'Seleccionar una clave SSH',
|
||||
'hostForm.auth.noKeys': 'No hay claves disponibles',
|
||||
'hostForm.auth.noKeysHint': 'No se encontraron claves SSH en el llavero. Crea una primero.',
|
||||
'hostForm.saveHost': 'Guardar host',
|
||||
|
||||
// Connection logs
|
||||
'logs.table.date': 'Fecha',
|
||||
'logs.table.user': 'Usuario',
|
||||
'logs.table.host': 'Host',
|
||||
'logs.table.saved': 'Guardado',
|
||||
'logs.empty.title': 'No hay registros de conexión',
|
||||
'logs.empty.desc':
|
||||
'Tu historial de conexiones aparecerá aquí cuando te conectes a hosts o abras terminales locales.',
|
||||
'logs.loadMore': 'Cargar {count} registros más',
|
||||
'logs.ongoing': 'en curso',
|
||||
'logs.localTerminal': 'Terminal local',
|
||||
'logs.action.save': 'Guardar',
|
||||
'logs.action.unsave': 'Quitar de guardados',
|
||||
'logs.action.delete': 'Eliminar',
|
||||
|
||||
// Log view
|
||||
'logView.customizeAppearance': 'Personalizar la apariencia',
|
||||
'logView.appearance': 'Apariencia',
|
||||
'logView.readOnly': 'Solo lectura',
|
||||
'logView.export': 'Exportar',
|
||||
'vault.import.plugins.title': 'Importadores de plugins',
|
||||
'vault.import.plugins.loading': 'Generando una vista previa de importación segura…',
|
||||
'vault.import.plugins.progress': '{completed} de {total} procesados {message}',
|
||||
'vault.import.plugins.preview': 'Vista previa de importación',
|
||||
'vault.import.plugins.empty': 'No se devolvieron borradores importables.',
|
||||
'vault.import.plugins.summary': '{parsed} borradores · {warnings} advertencias · {errors} errores',
|
||||
'vault.import.plugins.commit': 'Vista previa de importación',
|
||||
'vault.import.plugins.fileUnavailable': 'El archivo seleccionado no está disponible para el bridge del importador de escritorio.',
|
||||
'vault.import.plugins.notRecognized': 'Este importador no reconoció el archivo seleccionado.',
|
||||
'vault.import.plugins.containsErrors': 'La vista previa de importación contiene errores y no se puede confirmar.',
|
||||
'vault.import.plugins.committed': 'Se importaron {count} elementos validados.',
|
||||
'vault.import.plugins.duplicates': '{count} elementos duplicados se omitirán.',
|
||||
'vault.import.plugins.validationErrors': '{count} borradores fallaron la validación de host y deben corregirse mediante el importador.',
|
||||
'vault.import.plugins.kind.host': 'Host',
|
||||
'vault.import.plugins.kind.identity': 'Identidad',
|
||||
'vault.import.plugins.kind.key': 'Clave',
|
||||
'vault.import.plugins.kind.snippet': 'Fragmento',
|
||||
'vault.import.plugins.kind.group': 'Grupo',
|
||||
'vault.import.plugins.moreItems': '{count} elementos más están incluidos en esta importación.',
|
||||
'vault.import.plugins.moreDiagnostics': '{count} diagnósticos más se omitieron de esta vista previa.',
|
||||
|
||||
};
|
||||
@@ -34,6 +34,7 @@ export const ruAiMessages: Messages = {
|
||||
'ai.providers.apiKey.placeholder': 'Введите API-ключ',
|
||||
'ai.providers.apiKey.decrypting': 'Расшифровка...',
|
||||
'ai.providers.baseUrl': 'Базовый URL',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Anthropic-совместимый: хост с /v1 или без (например https://gateway.example или https://gateway.example/v1). Проверка и чат используют /v1/models и /v1/messages.',
|
||||
'ai.providers.skipTLSVerify': 'Пропустить проверку TLS-сертификата (для самоподписанных сертификатов)',
|
||||
'ai.providers.defaultModel': 'Модель по умолчанию',
|
||||
'ai.providers.defaultModel.placeholder': 'например, gpt-4o, claude-sonnet-4-20250514',
|
||||
@@ -42,6 +43,15 @@ export const ruAiMessages: Messages = {
|
||||
'ai.providers.contextWindow.help': 'Оставьте пустым, чтобы использовать значение из списка моделей, если оно доступно; иначе Netcatty применит безопасное значение по умолчанию.',
|
||||
'ai.providers.contextWindow.error': 'Введите положительное целое число или оставьте поле пустым.',
|
||||
'ai.providers.refreshModels': 'Обновить модели',
|
||||
'ai.providers.test': 'Проверить',
|
||||
'ai.providers.test.testing': 'Проверка…',
|
||||
'ai.providers.test.ok': 'Подключено ({latency} ms)',
|
||||
'ai.providers.test.warn': 'Эндпоинт доступен, но ответ выглядит неполным ({latency} ms)',
|
||||
'ai.providers.test.warnSlow': 'Подключено, но медленно ({latency} ms)',
|
||||
'ai.providers.test.error': 'Ошибка ({detail})',
|
||||
'ai.providers.test.missingBaseUrl': 'Сначала укажите Base URL',
|
||||
'ai.providers.test.missingApiKey': 'Сначала укажите API-ключ',
|
||||
'ai.providers.test.unavailable': 'Проверка соединения недоступна в этой среде',
|
||||
'ai.providers.searchModel': 'Искать или ввести ID модели...',
|
||||
'ai.providers.filterModels': 'Фильтровать модели...',
|
||||
'ai.providers.loadingModels': 'Загрузка моделей...',
|
||||
@@ -356,6 +366,14 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.reject': 'Отклонить',
|
||||
'ai.chat.toolLabel': 'Инструмент',
|
||||
'ai.chat.targetLabel': 'Цель',
|
||||
'ai.chat.rawCommand': 'Команда',
|
||||
'ai.chat.copyCommand': 'Копировать',
|
||||
'ai.chat.commandCopied': 'Скопировано',
|
||||
'ai.chat.approvalSession': 'Сессия',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': 'Каталог',
|
||||
'ai.chat.approvalReason': 'Причина',
|
||||
'ai.chat.approvalInvocation': 'Вызов',
|
||||
'ai.chat.permissionRequired': 'Требуется разрешение',
|
||||
'ai.chat.permissionDescription': 'AI-агент хочет выполнить вызов инструмента, для которого требуется ваше одобрение.',
|
||||
'ai.chat.commandBlocked': 'Эта команда заблокирована вашей политикой безопасности и не может быть выполнена.',
|
||||
@@ -395,6 +413,8 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.newChat': 'Новый чат',
|
||||
'ai.chat.allSessions': 'Все сессии',
|
||||
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
|
||||
'ai.chat.jumpNav': 'Перейти к сообщению',
|
||||
'ai.chat.jumpUntitled': '(пустое сообщение)',
|
||||
'ai.chat.usedTools': 'Использовано инструментов: {n}',
|
||||
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
|
||||
'ai.chat.noSessions': 'Предыдущих сессий нет',
|
||||
|
||||
@@ -82,14 +82,44 @@ export const ruCoreMessages: Messages = {
|
||||
'credentials.protectionUnavailable.message': 'Сохранённые пароли и ключи не могут быть автоматически расшифрованы на этом устройстве. Перед подключением введите учётные данные заново.',
|
||||
'credentials.protectionUnavailable.action': 'Открыть настройки',
|
||||
|
||||
// App Lock
|
||||
'appLock.title': 'Netcatty заблокирован',
|
||||
'appLock.reason.default': 'Введите пароль блокировки, чтобы продолжить.',
|
||||
'appLock.reason.startup': 'Netcatty блокируется при каждом открытии этого окна.',
|
||||
'appLock.reason.idle': 'Netcatty заблокирован после периода бездействия.',
|
||||
'appLock.reason.manual': 'Netcatty был заблокирован вручную.',
|
||||
'appLock.passwordLabel': 'Пароль блокировки',
|
||||
'appLock.passwordPlaceholder': 'Введите пароль для разблокировки',
|
||||
'appLock.unlock': 'Разблокировать',
|
||||
'appLock.unlocking': 'Разблокировка...',
|
||||
'appLock.usePassword': 'Использовать пароль',
|
||||
'appLock.error.emptyPassword': 'Введите пароль блокировки.',
|
||||
'appLock.error.incorrectPassword': 'Неверный пароль блокировки.',
|
||||
'appLock.systemUnlock.unlockWith': 'Разблокировать через {label}',
|
||||
'appLock.systemUnlock.preparing': 'Подготовка {label}...',
|
||||
'appLock.systemUnlock.verifying': 'Ожидание {label}...',
|
||||
'appLock.systemUnlock.error': 'Системная разблокировка не завершена. Используйте пароль блокировки, чтобы продолжить.',
|
||||
'appLock.logoLabel': 'Netcatty',
|
||||
'appLock.reset.title': 'Сбросить блокировку',
|
||||
'appLock.reset.description': 'Это удалит текущий пароль блокировки и отключит App Lock. Новый пароль можно создать позже в настройках.',
|
||||
'appLock.reset.cancel': 'Отмена',
|
||||
'appLock.reset.confirm': 'Сбросить App Lock',
|
||||
'appLock.reset.resetting': 'Сброс...',
|
||||
'appLock.reset.error': 'Не удалось сбросить App Lock. Попробуйте ещё раз.',
|
||||
'topTabs.lockApp': 'Заблокировать Netcatty',
|
||||
|
||||
// Settings shell
|
||||
'settings.title': 'Настройки',
|
||||
'settings.tab.application': 'Приложение',
|
||||
'settings.tab.appearance': 'Внешний вид',
|
||||
'settings.tab.terminal': 'Терминал',
|
||||
'settings.tab.shortcuts': 'Горячие клавиши',
|
||||
'settings.tab.ai': 'AI',
|
||||
'settings.tab.syncCloud': 'Синхронизация и облако',
|
||||
'settings.tab.system': 'Система',
|
||||
'settings.search.open': 'Поиск настроек',
|
||||
'settings.search.placeholder': 'Искать настройки…',
|
||||
'settings.search.noResults': 'Совпадений не найдено',
|
||||
|
||||
// Settings > System
|
||||
'settings.system.title': 'Система',
|
||||
@@ -113,6 +143,59 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.system.credentials.unavailableHint': 'Учётные данные, зашифрованные в другом профиле пользователя или на другой машине, здесь расшифровать нельзя. Повторно введите и сохраните их на этом устройстве.',
|
||||
'settings.system.credentials.portabilityHint': 'Облачная синхронизация переносима, потому что использует шифрование вашим мастер-ключом. Локальное шифрование safeStorage привязано к устройству и пользователю.',
|
||||
|
||||
// Settings > System > App Lock
|
||||
'settings.appLock.title': 'Блокировка приложения',
|
||||
'settings.appLock.description': 'Требовать локальный пароль блокировки при открытии Netcatty, после бездействия или при ручной блокировке.',
|
||||
'settings.appLock.setupTitle': 'Задайте пароль блокировки',
|
||||
'settings.appLock.setupDescription': 'Создайте пароль ниже. Блокировка приложения включится сразу после сохранения.',
|
||||
'settings.appLock.manageTitle': 'Блокировка приложения готова',
|
||||
'settings.appLock.enabledStatus': 'Включена. Netcatty блокируется при запуске, после бездействия и при ручной блокировке.',
|
||||
'settings.appLock.disabledStatus': 'Пароль сохранён, но блокировка приложения сейчас выключена.',
|
||||
'settings.appLock.disableTitle': 'Отключить блокировку приложения',
|
||||
'settings.appLock.disableDescription': 'Введите текущий пароль блокировки, чтобы Netcatty перестал блокироваться. Сохранённый пароль будет удалён; для повторного включения нужно создать новый.',
|
||||
'settings.appLock.disable': 'Отключить блокировку',
|
||||
'settings.appLock.disabling': 'Отключение...',
|
||||
'settings.appLock.setupPasswordTitle': 'Создайте пароль блокировки',
|
||||
'settings.appLock.setupPasswordDescription': 'Этот пароль нужен для разблокировки Netcatty, а также для изменения или отключения блокировки позже.',
|
||||
'settings.appLock.changePasswordTitle': 'Изменить пароль блокировки',
|
||||
'settings.appLock.changePasswordDescription': 'Введите текущий пароль блокировки, затем выберите новый.',
|
||||
'settings.appLock.enable': 'Включить блокировку приложения',
|
||||
'settings.appLock.enableDesc': 'Блокировать при запуске и после выбранного периода бездействия.',
|
||||
'settings.appLock.timeout': 'Тайм-аут бездействия',
|
||||
'settings.appLock.timeoutDesc': 'Блокировать Netcatty, если нет активности клавиатуры, указателя, колеса, касания или фокуса.',
|
||||
'settings.appLock.timeout.0': 'Не блокировать автоматически',
|
||||
'settings.appLock.timeout.1': '1 минута',
|
||||
'settings.appLock.timeout.5': '5 минут',
|
||||
'settings.appLock.timeout.15': '15 минут',
|
||||
'settings.appLock.timeout.30': '30 минут',
|
||||
'settings.appLock.timeout.60': '60 минут',
|
||||
'settings.appLock.systemUnlock.label': 'Разблокировка через {label}',
|
||||
'settings.appLock.systemUnlock.desc': 'Используйте {label} как альтернативный способ разблокировать Netcatty на этом устройстве.',
|
||||
'settings.appLock.systemUnlock.unavailableDesc': '{label} сейчас недоступен на этом устройстве.',
|
||||
'settings.appLock.systemUnlock.unavailable': 'Системная разблокировка недоступна на этом устройстве.',
|
||||
'settings.appLock.systemUnlock.locked': 'Разблокируйте Netcatty перед изменением системной разблокировки.',
|
||||
'settings.appLock.systemUnlock.autoPrompt.label': 'Открывать {label} автоматически',
|
||||
'settings.appLock.systemUnlock.autoPrompt.desc': 'Автоматически показывать запрос {label}, когда Netcatty ожидает разблокировки.',
|
||||
'settings.appLock.currentPassword': 'Текущий пароль',
|
||||
'settings.appLock.currentPasswordPlaceholder': 'Нужен для отключения или замены',
|
||||
'settings.appLock.currentPasswordForDisablePlaceholder': 'Текущий пароль для отключения блокировки',
|
||||
'settings.appLock.currentPasswordForChangePlaceholder': 'Текущий пароль для изменения',
|
||||
'settings.appLock.newPassword': 'Новый пароль',
|
||||
'settings.appLock.newPasswordPlaceholder': 'Задайте пароль блокировки',
|
||||
'settings.appLock.confirmPassword': 'Подтвердите пароль',
|
||||
'settings.appLock.confirmPasswordPlaceholder': 'Повторите пароль',
|
||||
'settings.appLock.savePassword': 'Задать и включить',
|
||||
'settings.appLock.savingPassword': 'Сохранение...',
|
||||
'settings.appLock.passwordSet': 'Пароль настроен',
|
||||
'settings.appLock.replacePassword': 'Заменить пароль',
|
||||
'settings.appLock.enableAfterPassword': 'Задайте пароль блокировки перед включением.',
|
||||
'settings.appLock.localOnlyHint': 'Локальная приватная блокировка. Она не синхронизируется и не является системной границей безопасности.',
|
||||
'settings.appLock.validation.currentRequired': 'Введите текущий пароль блокировки.',
|
||||
'settings.appLock.validation.newRequired': 'Введите новый пароль блокировки.',
|
||||
'settings.appLock.validation.confirmRequired': 'Подтвердите новый пароль блокировки.',
|
||||
'settings.appLock.validation.mismatch': 'Новые пароли не совпадают.',
|
||||
'settings.appLock.validation.incorrect': 'Текущий пароль блокировки неверен.',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': 'Сетевой прокси',
|
||||
'settings.system.networkProxy.description': 'HTTP(S)-прокси для облачной синхронизации и AI-провайдеров. Не связан с SSH ProxyJump / ProxyCommand.',
|
||||
@@ -185,6 +268,9 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.sessionLogs.timestamps': 'Добавлять метки времени',
|
||||
'settings.sessionLogs.timestampsDesc': 'Добавлять локальное время в начало каждой строки в текстовых и HTML-журналах.',
|
||||
'settings.sessionLogs.hint': 'Журналы сессий сохраняют весь вывод терминала для диагностики и аудита.',
|
||||
'settings.sessionLogs.clearAll': 'Очистить все журналы',
|
||||
'settings.sessionLogs.clearAllDesc': 'Удалить все сохранённые файлы журналов сессий и вложенные папки хостов из папки сохранения.',
|
||||
'settings.sessionLogs.clearConfirm': 'Это приведёт к безвозвратному удалению всех файлов журналов сессий в папке сохранения. Это действие нельзя отменить. Продолжить?',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
'settings.sshDebugLogs.title': 'Отладочные журналы SSH',
|
||||
@@ -219,6 +305,7 @@ export const ruCoreMessages: Messages = {
|
||||
'tray.status.active': 'Активно',
|
||||
'tray.status.inactive': 'Неактивно',
|
||||
'tray.status.error': 'Ошибка',
|
||||
'tray.status.unknown': 'Неизвестно',
|
||||
'tray.recentHosts': 'Недавние хосты',
|
||||
'tray.empty.title': 'Пока здесь ничего нет',
|
||||
'tray.empty.subtitle': 'Подключитесь к серверу, они по вам скучают 🚀',
|
||||
@@ -559,6 +646,12 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.behavior.dynamicTabTitle.off': 'Отключено',
|
||||
'settings.terminal.behavior.dynamicTabTitle.agent': 'Только агенты',
|
||||
'settings.terminal.behavior.dynamicTabTitle.all': 'Все сеансы',
|
||||
'settings.terminal.behavior.oscNotifications': 'Системные уведомления OSC',
|
||||
'settings.terminal.behavior.oscNotifications.desc':
|
||||
'Показывать системное уведомление, когда программа отправляет OSC 9, OSC 777 notify или kitty OSC 99. Так Codex и другие агенты сообщают о завершении хода и запросе подтверждения.',
|
||||
'settings.terminal.behavior.oscNotifications.off': 'Отключено',
|
||||
'settings.terminal.behavior.oscNotifications.unfocused': 'Когда окно не в фокусе',
|
||||
'settings.terminal.behavior.oscNotifications.always': 'Всегда',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'Буфер обмена OSC-52',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'Разрешить удалённым программам (tmux, vim и т. д.) доступ к локальному буферу обмена через escape-последовательности OSC-52.',
|
||||
@@ -726,6 +819,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.shortcuts.disableTerminalFontZoom.desc': 'Отключает быстрый масштаб текста в терминале, включая Cmd/Ctrl + колесо мыши.',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Сочетания без закреплённых вкладок',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'Если включено, Cmd/Ctrl+[1...9] и Ctrl+Tab переключают только рабочие вкладки (терминалы, рабочие области, редакторы), а не закреплённые Vault и SFTP.',
|
||||
'settings.shortcuts.showTabNumberBadges.label': 'Номера на вкладках',
|
||||
'settings.shortcuts.showTabNumberBadges.desc': 'Показывать 1-9 на вкладках при удержании Cmd/Ctrl, чтобы точнее переключаться через Cmd/Ctrl+[1...9].',
|
||||
'settings.shortcuts.section.custom': 'Пользовательские сочетания',
|
||||
'settings.shortcuts.resetAll': 'Сбросить все',
|
||||
'settings.shortcuts.recording': 'Нажмите клавиши...',
|
||||
|
||||
@@ -18,8 +18,11 @@ export const ruScriptsMessages = {
|
||||
'scripts.editor.modalSubtitle': 'Редактируйте метаданные и код скрипта в большом рабочем пространстве.',
|
||||
'scripts.editor.lineCount': '{count} строк',
|
||||
'scripts.editor.resize': 'Изменить размер редактора',
|
||||
'scripts.targets.hint': 'Автоматические триггеры применяются к выбранным хостам или ко всем хостам, если включена эта настройка.',
|
||||
'scripts.targets.hint': 'Выбранные группы разрешаются динамически, поэтому новые хосты включаются автоматически.',
|
||||
'scripts.targets.connectOrderHint': 'Порядок запуска скриптов подключения настраивается для каждого хоста в разделе «Сведения о хосте → Автоматизация».',
|
||||
'scripts.targets.currentHostMismatch': 'Этот скрипт не назначен текущему хосту.',
|
||||
'hostDetails.automation.groupScripts': 'Унаследованные скрипты группы',
|
||||
'hostDetails.automation.groupScriptsHint': 'Эти скрипты динамически следуют за группой хоста и выполняются перед очередью хоста.',
|
||||
'scripts.actions.runNowHint': 'Запускается для выбранных целей либо для всех доступных для подключения хостов, если включена эта настройка.',
|
||||
'scripts.actions.runParallel': 'Запустить на всех вкладках (параллельно)',
|
||||
'scripts.actions.runSequential': 'Запустить на всех вкладках (последовательно)',
|
||||
|
||||
@@ -8,8 +8,12 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.empty': 'Нет данных.',
|
||||
'systemManager.tabs.overview': 'Обзор',
|
||||
'systemManager.tabs.processes': 'Процессы',
|
||||
'systemManager.tabs.ports': 'Порты',
|
||||
'systemManager.tabs.services': 'Службы',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.tabs.ariaLabel': 'Разделы системного менеджера',
|
||||
'systemManager.popup.loading': 'Открытие терминала…',
|
||||
'systemManager.popup.startupFailed': 'Команда запуска не была выполнена успешно. Проверьте, что цель доступна, и повторите попытку.',
|
||||
|
||||
@@ -23,6 +27,9 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
|
||||
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
|
||||
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
|
||||
'systemManager.errors.loadGpu': 'Не удалось загрузить статистику GPU / NPU',
|
||||
'systemManager.errors.loadPorts': 'Не удалось загрузить слушающие порты',
|
||||
'systemManager.errors.loadServices': 'Не удалось загрузить службы systemd',
|
||||
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
|
||||
|
||||
'systemManager.overview.empty': 'Данных обзора пока нет.',
|
||||
@@ -187,6 +194,51 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.start': 'Запустить',
|
||||
'systemManager.docker.stop': 'Остановить',
|
||||
|
||||
'systemManager.ports.unavailable': 'На этом хосте не обнаружены инструменты портов (ss / netstat).',
|
||||
'systemManager.ports.loading': 'Загрузка слушающих портов…',
|
||||
'systemManager.ports.empty': 'Слушающие порты не найдены.',
|
||||
'systemManager.ports.search': 'Поиск портов…',
|
||||
'systemManager.ports.meta': '{{count}} слушающих',
|
||||
'systemManager.ports.filter.all': 'Все',
|
||||
'systemManager.ports.unknownProcess': 'Неизвестный процесс',
|
||||
'systemManager.ports.terminate': 'Завершить',
|
||||
'systemManager.ports.confirmTerminate': 'Отправить SIGTERM процессу {{pid}}, занимающему этот порт?',
|
||||
|
||||
'systemManager.services.unavailable': 'systemctl недоступен на этом хосте.',
|
||||
'systemManager.services.loading': 'Загрузка служб systemd…',
|
||||
'systemManager.services.empty': 'Службы systemd не найдены.',
|
||||
'systemManager.services.search': 'Поиск служб…',
|
||||
'systemManager.services.meta': '{{count}} служб',
|
||||
'systemManager.services.filter.all': 'Все',
|
||||
'systemManager.services.filter.running': 'Запущены',
|
||||
'systemManager.services.filter.failed': 'С ошибкой',
|
||||
'systemManager.services.filter.inactive': 'Неактивны',
|
||||
'systemManager.services.start': 'Запустить',
|
||||
'systemManager.services.stop': 'Остановить',
|
||||
'systemManager.services.restart': 'Перезапустить',
|
||||
'systemManager.services.enable': 'Включить автозапуск',
|
||||
'systemManager.services.disable': 'Отключить автозапуск',
|
||||
'systemManager.services.reload': 'Перезагрузить',
|
||||
'systemManager.services.scope.user': 'user',
|
||||
'systemManager.services.confirmAction': '{{action}} {{name}}?',
|
||||
|
||||
'systemManager.gpu.unavailable': 'На этом хосте не обнаружены инструменты NVIDIA GPU или Ascend NPU.',
|
||||
'systemManager.gpu.loading': 'Загрузка статистики ускорителей…',
|
||||
'systemManager.gpu.empty': 'Инструменты ускорителей найдены, но устройства не сообщены.',
|
||||
'systemManager.gpu.meta': '{{devices}} устройств · {{processes}} процессов',
|
||||
'systemManager.gpu.devices': 'Устройства',
|
||||
'systemManager.gpu.processes': 'Вычислительные процессы',
|
||||
'systemManager.gpu.noProcesses': 'Вычислительные процессы не сообщены.',
|
||||
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
|
||||
'systemManager.gpu.vendor.ascend': 'Ascend',
|
||||
'systemManager.gpu.util': 'Загрузка',
|
||||
'systemManager.gpu.memory': 'VRAM',
|
||||
'systemManager.gpu.hbm': 'HBM',
|
||||
'systemManager.gpu.temperature': 'Температура',
|
||||
'systemManager.gpu.power': 'Питание',
|
||||
'systemManager.gpu.fan': 'Вентилятор {{value}}%',
|
||||
'systemManager.gpu.driver': 'Драйвер {{version}}',
|
||||
|
||||
'systemManager.inspect.status': 'Статус',
|
||||
'systemManager.inspect.image': 'Образ',
|
||||
'systemManager.inspect.created': 'Создан',
|
||||
|
||||
@@ -103,6 +103,10 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.statusbar.copyHostname.tooltip': 'Копировать адрес хоста ({hostname})',
|
||||
'terminal.statusbar.copyHostname.toast': 'Адрес хоста скопирован: {hostname}',
|
||||
'terminal.statusbar.copyHostname.error': 'Не удалось скопировать адрес хоста в буфер обмена',
|
||||
'terminal.statusbar.disconnect.label': 'Отключить',
|
||||
'terminal.statusbar.disconnect.tooltip': 'Отключить сессию, не закрывая вкладку',
|
||||
'terminal.statusbar.reconnect.label': 'Переподключиться',
|
||||
'terminal.statusbar.reconnect.tooltip': 'Переподключить эту сессию',
|
||||
'terminal.serverStats.cpu': 'Использование CPU',
|
||||
'terminal.serverStats.cpuCores': 'Использование ядер CPU',
|
||||
'terminal.serverStats.memory': 'Использование памяти',
|
||||
@@ -677,6 +681,9 @@ export const ruTerminalMessages: Messages = {
|
||||
'snippets.field.variableDefault': 'по умолчанию {value}',
|
||||
'snippets.targets.title': 'Цели',
|
||||
'snippets.targets.add': 'Добавить цели',
|
||||
'snippets.targets.selectHosts': 'Хосты',
|
||||
'snippets.targets.selectGroups': 'Группы',
|
||||
'snippets.targets.noGroups': 'Группы не найдены',
|
||||
'snippets.history.title': 'История оболочки',
|
||||
'snippets.history.subtitle': '{count} команд',
|
||||
'snippets.history.emptyTitle': 'История оболочки пока пуста',
|
||||
@@ -758,7 +765,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'snippets.shortkey.recording': 'Нажмите сочетание клавиш...',
|
||||
'snippets.shortkey.hint': 'Нажмите это сочетание в терминале, чтобы быстро отправить команду.',
|
||||
'snippets.shortkey.clear': 'Очистить сочетание',
|
||||
'snippets.shortkey.error.systemConflict': 'Это сочетание конфликтует с системным сочетанием',
|
||||
'snippets.shortkey.error.systemConflict': 'Это сочетание конфликтует с «{name}»',
|
||||
'snippets.shortkey.error.snippetConflict': 'Это сочетание уже используется сниппетом: {name}',
|
||||
|
||||
// Serial Port
|
||||
|
||||
@@ -266,6 +266,10 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.moveTo.confirm': 'Переместить',
|
||||
'sftp.moveTo.pathNotFound': 'Каталог не найден или недоступен',
|
||||
'sftp.context.download': 'Скачать',
|
||||
'sftp.context.extract': 'Распаковать здесь',
|
||||
'sftp.extract.extracting': 'Распаковка {fileName}...',
|
||||
'sftp.extract.success': 'Распакован {fileName}',
|
||||
'sftp.extract.error': 'Не удалось распаковать {fileName}: {error}',
|
||||
'sftp.context.copyToOtherPane': 'Копировать в другую панель',
|
||||
'sftp.copyToOtherPane.unavailable': 'Откройте двухпанельный режим SFTP и подключите панель назначения перед копированием файлов.',
|
||||
'sftp.copyCurrentPath': 'Копировать текущий путь',
|
||||
@@ -305,6 +309,7 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.transfers.calculatingTotal': 'Вычисление общего размера...',
|
||||
'sftp.transfers.filesCount': '{count} файл(ов)',
|
||||
'sftp.transfers.filesProgress': '{current}/{total} файл(ов)',
|
||||
'sftp.transfers.filesDiscoveredProgress': '{completed} готово · {discovered} найдено',
|
||||
'sftp.transfers.expandChildren': 'Показать файлы',
|
||||
'sftp.transfers.collapseChildren': 'Скрыть файлы',
|
||||
'sftp.transfers.expandChildList': 'Показать детали',
|
||||
@@ -360,6 +365,7 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.transfers.dragToResize': 'Перетащите для изменения размера',
|
||||
'sftp.goUp': 'Наверх',
|
||||
'sftp.goToTerminalCwd': 'Перейти в каталог терминала',
|
||||
'sftp.locatePathInTerminal': 'Открыть путь в терминале',
|
||||
'sftp.followTerminalCwd': 'Следовать за каталогом терминала',
|
||||
'sftp.followTerminalCwd.enable': 'Включить следование за каталогом терминала',
|
||||
'sftp.followTerminalCwd.disable': 'Отключить следование за каталогом терминала',
|
||||
@@ -523,7 +529,7 @@ export const ruVaultMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': 'Передать файл на активный хост другой панели',
|
||||
'settings.sshDeepLink.title': 'SSH- и Telnet-ссылки',
|
||||
'settings.sshDeepLink.enable': 'Открывать ssh:// и telnet:// ссылки в Netcatty',
|
||||
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// и telnet:// ссылки из браузеров и других приложений.',
|
||||
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// и telnet:// ссылки из браузеров и других приложений, а также запуски в стиле PuTTY, например -ssh user@host -P 22 -pw password.',
|
||||
'settings.explorerContextMenu.title': 'Проводник Windows',
|
||||
'settings.explorerContextMenu.enable': 'Показывать «Open in Netcatty» в контекстном меню папок',
|
||||
'settings.explorerContextMenu.enableDesc': 'Добавляет пункт в меню папок и фона папки, чтобы открыть локальный терминал в этом каталоге. Применяется сразу, переустановка не нужна.',
|
||||
@@ -597,6 +603,9 @@ export const ruVaultMessages: Messages = {
|
||||
'settings.sftp.compressedUpload.enable': 'Включить сжатие папок',
|
||||
'settings.sftp.compressedUpload.enableDesc': 'Автоматически сжимать папки с помощью tar перед передачей. Требует поддержки tar на сервере. Если она недоступна, будет использована обычная передача.',
|
||||
|
||||
'settings.sftp.skipUnchanged.enable': 'Пропускать неизменённые файлы',
|
||||
'settings.sftp.skipUnchanged.enableDesc': 'Пропускать файлы, у которых размер и время изменения уже совпадают с назначением (в стиле rsync). Отключается при замене папки во временный путь.',
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Поиск хостов или вкладок',
|
||||
'qs.jumpTo': 'Перейти к',
|
||||
|
||||
@@ -5,6 +5,8 @@ import { enSystemManagerMessages } from "./en/systemManager";
|
||||
import { enTerminalMessages } from "./en/terminal";
|
||||
import { ruSystemManagerMessages } from "./ru/systemManager";
|
||||
import { ruTerminalMessages } from "./ru/terminal";
|
||||
import { esSystemManagerMessages } from "./es/systemManager";
|
||||
import { esTerminalMessages } from "./es/terminal";
|
||||
import { zhCnSystemManagerMessages } from "./zh-CN/systemManager";
|
||||
import { zhCNVaultMessages } from "./zh-CN/vault";
|
||||
import { zhTwSystemManagerMessages } from "./zh-TW/systemManager";
|
||||
@@ -14,6 +16,7 @@ test("SSH network latency is explicit in every locale and UI surface", () => {
|
||||
const labels = [
|
||||
[enTerminalMessages, enSystemManagerMessages, "SSH network latency"],
|
||||
[ruTerminalMessages, ruSystemManagerMessages, "Сетевая задержка SSH"],
|
||||
[esTerminalMessages, esSystemManagerMessages, "Latencia de red SSH"],
|
||||
[zhCNVaultMessages, zhCnSystemManagerMessages, "SSH 网络延迟"],
|
||||
[zhTWVaultMessages, zhTwSystemManagerMessages, "SSH 網路延遲"],
|
||||
] as const;
|
||||
|
||||
@@ -2,13 +2,22 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
|
||||
import en from "./en.ts";
|
||||
import { HOST_ICON_COLORS, HOST_ICON_IDS } from "../../../domain/hostIcon.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import ru from "./ru.ts";
|
||||
import es from "./es.ts";
|
||||
|
||||
const LOCALIZED_SETTINGS_LOCALES = [
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "ru", messages: ru },
|
||||
{ name: "es", messages: es },
|
||||
];
|
||||
|
||||
const APP_LOCK_LOCALES = [
|
||||
{ name: "en", messages: en },
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "ru", messages: ru },
|
||||
];
|
||||
|
||||
test("localized settings include names for every default shortcut", () => {
|
||||
@@ -59,6 +68,21 @@ test("localized settings include network proxy labels", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include OSC desktop notification labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.behavior.oscNotifications",
|
||||
"settings.terminal.behavior.oscNotifications.desc",
|
||||
"settings.terminal.behavior.oscNotifications.off",
|
||||
"settings.terminal.behavior.oscNotifications.unfocused",
|
||||
"settings.terminal.behavior.oscNotifications.always",
|
||||
];
|
||||
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing OSC notification labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include terminal font weight option labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.font.weight.thin",
|
||||
@@ -78,6 +102,73 @@ test("localized settings include terminal font weight option labels", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("all app lock strings are translated in every supported locale", () => {
|
||||
const keys = [
|
||||
"appLock.title",
|
||||
"appLock.reason.default",
|
||||
"appLock.reason.startup",
|
||||
"appLock.reason.idle",
|
||||
"appLock.reason.manual",
|
||||
"appLock.passwordLabel",
|
||||
"appLock.passwordPlaceholder",
|
||||
"appLock.unlock",
|
||||
"appLock.unlocking",
|
||||
"appLock.error.emptyPassword",
|
||||
"appLock.error.incorrectPassword",
|
||||
"appLock.systemUnlock.unlockWith",
|
||||
"appLock.systemUnlock.error",
|
||||
"appLock.logoLabel",
|
||||
"appLock.reset.title",
|
||||
"appLock.reset.description",
|
||||
"appLock.reset.cancel",
|
||||
"appLock.reset.confirm",
|
||||
"appLock.reset.resetting",
|
||||
"appLock.reset.error",
|
||||
"topTabs.lockApp",
|
||||
"settings.appLock.title",
|
||||
"settings.appLock.description",
|
||||
"settings.appLock.enable",
|
||||
"settings.appLock.enableDesc",
|
||||
"settings.appLock.timeout",
|
||||
"settings.appLock.timeoutDesc",
|
||||
"settings.appLock.timeout.0",
|
||||
"settings.appLock.timeout.1",
|
||||
"settings.appLock.timeout.5",
|
||||
"settings.appLock.timeout.15",
|
||||
"settings.appLock.timeout.30",
|
||||
"settings.appLock.timeout.60",
|
||||
"settings.appLock.systemUnlock.label",
|
||||
"settings.appLock.systemUnlock.desc",
|
||||
"settings.appLock.systemUnlock.unavailableDesc",
|
||||
"settings.appLock.systemUnlock.unavailable",
|
||||
"settings.appLock.systemUnlock.locked",
|
||||
"settings.appLock.systemUnlock.autoPrompt.label",
|
||||
"settings.appLock.systemUnlock.autoPrompt.desc",
|
||||
"settings.appLock.currentPassword",
|
||||
"settings.appLock.currentPasswordPlaceholder",
|
||||
"settings.appLock.newPassword",
|
||||
"settings.appLock.newPasswordPlaceholder",
|
||||
"settings.appLock.confirmPassword",
|
||||
"settings.appLock.confirmPasswordPlaceholder",
|
||||
"settings.appLock.savePassword",
|
||||
"settings.appLock.savingPassword",
|
||||
"settings.appLock.passwordSet",
|
||||
"settings.appLock.replacePassword",
|
||||
"settings.appLock.enableAfterPassword",
|
||||
"settings.appLock.localOnlyHint",
|
||||
"settings.appLock.validation.currentRequired",
|
||||
"settings.appLock.validation.newRequired",
|
||||
"settings.appLock.validation.confirmRequired",
|
||||
"settings.appLock.validation.mismatch",
|
||||
"settings.appLock.validation.incorrect",
|
||||
];
|
||||
|
||||
for (const locale of APP_LOCK_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing app lock labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized vault messages include host icon labels", () => {
|
||||
const keys = [
|
||||
"hostDetails.icon.title",
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
|
||||
import en from './en.ts';
|
||||
import ru from './ru.ts';
|
||||
import es from './es.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
@@ -14,12 +15,23 @@ const KEYS = [
|
||||
] as const;
|
||||
|
||||
test('snippet bulk-delete copy exists in every locale', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, zhCN, zhTW })) {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
const missing = KEYS.filter((key) => !messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale} is missing snippet bulk-delete copy`);
|
||||
}
|
||||
});
|
||||
|
||||
test('snippet shortkey system-conflict copy names the conflicting action', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
const text = messages['snippets.shortkey.error.systemConflict'];
|
||||
assert.match(
|
||||
text ?? '',
|
||||
/\{name\}/,
|
||||
`${locale} system-conflict copy should include {name}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('English bulk-delete copy is entity-neutral and grammatical for one item', () => {
|
||||
assert.equal(
|
||||
en['snippets.selection.deleteConfirmTitle'].replace('{count}', '1'),
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
|
||||
import en from './en';
|
||||
import ru from './ru';
|
||||
import es from './es';
|
||||
import zhCN from './zh-CN';
|
||||
import zhTW from './zh-TW';
|
||||
|
||||
@@ -14,7 +15,7 @@ const keys = [
|
||||
] as const;
|
||||
|
||||
test('terminal host management strings exist in every shipped locale', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
|
||||
for (const [locale, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
|
||||
for (const key of keys) {
|
||||
assert.equal(typeof messages[key], 'string', `${locale} is missing ${key}`);
|
||||
assert.notEqual(messages[key], '', `${locale} has an empty ${key}`);
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
||||
|
||||
import en from "./en.ts";
|
||||
import ru from "./ru.ts";
|
||||
import es from "./es.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import zhTW from "./zh-TW.ts";
|
||||
|
||||
@@ -29,6 +30,7 @@ const INLINE_IMAGE_KEYS = [
|
||||
|
||||
const LOCALES = [
|
||||
{ name: "en", messages: en },
|
||||
{ name: "es", messages: es },
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "zh-TW", messages: zhTW },
|
||||
{ name: "ru", messages: ru },
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
|
||||
import en from "./en.ts";
|
||||
import ru from "./ru.ts";
|
||||
import es from "./es.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
|
||||
const osc7Keys = [
|
||||
@@ -19,7 +20,7 @@ const osc7Keys = [
|
||||
] as const;
|
||||
|
||||
test("OSC 7 setup copy exists in every bundled locale", () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, zhCN })) {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN })) {
|
||||
for (const key of osc7Keys) {
|
||||
assert.equal(typeof messages[key], "string", `${locale} is missing ${key}`);
|
||||
assert.notEqual(messages[key], "", `${locale} has empty ${key}`);
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
||||
|
||||
import en from "./en.ts";
|
||||
import ru from "./ru.ts";
|
||||
import es from "./es.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import zhTW from "./zh-TW.ts";
|
||||
|
||||
@@ -26,7 +27,7 @@ const KEYS = [
|
||||
] as const;
|
||||
|
||||
test("bulk vault import and group-selection copy exists in every locale", () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, zhCN, zhTW })) {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
const missing = KEYS.filter((key) => !messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale} is missing bulk vault labels`);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.providers.apiKey.placeholder': '输入 API Key',
|
||||
'ai.providers.apiKey.decrypting': '解密中...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Anthropic 兼容:可填不带或带 /v1 的主机(例如 https://gateway.example 或 https://gateway.example/v1)。检测与聊天都会请求 /v1/models、/v1/messages。',
|
||||
'ai.providers.skipTLSVerify': '跳过 TLS 证书验证(用于自签名证书)',
|
||||
'ai.providers.defaultModel': '默认模型',
|
||||
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
|
||||
@@ -61,6 +62,15 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.providers.contextWindow.help': '留空时优先使用模型列表返回的值;如果没有,Netcatty 会使用安全默认值。',
|
||||
'ai.providers.contextWindow.error': '请输入正整数,或留空。',
|
||||
'ai.providers.refreshModels': '刷新模型列表',
|
||||
'ai.providers.test': '检测',
|
||||
'ai.providers.test.testing': '检测中…',
|
||||
'ai.providers.test.ok': '连接正常({latency} ms)',
|
||||
'ai.providers.test.warn': '已连通,但响应不完整({latency} ms)',
|
||||
'ai.providers.test.warnSlow': '已连通,但较慢({latency} ms)',
|
||||
'ai.providers.test.error': '检测失败({detail})',
|
||||
'ai.providers.test.missingBaseUrl': '请先填写 Base URL',
|
||||
'ai.providers.test.missingApiKey': '请先填写 API Key',
|
||||
'ai.providers.test.unavailable': '当前环境无法进行连接检测',
|
||||
'ai.providers.searchModel': '搜索或输入模型 ID...',
|
||||
'ai.providers.filterModels': '筛选模型...',
|
||||
'ai.providers.loadingModels': '加载模型中...',
|
||||
@@ -404,6 +414,14 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.reject': '拒绝',
|
||||
'ai.chat.toolLabel': '工具',
|
||||
'ai.chat.targetLabel': '目标',
|
||||
'ai.chat.rawCommand': '命令',
|
||||
'ai.chat.copyCommand': '复制',
|
||||
'ai.chat.commandCopied': '已复制',
|
||||
'ai.chat.approvalSession': '会话',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': '目录',
|
||||
'ai.chat.approvalReason': '原因',
|
||||
'ai.chat.approvalInvocation': '调用详情',
|
||||
'ai.chat.permissionRequired': '需要权限',
|
||||
'ai.chat.permissionDescription': 'AI Agent 希望执行一个需要你批准的工具调用。',
|
||||
'ai.chat.commandBlocked': '此命令已被安全策略拦截,无法执行。',
|
||||
@@ -443,6 +461,8 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.newChat': '新对话',
|
||||
'ai.chat.allSessions': '所有会话',
|
||||
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
|
||||
'ai.chat.jumpNav': '跳转到消息',
|
||||
'ai.chat.jumpUntitled': '(空消息)',
|
||||
'ai.chat.usedTools': '已使用 {n} 个工具',
|
||||
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
|
||||
'ai.chat.noSessions': '没有历史会话',
|
||||
|
||||
@@ -66,14 +66,44 @@ export const zhCNCoreMessages: Messages = {
|
||||
'credentials.protectionUnavailable.message': '当前设备无法自动解密已保存的密码和密钥。连接前请重新输入凭据。',
|
||||
'credentials.protectionUnavailable.action': '打开设置',
|
||||
|
||||
// App Lock
|
||||
'appLock.title': 'Netcatty 已锁定',
|
||||
'appLock.reason.default': '输入锁定密码以继续。',
|
||||
'appLock.reason.startup': '每次打开此窗口时,Netcatty 都会锁定。',
|
||||
'appLock.reason.idle': 'Netcatty 已因长时间无操作而锁定。',
|
||||
'appLock.reason.manual': 'Netcatty 已被手动锁定。',
|
||||
'appLock.passwordLabel': '锁定密码',
|
||||
'appLock.passwordPlaceholder': '输入密码解锁',
|
||||
'appLock.unlock': '解锁',
|
||||
'appLock.unlocking': '正在解锁...',
|
||||
'appLock.usePassword': '使用密码解锁',
|
||||
'appLock.error.emptyPassword': '请输入锁定密码。',
|
||||
'appLock.error.incorrectPassword': '锁定密码不正确。',
|
||||
'appLock.systemUnlock.unlockWith': '使用 {label} 解锁',
|
||||
'appLock.systemUnlock.preparing': '正在准备 {label}...',
|
||||
'appLock.systemUnlock.verifying': '正在等待 {label}...',
|
||||
'appLock.systemUnlock.error': '系统解锁未完成。请使用锁定密码继续。',
|
||||
'appLock.logoLabel': 'Netcatty',
|
||||
'appLock.reset.title': '重置应用锁定',
|
||||
'appLock.reset.description': '这会删除当前锁定密码并关闭应用锁定。需要再次使用时,请到设置里创建新密码。',
|
||||
'appLock.reset.cancel': '取消',
|
||||
'appLock.reset.confirm': '重置应用锁定',
|
||||
'appLock.reset.resetting': '正在重置...',
|
||||
'appLock.reset.error': '无法重置应用锁定,请重试。',
|
||||
'topTabs.lockApp': '锁定 Netcatty',
|
||||
|
||||
// Settings shell
|
||||
'settings.title': '设置',
|
||||
'settings.tab.application': '应用',
|
||||
'settings.tab.appearance': '外观',
|
||||
'settings.tab.terminal': '终端',
|
||||
'settings.tab.shortcuts': '快捷键',
|
||||
'settings.tab.ai': 'AI',
|
||||
'settings.tab.syncCloud': '同步与云',
|
||||
'settings.tab.system': '系统',
|
||||
'settings.search.open': '搜索设置',
|
||||
'settings.search.placeholder': '搜索设置项…',
|
||||
'settings.search.noResults': '没有匹配的设置项',
|
||||
'settings.terminal.connection.verifyHostKeys': '校验 SSH 主机密钥',
|
||||
'settings.terminal.connection.verifyHostKeys.desc': '连接到新的或已变化的 SSH 主机密钥前先询问。只建议在可信内网中关闭。',
|
||||
'settings.terminal.section.sidePanel': '侧边栏',
|
||||
@@ -111,6 +141,59 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
|
||||
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
|
||||
|
||||
// Settings > System > App Lock
|
||||
'settings.appLock.title': '应用锁定',
|
||||
'settings.appLock.description': '在 Netcatty 打开、长时间无操作或手动锁定后,要求输入本地锁定密码。',
|
||||
'settings.appLock.setupTitle': '设置锁定密码',
|
||||
'settings.appLock.setupDescription': '在下方创建密码,保存后会立即启用应用锁定。',
|
||||
'settings.appLock.manageTitle': '应用锁定已准备就绪',
|
||||
'settings.appLock.enabledStatus': '已启用。Netcatty 会在启动、长时间无操作或手动锁定时要求输入密码。',
|
||||
'settings.appLock.disabledStatus': '密码已保存,但应用锁定当前关闭。',
|
||||
'settings.appLock.disableTitle': '关闭应用锁定',
|
||||
'settings.appLock.disableDescription': '输入当前锁定密码后停止锁定 Netcatty。已保存的密码会被删除;以后重新启用时需要创建新密码。',
|
||||
'settings.appLock.disable': '关闭应用锁定',
|
||||
'settings.appLock.disabling': '正在关闭...',
|
||||
'settings.appLock.setupPasswordTitle': '创建锁定密码',
|
||||
'settings.appLock.setupPasswordDescription': '此密码用于解锁 Netcatty,也用于以后修改或关闭应用锁定。',
|
||||
'settings.appLock.changePasswordTitle': '修改锁定密码',
|
||||
'settings.appLock.changePasswordDescription': '先输入当前锁定密码,再设置新密码。',
|
||||
'settings.appLock.enable': '启用应用锁定',
|
||||
'settings.appLock.enableDesc': '启动时锁定,并在达到所选无操作时间后锁定。',
|
||||
'settings.appLock.timeout': '无操作超时',
|
||||
'settings.appLock.timeoutDesc': '没有键盘、指针、滚轮、触摸或窗口聚焦活动时锁定 Netcatty。',
|
||||
'settings.appLock.timeout.0': '不自动锁定',
|
||||
'settings.appLock.timeout.1': '1 分钟',
|
||||
'settings.appLock.timeout.5': '5 分钟',
|
||||
'settings.appLock.timeout.15': '15 分钟',
|
||||
'settings.appLock.timeout.30': '30 分钟',
|
||||
'settings.appLock.timeout.60': '60 分钟',
|
||||
'settings.appLock.systemUnlock.label': '{label} 解锁',
|
||||
'settings.appLock.systemUnlock.desc': '在此设备上使用 {label} 作为解锁 Netcatty 的另一种方式。',
|
||||
'settings.appLock.systemUnlock.unavailableDesc': '此设备当前无法使用 {label}。',
|
||||
'settings.appLock.systemUnlock.unavailable': '此设备当前无法使用系统解锁。',
|
||||
'settings.appLock.systemUnlock.locked': '请先解锁 Netcatty,再更改系统解锁。',
|
||||
'settings.appLock.systemUnlock.autoPrompt.label': '自动弹出 {label}',
|
||||
'settings.appLock.systemUnlock.autoPrompt.desc': '当 Netcatty 等待解锁时,自动显示 {label} 验证提示。',
|
||||
'settings.appLock.currentPassword': '当前密码',
|
||||
'settings.appLock.currentPasswordPlaceholder': '禁用或替换时需要',
|
||||
'settings.appLock.currentPasswordForDisablePlaceholder': '用于关闭应用锁定的当前密码',
|
||||
'settings.appLock.currentPasswordForChangePlaceholder': '用于修改密码的当前密码',
|
||||
'settings.appLock.newPassword': '新密码',
|
||||
'settings.appLock.newPasswordPlaceholder': '设置锁定密码',
|
||||
'settings.appLock.confirmPassword': '确认密码',
|
||||
'settings.appLock.confirmPasswordPlaceholder': '再次输入密码',
|
||||
'settings.appLock.savePassword': '设置并启用',
|
||||
'settings.appLock.savingPassword': '正在保存...',
|
||||
'settings.appLock.passwordSet': '密码已配置',
|
||||
'settings.appLock.replacePassword': '替换密码',
|
||||
'settings.appLock.enableAfterPassword': '请先设置锁定密码,再启用应用锁定。',
|
||||
'settings.appLock.localOnlyHint': '仅限本设备的隐私锁。不会同步,也不是系统级安全边界。',
|
||||
'settings.appLock.validation.currentRequired': '请输入当前锁定密码。',
|
||||
'settings.appLock.validation.newRequired': '请输入新的锁定密码。',
|
||||
'settings.appLock.validation.confirmRequired': '请确认新的锁定密码。',
|
||||
'settings.appLock.validation.mismatch': '两次输入的新密码不一致。',
|
||||
'settings.appLock.validation.incorrect': '当前锁定密码不正确。',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': '网络代理',
|
||||
'settings.system.networkProxy.description': '用于云同步与 AI 服务商的 HTTP(S) 代理。与 SSH 的 ProxyJump / ProxyCommand 配置无关。',
|
||||
@@ -166,6 +249,10 @@ export const zhCNCoreMessages: Messages = {
|
||||
|
||||
// Settings > Session Restore
|
||||
'settings.sessionRestore.title': '会话恢复',
|
||||
'settings.sessionRestore.startupLanding': '启动时打开',
|
||||
'settings.sessionRestore.startupLandingDesc': '当没有可恢复的会话时,选择启动后进入主机主页还是直接打开本地终端。',
|
||||
'settings.sessionRestore.startupLanding.vault': '主页(主机)',
|
||||
'settings.sessionRestore.startupLanding.localTerminal': '本地终端',
|
||||
'settings.sessionRestore.restorePreviousSession': '恢复上次的标签页和工作区',
|
||||
'settings.sessionRestore.restorePreviousSessionDesc': '启动时重新打开上次的终端标签页和工作区布局。',
|
||||
'settings.sessionRestore.restoreTerminalCwd': '恢复终端工作目录',
|
||||
@@ -194,6 +281,9 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.sessionLogs.timestamps': '添加时间戳',
|
||||
'settings.sessionLogs.timestampsDesc': '为纯文本和 HTML 日志的每一行添加本地时间。',
|
||||
'settings.sessionLogs.hint': '会话日志用于记录终端输出,便于故障排查和审计。',
|
||||
'settings.sessionLogs.clearAll': '清空所有日志',
|
||||
'settings.sessionLogs.clearAllDesc': '删除保存目录中所有会话日志文件及主机子目录。',
|
||||
'settings.sessionLogs.clearConfirm': '此操作将永久删除保存目录中的所有会话日志文件,且无法撤销,是否继续?',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
'settings.sshDebugLogs.title': 'SSH 调试日志',
|
||||
@@ -228,6 +318,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'tray.status.active': '已启用',
|
||||
'tray.status.inactive': '未启用',
|
||||
'tray.status.error': '错误',
|
||||
'tray.status.unknown': '未知',
|
||||
'tray.recentHosts': '最近连接的主机',
|
||||
'tray.empty.title': '一切都很安静',
|
||||
'tray.empty.subtitle': '去连接个服务器吧,它们想念你了 🚀',
|
||||
@@ -821,6 +912,10 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.moveTo.confirm': '移动',
|
||||
'sftp.moveTo.pathNotFound': '目录不存在或无法访问',
|
||||
'sftp.context.download': '下载',
|
||||
'sftp.context.extract': '解压到当前目录',
|
||||
'sftp.extract.extracting': '正在解压 {fileName}...',
|
||||
'sftp.extract.success': '已解压 {fileName}',
|
||||
'sftp.extract.error': '解压 {fileName} 失败:{error}',
|
||||
'sftp.context.copyToOtherPane': '复制到另一侧',
|
||||
'sftp.copyToOtherPane.unavailable': '请打开双栏 SFTP 文件管理,并连接目标侧后再复制文件。',
|
||||
'sftp.copyCurrentPath': '复制当前路径',
|
||||
@@ -860,6 +955,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.transfers.calculatingTotal': '正在统计总大小...',
|
||||
'sftp.transfers.filesCount': '{count} 个文件',
|
||||
'sftp.transfers.filesProgress': '{current}/{total} 个文件',
|
||||
'sftp.transfers.filesDiscoveredProgress': '已完成 {completed} · 已发现 {discovered}',
|
||||
'sftp.transfers.expandChildren': '展开文件',
|
||||
'sftp.transfers.collapseChildren': '收起文件',
|
||||
'sftp.transfers.expandChildList': '展开详情',
|
||||
@@ -915,6 +1011,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.transfers.dragToResize': '拖拽调整高度',
|
||||
'sftp.goUp': '上一级',
|
||||
'sftp.goToTerminalCwd': '定位到终端当前目录',
|
||||
'sftp.locatePathInTerminal': '定位路径到终端',
|
||||
'sftp.followTerminalCwd': '追随终端目录',
|
||||
'sftp.followTerminalCwd.enable': '开启追随终端目录',
|
||||
'sftp.followTerminalCwd.disable': '关闭追随终端目录',
|
||||
|
||||
@@ -18,8 +18,11 @@ export const zhCNScriptsMessages = {
|
||||
'scripts.editor.modalSubtitle': '在更大的工作区中编辑脚本元数据和代码。',
|
||||
'scripts.editor.lineCount': '{count} 行',
|
||||
'scripts.editor.resize': '调整编辑器高度',
|
||||
'scripts.targets.hint': '自动触发作用于所选主机;勾选「对所有主机生效」则作用于全部可连接主机。',
|
||||
'scripts.targets.hint': '所选分组会动态匹配,之后加入分组的主机也会自动生效。',
|
||||
'scripts.targets.connectOrderHint': '连接脚本的执行顺序请在主机详情 → 自动化 中调整。',
|
||||
'scripts.targets.currentHostMismatch': '此脚本未分配给当前主机。',
|
||||
'hostDetails.automation.groupScripts': '分组继承脚本',
|
||||
'hostDetails.automation.groupScriptsHint': '这些脚本动态跟随主机所在分组,并在主机专属队列之前执行。',
|
||||
'scripts.actions.runNowHint': '在所选目标主机上运行;勾选「对所有主机生效」时在所有可连接主机上运行。',
|
||||
'scripts.actions.runParallel': '在所有标签页并行运行',
|
||||
'scripts.actions.runSequential': '在所有标签页顺序运行',
|
||||
|
||||
@@ -8,8 +8,12 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.empty': '暂无数据。',
|
||||
'systemManager.tabs.overview': '概览',
|
||||
'systemManager.tabs.processes': '进程',
|
||||
'systemManager.tabs.ports': '端口',
|
||||
'systemManager.tabs.services': '服务',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.tabs.ariaLabel': '系统管理分区',
|
||||
'systemManager.popup.loading': '正在打开终端…',
|
||||
'systemManager.popup.startupFailed': '启动命令未成功。请确认目标仍然可用后重试。',
|
||||
|
||||
@@ -23,6 +27,9 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
|
||||
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
|
||||
'systemManager.errors.loadOverview': '加载系统概览失败',
|
||||
'systemManager.errors.loadGpu': '加载 GPU / NPU 状态失败',
|
||||
'systemManager.errors.loadPorts': '加载监听端口失败',
|
||||
'systemManager.errors.loadServices': '加载 systemd 服务失败',
|
||||
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
|
||||
|
||||
'systemManager.overview.empty': '暂无系统概览数据。',
|
||||
@@ -187,6 +194,51 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.start': '启动',
|
||||
'systemManager.docker.stop': '停止',
|
||||
|
||||
'systemManager.ports.unavailable': '此主机未检测到端口工具(ss / netstat)。',
|
||||
'systemManager.ports.loading': '正在加载监听端口…',
|
||||
'systemManager.ports.empty': '暂无监听端口。',
|
||||
'systemManager.ports.search': '搜索端口…',
|
||||
'systemManager.ports.meta': '{{count}} 个监听',
|
||||
'systemManager.ports.filter.all': '全部',
|
||||
'systemManager.ports.unknownProcess': '未知进程',
|
||||
'systemManager.ports.terminate': '结束进程',
|
||||
'systemManager.ports.confirmTerminate': '向占用该端口的进程 {{pid}} 发送 SIGTERM?',
|
||||
|
||||
'systemManager.services.unavailable': '此主机未检测到 systemctl。',
|
||||
'systemManager.services.loading': '正在加载 systemd 服务…',
|
||||
'systemManager.services.empty': '暂无 systemd 服务。',
|
||||
'systemManager.services.search': '搜索服务…',
|
||||
'systemManager.services.meta': '{{count}} 个服务',
|
||||
'systemManager.services.filter.all': '全部',
|
||||
'systemManager.services.filter.running': '运行中',
|
||||
'systemManager.services.filter.failed': '失败',
|
||||
'systemManager.services.filter.inactive': '未运行',
|
||||
'systemManager.services.start': '启动',
|
||||
'systemManager.services.stop': '停止',
|
||||
'systemManager.services.restart': '重启',
|
||||
'systemManager.services.enable': '开机启用',
|
||||
'systemManager.services.disable': '禁用开机',
|
||||
'systemManager.services.reload': '重载',
|
||||
'systemManager.services.scope.user': '用户',
|
||||
'systemManager.services.confirmAction': '确认对 {{name}} 执行「{{action}}」?',
|
||||
|
||||
'systemManager.gpu.unavailable': '此主机未检测到 NVIDIA GPU 或昇腾 NPU 工具。',
|
||||
'systemManager.gpu.loading': '正在加载加速卡状态…',
|
||||
'systemManager.gpu.empty': '已检测到加速卡工具,但未返回设备信息。',
|
||||
'systemManager.gpu.meta': '{{devices}} 张卡 · {{processes}} 个进程',
|
||||
'systemManager.gpu.devices': '设备',
|
||||
'systemManager.gpu.processes': '计算进程',
|
||||
'systemManager.gpu.noProcesses': '当前没有计算进程。',
|
||||
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
|
||||
'systemManager.gpu.vendor.ascend': '昇腾',
|
||||
'systemManager.gpu.util': '利用率',
|
||||
'systemManager.gpu.memory': '显存',
|
||||
'systemManager.gpu.hbm': 'HBM',
|
||||
'systemManager.gpu.temperature': '温度',
|
||||
'systemManager.gpu.power': '功耗',
|
||||
'systemManager.gpu.fan': '风扇 {{value}}%',
|
||||
'systemManager.gpu.driver': '驱动 {{version}}',
|
||||
|
||||
'systemManager.inspect.status': '状态',
|
||||
'systemManager.inspect.image': '镜像',
|
||||
'systemManager.inspect.created': '创建时间',
|
||||
|
||||
@@ -131,7 +131,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
|
||||
'settings.sshDeepLink.title': 'SSH 和 Telnet 链接',
|
||||
'settings.sshDeepLink.enable': '用 Netcatty 打开 ssh:// 和 telnet:// 链接',
|
||||
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh://、telnet:// 链接。',
|
||||
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh://、telnet:// 链接,以及堡垒机常用的 PuTTY 启动参数(例如 -ssh user@host -P 22 -pw password)。',
|
||||
'settings.explorerContextMenu.title': 'Windows 资源管理器',
|
||||
'settings.explorerContextMenu.enable': '在文件夹右键菜单中显示 “Open in Netcatty”',
|
||||
'settings.explorerContextMenu.enableDesc': '为文件夹和文件夹空白处添加右键项,可直接在该目录打开本地终端。开关立即生效,无需重装。',
|
||||
@@ -206,6 +206,9 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.sftp.compressedUpload.enable': '启用文件夹压缩',
|
||||
'settings.sftp.compressedUpload.enableDesc': '自动使用 tar 压缩文件夹后再传输。需要服务器支持 tar 命令,不支持时自动回退到普通传输。',
|
||||
|
||||
'settings.sftp.skipUnchanged.enable': '跳过未改文件',
|
||||
'settings.sftp.skipUnchanged.enableDesc': '当目标文件的大小与修改时间已与源一致时跳过传输(类似 rsync)。替换到暂存路径时自动禁用。',
|
||||
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': '终端主题',
|
||||
'settings.terminal.themeModal.title': '选择主题',
|
||||
@@ -335,6 +338,12 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.dynamicTabTitle.off': '关闭',
|
||||
'settings.terminal.behavior.dynamicTabTitle.agent': '仅 Agent',
|
||||
'settings.terminal.behavior.dynamicTabTitle.all': '全部会话',
|
||||
'settings.terminal.behavior.oscNotifications': 'OSC 桌面通知',
|
||||
'settings.terminal.behavior.oscNotifications.desc':
|
||||
'当程序发出 OSC 9、OSC 777 notify 或 kitty OSC 99 时弹出系统通知。Codex 等 Agent 用它提示回合完成或等待审批。',
|
||||
'settings.terminal.behavior.oscNotifications.off': '关闭',
|
||||
'settings.terminal.behavior.oscNotifications.unfocused': '未聚焦时',
|
||||
'settings.terminal.behavior.oscNotifications.always': '始终',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'允许远程程序(tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',
|
||||
@@ -510,6 +519,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.shortcuts.disableTerminalFontZoom.desc': '关闭终端文字缩放快捷操作,包括 Cmd/Ctrl 加滚轮。',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '标签快捷键跳过固定标签',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '开启后,Cmd/Ctrl+[1...9] 和 Ctrl+Tab 仅在终端、工作区、编辑器等可关闭标签页之间切换,不包括固定的 Vault 和 SFTP 标签页。',
|
||||
'settings.shortcuts.showTabNumberBadges.label': '显示标签页序号角标',
|
||||
'settings.shortcuts.showTabNumberBadges.desc': '按住 Cmd/Ctrl 时,在对应 Cmd/Ctrl+[1...9] 的标签页上显示 1-9,方便准确切换。',
|
||||
'settings.shortcuts.section.custom': '自定义快捷键',
|
||||
'settings.shortcuts.resetAll': '全部重置',
|
||||
'settings.shortcuts.recording': '请按键...',
|
||||
@@ -737,8 +748,11 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'snippets.scriptEditor.expand': '弹窗编辑',
|
||||
'snippets.scriptEditor.resize': '调整编辑器高度',
|
||||
'snippets.scriptEditor.modalTitle': '编辑脚本',
|
||||
'snippets.targets.title': '目标主机',
|
||||
'snippets.targets.add': '添加目标主机',
|
||||
'snippets.targets.title': '执行目标',
|
||||
'snippets.targets.add': '添加目标',
|
||||
'snippets.targets.selectHosts': '选择主机',
|
||||
'snippets.targets.selectGroups': '选择分组',
|
||||
'snippets.targets.noGroups': '未找到分组',
|
||||
'snippets.targets.allHosts': '对所有主机生效',
|
||||
'snippets.targets.allHostsShort': '所有主机',
|
||||
'snippets.targets.allHostsActive': '将作用于所有可连接的主机。',
|
||||
@@ -825,7 +839,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'snippets.shortkey.recording': '请按下快捷键组合...',
|
||||
'snippets.shortkey.hint': '在终端中按下此快捷键可快速发送命令。',
|
||||
'snippets.shortkey.clear': '清除快捷键',
|
||||
'snippets.shortkey.error.systemConflict': '此快捷键与系统快捷键冲突',
|
||||
'snippets.shortkey.error.systemConflict': '此快捷键与「{name}」冲突',
|
||||
'snippets.shortkey.error.snippetConflict': '此快捷键已被代码片段使用:{name}',
|
||||
|
||||
'snippets.variables.dialogTitle': '填写变量',
|
||||
|
||||
@@ -427,6 +427,10 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.statusbar.copyHostname.tooltip': '复制主机地址({hostname})',
|
||||
'terminal.statusbar.copyHostname.toast': '已复制主机地址:{hostname}',
|
||||
'terminal.statusbar.copyHostname.error': '复制主机地址失败',
|
||||
'terminal.statusbar.disconnect.label': '断开连接',
|
||||
'terminal.statusbar.disconnect.tooltip': '断开当前会话,但保留标签页',
|
||||
'terminal.statusbar.reconnect.label': '重新连接',
|
||||
'terminal.statusbar.reconnect.tooltip': '重新连接当前会话',
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.memory': '内存使用',
|
||||
|
||||
@@ -53,6 +53,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.providers.apiKey.placeholder': '輸入 API Key',
|
||||
'ai.providers.apiKey.decrypting': '解密中...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Anthropic 相容:可填不帶或帶 /v1 的主機(例如 https://gateway.example 或 https://gateway.example/v1)。偵測與聊天都會請求 /v1/models、/v1/messages。',
|
||||
'ai.providers.skipTLSVerify': '跳過 TLS 憑證驗證(用於自我簽署憑證)',
|
||||
'ai.providers.defaultModel': '預設模型',
|
||||
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
|
||||
@@ -61,6 +62,15 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.providers.contextWindow.help': '留空時優先使用模型列表傳回的值;如果沒有,Netcatty 會使用安全預設值。',
|
||||
'ai.providers.contextWindow.error': '請輸入正整數,或留空。',
|
||||
'ai.providers.refreshModels': '重新整理模型列表',
|
||||
'ai.providers.test': '檢測',
|
||||
'ai.providers.test.testing': '檢測中…',
|
||||
'ai.providers.test.ok': '連線正常({latency} ms)',
|
||||
'ai.providers.test.warn': '已連通,但回應不完整({latency} ms)',
|
||||
'ai.providers.test.warnSlow': '已連通,但較慢({latency} ms)',
|
||||
'ai.providers.test.error': '檢測失敗({detail})',
|
||||
'ai.providers.test.missingBaseUrl': '請先填寫 Base URL',
|
||||
'ai.providers.test.missingApiKey': '請先填寫 API Key',
|
||||
'ai.providers.test.unavailable': '目前環境無法進行連線檢測',
|
||||
'ai.providers.searchModel': '搜尋或輸入模型 ID...',
|
||||
'ai.providers.filterModels': '篩選模型...',
|
||||
'ai.providers.loadingModels': '載入模型中...',
|
||||
@@ -403,6 +413,14 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.reject': '拒絕',
|
||||
'ai.chat.toolLabel': '工具',
|
||||
'ai.chat.targetLabel': '目標',
|
||||
'ai.chat.rawCommand': '指令',
|
||||
'ai.chat.copyCommand': '複製',
|
||||
'ai.chat.commandCopied': '已複製',
|
||||
'ai.chat.approvalSession': '工作階段',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': '目錄',
|
||||
'ai.chat.approvalReason': '原因',
|
||||
'ai.chat.approvalInvocation': '呼叫詳情',
|
||||
'ai.chat.permissionRequired': '需要權限',
|
||||
'ai.chat.permissionDescription': 'AI Agent 希望執行一個需要你批准的工具呼叫。',
|
||||
'ai.chat.commandBlocked': '此指令已被安全策略攔截,無法執行。',
|
||||
@@ -442,6 +460,8 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.newChat': '新對話',
|
||||
'ai.chat.allSessions': '所有工作階段',
|
||||
'ai.chat.loadEarlierMessages': '載入更早的訊息(還有 {n} 條)',
|
||||
'ai.chat.jumpNav': '跳轉到訊息',
|
||||
'ai.chat.jumpUntitled': '(空訊息)',
|
||||
'ai.chat.usedTools': '已使用 {n} 個工具',
|
||||
'ai.chat.loadMoreSessions': '載入更多工作階段(還有 {n} 條)',
|
||||
'ai.chat.noSessions': '沒有歷史工作階段',
|
||||
|
||||
@@ -72,8 +72,12 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.tab.appearance': '外觀',
|
||||
'settings.tab.terminal': '終端',
|
||||
'settings.tab.shortcuts': '快捷鍵',
|
||||
'settings.tab.ai': 'AI',
|
||||
'settings.tab.syncCloud': '同步與雲',
|
||||
'settings.tab.system': '系統',
|
||||
'settings.search.open': '搜尋設定',
|
||||
'settings.search.placeholder': '搜尋設定項…',
|
||||
'settings.search.noResults': '沒有符合的設定項',
|
||||
'settings.terminal.connection.verifyHostKeys': '驗證 SSH 主機金鑰',
|
||||
'settings.terminal.connection.verifyHostKeys.desc': '連線到新的或已變化的 SSH 主機金鑰前先詢問。只建議在可信內網中關閉。',
|
||||
'settings.terminal.section.sidePanel': '側邊欄',
|
||||
@@ -166,6 +170,10 @@ export const zhTWCoreMessages: Messages = {
|
||||
|
||||
// Settings > Session Restore
|
||||
'settings.sessionRestore.title': '工作階段恢復',
|
||||
'settings.sessionRestore.startupLanding': '啟動時開啟',
|
||||
'settings.sessionRestore.startupLandingDesc': '當沒有可恢復的工作階段時,選擇啟動後進入主機主頁還是直接開啟本機終端。',
|
||||
'settings.sessionRestore.startupLanding.vault': '主頁(主機)',
|
||||
'settings.sessionRestore.startupLanding.localTerminal': '本機終端',
|
||||
'settings.sessionRestore.restorePreviousSession': '恢復上次的標籤頁和工作區',
|
||||
'settings.sessionRestore.restorePreviousSessionDesc': '啟動時重新開啟上次的終端標籤頁和工作區佈局。',
|
||||
'settings.sessionRestore.restoreTerminalCwd': '恢復終端工作目錄',
|
||||
@@ -194,6 +202,9 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.sessionLogs.timestamps': '新增時間戳',
|
||||
'settings.sessionLogs.timestampsDesc': '為純文字和 HTML 日誌的每一行新增本機時間。',
|
||||
'settings.sessionLogs.hint': '工作階段日誌用於記錄終端輸出,便於故障排查和審計。',
|
||||
'settings.sessionLogs.clearAll': '清空所有日誌',
|
||||
'settings.sessionLogs.clearAllDesc': '刪除儲存目錄中所有工作階段日誌檔案及主機子目錄。',
|
||||
'settings.sessionLogs.clearConfirm': '此操作將永久刪除儲存目錄中的所有工作階段日誌檔案,且無法復原,是否繼續?',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
'settings.sshDebugLogs.title': 'SSH 除錯日誌',
|
||||
@@ -228,6 +239,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'tray.status.active': '已啟用',
|
||||
'tray.status.inactive': '未啟用',
|
||||
'tray.status.error': '錯誤',
|
||||
'tray.status.unknown': '未知',
|
||||
'tray.recentHosts': '最近連線的主機',
|
||||
'tray.empty.title': '一切都很安靜',
|
||||
'tray.empty.subtitle': '去連線個伺服器吧,它們想念你了 🚀',
|
||||
@@ -821,6 +833,10 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.moveTo.confirm': '移動',
|
||||
'sftp.moveTo.pathNotFound': '目錄不存在或無法存取',
|
||||
'sftp.context.download': '下載',
|
||||
'sftp.context.extract': '解壓到目前目錄',
|
||||
'sftp.extract.extracting': '正在解壓 {fileName}...',
|
||||
'sftp.extract.success': '已解壓 {fileName}',
|
||||
'sftp.extract.error': '解壓 {fileName} 失敗:{error}',
|
||||
'sftp.context.copyToOtherPane': '複製到另一側',
|
||||
'sftp.copyToOtherPane.unavailable': '請開啟雙欄 SFTP 檔案管理,並連線目標側後再複製檔案。',
|
||||
'sftp.copyCurrentPath': '複製目前路徑',
|
||||
@@ -860,6 +876,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.transfers.calculatingTotal': '正在統計總大小...',
|
||||
'sftp.transfers.filesCount': '{count} 個檔案',
|
||||
'sftp.transfers.filesProgress': '{current}/{total} 個檔案',
|
||||
'sftp.transfers.filesDiscoveredProgress': '已完成 {completed} · 已發現 {discovered}',
|
||||
'sftp.transfers.expandChildren': '展開檔案',
|
||||
'sftp.transfers.collapseChildren': '收起檔案',
|
||||
'sftp.transfers.expandChildList': '展開詳情',
|
||||
@@ -915,6 +932,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.transfers.dragToResize': '拖拽調整高度',
|
||||
'sftp.goUp': '上一級',
|
||||
'sftp.goToTerminalCwd': '定位到終端目前目錄',
|
||||
'sftp.locatePathInTerminal': '定位路徑到終端',
|
||||
'sftp.followTerminalCwd': '追隨終端目錄',
|
||||
'sftp.followTerminalCwd.enable': '開啟追隨終端目錄',
|
||||
'sftp.followTerminalCwd.disable': '關閉追隨終端目錄',
|
||||
|
||||
@@ -18,8 +18,11 @@ export const zhTWScriptsMessages = {
|
||||
'scripts.editor.modalSubtitle': '在更大的工作區中編輯指令碼的中繼資料與程式碼。',
|
||||
'scripts.editor.lineCount': '{count} 行',
|
||||
'scripts.editor.resize': '調整編輯器高度',
|
||||
'scripts.targets.hint': '自動觸發會作用於所選主機;勾選「對所有主機生效」則作用於全部可連線的主機。',
|
||||
'scripts.targets.hint': '所選分組會動態比對,之後加入分組的主機也會自動生效。',
|
||||
'scripts.targets.connectOrderHint': '連線指令碼的執行順序請在主機詳情 → 自動化 中調整。',
|
||||
'scripts.targets.currentHostMismatch': '此指令碼未分配給目前主機。',
|
||||
'hostDetails.automation.groupScripts': '分組繼承指令碼',
|
||||
'hostDetails.automation.groupScriptsHint': '這些指令碼會動態跟隨主機所在分組,並在主機專屬佇列之前執行。',
|
||||
'scripts.actions.runNowHint': '在所選目標主機上執行;勾選「對所有主機生效」時在所有可連線的主機上執行。',
|
||||
'scripts.actions.runParallel': '在所有標籤頁並行執行',
|
||||
'scripts.actions.runSequential': '在所有標籤頁依序執行',
|
||||
|
||||
@@ -8,8 +8,12 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.empty': '暫無資料。',
|
||||
'systemManager.tabs.overview': '概覽',
|
||||
'systemManager.tabs.processes': '程序',
|
||||
'systemManager.tabs.ports': '連接埠',
|
||||
'systemManager.tabs.services': '服務',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.tabs.ariaLabel': '系統管理分區',
|
||||
'systemManager.popup.loading': '正在開啟終端…',
|
||||
'systemManager.popup.startupFailed': '啟動指令未順利完成。請確認目標仍可使用後再試一次。',
|
||||
|
||||
@@ -23,6 +27,9 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
|
||||
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
|
||||
'systemManager.errors.loadOverview': '載入系統概覽失敗',
|
||||
'systemManager.errors.loadGpu': '載入 GPU / NPU 狀態失敗',
|
||||
'systemManager.errors.loadPorts': '載入監聽連接埠失敗',
|
||||
'systemManager.errors.loadServices': '載入 systemd 服務失敗',
|
||||
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
|
||||
|
||||
'systemManager.overview.empty': '暫無系統概覽資料。',
|
||||
@@ -187,6 +194,51 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.start': '啟動',
|
||||
'systemManager.docker.stop': '停止',
|
||||
|
||||
'systemManager.ports.unavailable': '此主機未偵測到連接埠工具(ss / netstat)。',
|
||||
'systemManager.ports.loading': '正在載入監聽連接埠…',
|
||||
'systemManager.ports.empty': '暫無監聽連接埠。',
|
||||
'systemManager.ports.search': '搜尋連接埠…',
|
||||
'systemManager.ports.meta': '{{count}} 個監聽',
|
||||
'systemManager.ports.filter.all': '全部',
|
||||
'systemManager.ports.unknownProcess': '未知程序',
|
||||
'systemManager.ports.terminate': '結束程序',
|
||||
'systemManager.ports.confirmTerminate': '向佔用該連接埠的程序 {{pid}} 傳送 SIGTERM?',
|
||||
|
||||
'systemManager.services.unavailable': '此主機未偵測到 systemctl。',
|
||||
'systemManager.services.loading': '正在載入 systemd 服務…',
|
||||
'systemManager.services.empty': '暫無 systemd 服務。',
|
||||
'systemManager.services.search': '搜尋服務…',
|
||||
'systemManager.services.meta': '{{count}} 個服務',
|
||||
'systemManager.services.filter.all': '全部',
|
||||
'systemManager.services.filter.running': '執行中',
|
||||
'systemManager.services.filter.failed': '失敗',
|
||||
'systemManager.services.filter.inactive': '未執行',
|
||||
'systemManager.services.start': '啟動',
|
||||
'systemManager.services.stop': '停止',
|
||||
'systemManager.services.restart': '重新啟動',
|
||||
'systemManager.services.enable': '開機啟用',
|
||||
'systemManager.services.disable': '停用開機',
|
||||
'systemManager.services.reload': '重新載入',
|
||||
'systemManager.services.scope.user': '使用者',
|
||||
'systemManager.services.confirmAction': '確認對 {{name}} 執行「{{action}}」?',
|
||||
|
||||
'systemManager.gpu.unavailable': '此主機未偵測到 NVIDIA GPU 或昇騰 NPU 工具。',
|
||||
'systemManager.gpu.loading': '正在載入加速卡狀態…',
|
||||
'systemManager.gpu.empty': '已偵測到加速卡工具,但未回傳裝置資訊。',
|
||||
'systemManager.gpu.meta': '{{devices}} 張卡 · {{processes}} 個程序',
|
||||
'systemManager.gpu.devices': '裝置',
|
||||
'systemManager.gpu.processes': '計算程序',
|
||||
'systemManager.gpu.noProcesses': '目前沒有計算程序。',
|
||||
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
|
||||
'systemManager.gpu.vendor.ascend': '昇騰',
|
||||
'systemManager.gpu.util': '使用率',
|
||||
'systemManager.gpu.memory': '顯示記憶體',
|
||||
'systemManager.gpu.hbm': 'HBM',
|
||||
'systemManager.gpu.temperature': '溫度',
|
||||
'systemManager.gpu.power': '功耗',
|
||||
'systemManager.gpu.fan': '風扇 {{value}}%',
|
||||
'systemManager.gpu.driver': '驅動 {{version}}',
|
||||
|
||||
'systemManager.inspect.status': '狀態',
|
||||
'systemManager.inspect.image': '映像',
|
||||
'systemManager.inspect.created': '建立時間',
|
||||
|
||||
@@ -131,7 +131,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': '將檔案傳輸到另一窗格的使用中主機',
|
||||
'settings.sshDeepLink.title': 'SSH 和 Telnet 連結',
|
||||
'settings.sshDeepLink.enable': '用 Netcatty 開啟 ssh:// 和 telnet:// 連結',
|
||||
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh://、telnet:// 連結。',
|
||||
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh://、telnet:// 連結,以及堡壘機常用的 PuTTY 啟動參數(例如 -ssh user@host -P 22 -pw password)。',
|
||||
'settings.explorerContextMenu.title': 'Windows 檔案總管',
|
||||
'settings.explorerContextMenu.enable': '在資料夾右鍵選單中顯示 “Open in Netcatty”',
|
||||
'settings.explorerContextMenu.enableDesc': '為資料夾與資料夾空白處加入右鍵項目,可直接在該目錄開啟本機終端機。開關立即生效,無需重裝。',
|
||||
@@ -206,6 +206,9 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.sftp.compressedUpload.enable': '啟用資料夾壓縮',
|
||||
'settings.sftp.compressedUpload.enableDesc': '自動使用 tar 壓縮資料夾後再傳輸。需要伺服器支援 tar 指令,不支援時自動回退到普通傳輸。',
|
||||
|
||||
'settings.sftp.skipUnchanged.enable': '跳過未改檔案',
|
||||
'settings.sftp.skipUnchanged.enableDesc': '當目標檔案的大小與修改時間已與來源一致時跳過傳輸(類似 rsync)。替換到暫存路徑時自動停用。',
|
||||
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': '終端主題',
|
||||
'settings.terminal.themeModal.title': '選擇主題',
|
||||
@@ -335,6 +338,12 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.dynamicTabTitle.off': '關閉',
|
||||
'settings.terminal.behavior.dynamicTabTitle.agent': '僅 Agent',
|
||||
'settings.terminal.behavior.dynamicTabTitle.all': '全部工作階段',
|
||||
'settings.terminal.behavior.oscNotifications': 'OSC 桌面通知',
|
||||
'settings.terminal.behavior.oscNotifications.desc':
|
||||
'當程式發出 OSC 9、OSC 777 notify 或 kitty OSC 99 時顯示系統通知。Codex 等 Agent 會用它提示回合完成或等待核准。',
|
||||
'settings.terminal.behavior.oscNotifications.off': '關閉',
|
||||
'settings.terminal.behavior.oscNotifications.unfocused': '未聚焦時',
|
||||
'settings.terminal.behavior.oscNotifications.always': '一律',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪貼簿',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'允許遠端程式(tmux、vim 等)透過 OSC-52 轉義序列存取本機剪貼簿。',
|
||||
@@ -510,6 +519,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.shortcuts.disableTerminalFontZoom.desc': '關閉終端文字縮放快捷操作,包括 Cmd/Ctrl 加滾輪。',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '標籤快捷鍵跳過固定標籤',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '開啟後,Cmd/Ctrl+[1...9] 和 Ctrl+Tab 僅在終端、工作區、編輯器等可關閉標籤頁之間切換,不包括固定的 Vault 和 SFTP 標籤頁。',
|
||||
'settings.shortcuts.showTabNumberBadges.label': '顯示標籤頁序號角標',
|
||||
'settings.shortcuts.showTabNumberBadges.desc': '按住 Cmd/Ctrl 時,在對應 Cmd/Ctrl+[1...9] 的標籤頁上顯示 1-9,方便準確切換。',
|
||||
'settings.shortcuts.section.custom': '自訂快捷鍵',
|
||||
'settings.shortcuts.resetAll': '全部重置',
|
||||
'settings.shortcuts.recording': '請按鍵...',
|
||||
@@ -734,8 +745,11 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'snippets.scriptEditor.expand': '彈出視窗編輯',
|
||||
'snippets.scriptEditor.resize': '調整編輯器高度',
|
||||
'snippets.scriptEditor.modalTitle': '編輯指令碼',
|
||||
'snippets.targets.title': '目標主機',
|
||||
'snippets.targets.add': '新增目標主機',
|
||||
'snippets.targets.title': '執行目標',
|
||||
'snippets.targets.add': '新增目標',
|
||||
'snippets.targets.selectHosts': '選擇主機',
|
||||
'snippets.targets.selectGroups': '選擇分組',
|
||||
'snippets.targets.noGroups': '未找到分組',
|
||||
'snippets.history.title': 'Shell 歷史',
|
||||
'snippets.history.subtitle': '{count} 條指令',
|
||||
'snippets.history.emptyTitle': '暫無 Shell 歷史',
|
||||
@@ -825,7 +839,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'snippets.shortkey.recording': '請按下快捷鍵組合...',
|
||||
'snippets.shortkey.hint': '在終端中按下此快捷鍵可快速傳送指令。',
|
||||
'snippets.shortkey.clear': '清除快捷鍵',
|
||||
'snippets.shortkey.error.systemConflict': '此快捷鍵與系統快捷鍵衝突',
|
||||
'snippets.shortkey.error.systemConflict': '此快捷鍵與「{name}」衝突',
|
||||
'snippets.shortkey.error.snippetConflict': '此快捷鍵已被程式碼片段使用:{name}',
|
||||
|
||||
'snippets.variables.dialogTitle': '填寫變數',
|
||||
|
||||
@@ -427,6 +427,10 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.statusbar.copyHostname.tooltip': '複製主機地址({hostname})',
|
||||
'terminal.statusbar.copyHostname.toast': '已複製主機地址:{hostname}',
|
||||
'terminal.statusbar.copyHostname.error': '複製主機地址失敗',
|
||||
'terminal.statusbar.disconnect.label': '中斷連線',
|
||||
'terminal.statusbar.disconnect.tooltip': '中斷目前工作階段,但保留標籤頁',
|
||||
'terminal.statusbar.reconnect.label': '重新連線',
|
||||
'terminal.statusbar.reconnect.tooltip': '重新連線目前工作階段',
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.memory': '記憶體使用',
|
||||
|
||||
@@ -2,6 +2,7 @@ import en, { type Messages } from './locales/en';
|
||||
import zhCN from './locales/zh-CN';
|
||||
import zhTW from './locales/zh-TW';
|
||||
import ru from './locales/ru';
|
||||
import es from './locales/es';
|
||||
|
||||
// Keep keys stable; add new locales by adding another import and map entry.
|
||||
export { type Messages };
|
||||
@@ -9,6 +10,7 @@ export { type Messages };
|
||||
export const MESSAGES_BY_LOCALE: Record<string, Messages> = {
|
||||
en,
|
||||
ru,
|
||||
es,
|
||||
'zh-CN': zhCN,
|
||||
'zh-TW': zhTW,
|
||||
};
|
||||
|
||||
@@ -103,4 +103,35 @@ describe('storePluginSyncSecretsThenConnect', () => {
|
||||
);
|
||||
assert.deepEqual(deletedKeys, [['sync-credential']]);
|
||||
});
|
||||
|
||||
it('keeps overwritten secrets when reconnect connect rejects', async () => {
|
||||
const deletedKeys: string[][] = [];
|
||||
const restoredKeys: string[][] = [];
|
||||
await assert.rejects(
|
||||
() => storePluginSyncSecretsThenConnect({
|
||||
providerId: 'plugin:sync.example',
|
||||
secrets: [{ secretKey: 'sync-credential', value: 'bad-password' }],
|
||||
putSecret: async ({ key }) => ({
|
||||
kind: 'secret',
|
||||
id: 'ref-overwritten',
|
||||
key,
|
||||
created: false,
|
||||
}),
|
||||
deleteSecrets: async ({ keys }) => {
|
||||
deletedKeys.push([...keys]);
|
||||
return { deleted: keys.length };
|
||||
},
|
||||
restoreSecrets: async ({ keys }) => {
|
||||
restoredKeys.push([...keys]);
|
||||
return { restored: keys.length };
|
||||
},
|
||||
connect: async () => {
|
||||
throw new Error('auth failed');
|
||||
},
|
||||
}),
|
||||
/auth failed/,
|
||||
);
|
||||
assert.deepEqual(deletedKeys, []);
|
||||
assert.deepEqual(restoredKeys, [['sync-credential']]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* Persist plugin sync secrets then connect; roll back just-created secrets if
|
||||
* connect (or a later put) fails so bad passwords are not left in OS storage.
|
||||
* Overwritten keys are restored from the host-side overwrite stash so a
|
||||
* rejected reconnect does not leave a broken SecretRef / rejected credential.
|
||||
*/
|
||||
|
||||
export type PluginSyncSecretRef = { kind: 'secret'; id: string; key: string };
|
||||
@@ -10,6 +12,11 @@ export interface PluginSyncConnectSecretInput {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface PluginSyncPutSecretResult extends PluginSyncSecretRef {
|
||||
/** False when the durable (plugin,key) row already existed and was overwritten. */
|
||||
created?: boolean;
|
||||
}
|
||||
|
||||
export interface StorePluginSyncSecretsThenConnectParams {
|
||||
providerId: string;
|
||||
secrets: readonly PluginSyncConnectSecretInput[];
|
||||
@@ -19,11 +26,20 @@ export interface StorePluginSyncSecretsThenConnectParams {
|
||||
providerId: string;
|
||||
key: string;
|
||||
value: string;
|
||||
}) => Promise<PluginSyncSecretRef>;
|
||||
}) => Promise<PluginSyncPutSecretResult>;
|
||||
deleteSecrets: (params: {
|
||||
providerId: string;
|
||||
keys: string[];
|
||||
}) => Promise<unknown>;
|
||||
/**
|
||||
* Restore host-stashed plaintext for overwritten keys (`discard: false`),
|
||||
* or drop the stash after a successful connect (`discard: true`).
|
||||
*/
|
||||
restoreSecrets?: (params: {
|
||||
providerId: string;
|
||||
keys: string[];
|
||||
discard?: boolean;
|
||||
}) => Promise<unknown>;
|
||||
connect: (credential: PluginSyncSecretRef | undefined) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -31,6 +47,7 @@ export async function storePluginSyncSecretsThenConnect(
|
||||
params: StorePluginSyncSecretsThenConnectParams,
|
||||
): Promise<void> {
|
||||
const createdKeys: string[] = [];
|
||||
const overwrittenKeys: string[] = [];
|
||||
let credential: PluginSyncSecretRef | undefined = params.existingCredential;
|
||||
|
||||
try {
|
||||
@@ -42,13 +59,28 @@ export async function storePluginSyncSecretsThenConnect(
|
||||
key: secret.secretKey,
|
||||
value: secret.value,
|
||||
});
|
||||
createdKeys.push(secret.secretKey);
|
||||
if (ref.created === false) {
|
||||
overwrittenKeys.push(secret.secretKey);
|
||||
} else {
|
||||
createdKeys.push(secret.secretKey);
|
||||
}
|
||||
// SyncConnectPayload.credential carries the primary (first) secret;
|
||||
// additional secrets remain addressable via secrets.get(key).
|
||||
if (!credential) credential = ref;
|
||||
}
|
||||
}
|
||||
await params.connect(credential);
|
||||
if (overwrittenKeys.length > 0 && params.restoreSecrets) {
|
||||
try {
|
||||
await params.restoreSecrets({
|
||||
providerId: params.providerId,
|
||||
keys: [...overwrittenKeys],
|
||||
discard: true,
|
||||
});
|
||||
} catch {
|
||||
/* best-effort stash cleanup */
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (createdKeys.length > 0) {
|
||||
try {
|
||||
@@ -60,6 +92,16 @@ export async function storePluginSyncSecretsThenConnect(
|
||||
/* best-effort; surface the original connect/put error */
|
||||
}
|
||||
}
|
||||
if (overwrittenKeys.length > 0 && params.restoreSecrets) {
|
||||
try {
|
||||
await params.restoreSecrets({
|
||||
providerId: params.providerId,
|
||||
keys: [...overwrittenKeys],
|
||||
});
|
||||
} catch {
|
||||
/* best-effort; surface the original connect/put error */
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const chromeThemeSource = readFileSync(new URL("./useActiveChromeTheme.ts", impo
|
||||
const storeSource = readFileSync(new URL("./activeTabStore.ts", import.meta.url), "utf8");
|
||||
|
||||
test("active tab changes notify chrome theme before react subscribers", () => {
|
||||
const setActiveTabIdBody = storeSource.match(/setActiveTabId = \(id: string\) => \{[\s\S]*?\n {2}\};/)?.[0] ?? "";
|
||||
const setActiveTabIdBody = storeSource.match(/setActiveTabId = \(id: string(?:, options\?: \w+)?\) => \{[\s\S]*?\n {2}\};/)?.[0] ?? "";
|
||||
assert.match(setActiveTabIdBody, /this\.syncListeners\.forEach\(\(listener\) => listener\(id\)\)/);
|
||||
assert.match(setActiveTabIdBody, /this\.scheduleNotify\(\)/);
|
||||
assert.ok(
|
||||
|
||||
@@ -4,6 +4,37 @@ import test from 'node:test';
|
||||
import { activeTabStore, fromEditorTabId, isEditorTabId, toEditorTabId } from './activeTabStore';
|
||||
import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
|
||||
|
||||
test('active tab store remembers the previous tab for restore-on-close', () => {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
(globalThis as { window?: unknown }).window ??= globalThis;
|
||||
|
||||
const marker = `prev-tab-${Date.now()}`;
|
||||
const first = `${marker}-a`;
|
||||
const second = `${marker}-b`;
|
||||
const third = `${marker}-c`;
|
||||
const before = activeTabStore.getActiveTabId();
|
||||
|
||||
try {
|
||||
activeTabStore.setActiveTabId(first);
|
||||
activeTabStore.setActiveTabId(second);
|
||||
assert.equal(activeTabStore.getPreviousActiveTabId(), first);
|
||||
|
||||
activeTabStore.setActiveTabId(third);
|
||||
assert.equal(activeTabStore.getPreviousActiveTabId(), second);
|
||||
|
||||
activeTabStore.setActiveTabId(first, { recordPrevious: false });
|
||||
assert.equal(activeTabStore.getActiveTabId(), first);
|
||||
assert.equal(activeTabStore.getPreviousActiveTabId(), second);
|
||||
} finally {
|
||||
activeTabStore.setActiveTabId(before, { recordPrevious: false });
|
||||
if (previousWindow === undefined) {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
} else {
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('editor tab helpers round trip ids', () => {
|
||||
assert.equal(toEditorTabId('file-1'), 'editor:file-1');
|
||||
assert.equal(fromEditorTabId('editor:file-1'), 'file-1');
|
||||
|
||||
@@ -16,14 +16,22 @@ export const toEditorTabId = (editorId: string): string => `${EDITOR_PREFIX}${ed
|
||||
/** Strip the "editor:" prefix to recover the internal editorTab id. */
|
||||
export const fromEditorTabId = (tabId: string): string => tabId.slice(EDITOR_PREFIX.length);
|
||||
|
||||
type SetActiveTabOptions = {
|
||||
/** When false, keep the previous-tab pointer unchanged (used for close restore). */
|
||||
recordPrevious?: boolean;
|
||||
};
|
||||
|
||||
class ActiveTabStore {
|
||||
private activeTabId: string = 'vault';
|
||||
private previousActiveTabId: string | null = null;
|
||||
private listeners = new Set<Listener>();
|
||||
private syncListeners = new Set<SyncListener>();
|
||||
private notifyRafId: number | null = null;
|
||||
|
||||
getActiveTabId = () => this.activeTabId;
|
||||
|
||||
getPreviousActiveTabId = () => this.previousActiveTabId;
|
||||
|
||||
private scheduleNotify = () => {
|
||||
if (this.notifyRafId !== null) return;
|
||||
const schedule = typeof requestAnimationFrame === 'function'
|
||||
@@ -35,8 +43,11 @@ class ActiveTabStore {
|
||||
});
|
||||
};
|
||||
|
||||
setActiveTabId = (id: string) => {
|
||||
setActiveTabId = (id: string, options?: SetActiveTabOptions) => {
|
||||
if (this.activeTabId !== id) {
|
||||
if (options?.recordPrevious !== false) {
|
||||
this.previousActiveTabId = this.activeTabId;
|
||||
}
|
||||
this.activeTabId = id;
|
||||
this.syncListeners.forEach((listener) => listener(id));
|
||||
// Coalesce rapid tab switches into one notification per frame and avoid
|
||||
|
||||
@@ -18,8 +18,103 @@ import {
|
||||
setDraftView,
|
||||
setSessionView,
|
||||
updateDraftForScope,
|
||||
draftsByScopeEqualIgnoringComposerText,
|
||||
draftsByScopeEqualIgnoringAllComposerText,
|
||||
} from "./aiDraftState.ts";
|
||||
|
||||
test("draftsByScopeEqualIgnoringComposerText ignores typing in the active scope", () => {
|
||||
const base = createEmptyDraft("catty");
|
||||
const prev = { "terminal:1": { ...base, text: "" } };
|
||||
const next = { "terminal:1": { ...base, text: "你好", updatedAt: base.updatedAt + 1 } };
|
||||
assert.equal(draftsByScopeEqualIgnoringComposerText(prev, next, "terminal:1"), true);
|
||||
assert.equal(
|
||||
draftsByScopeEqualIgnoringComposerText(
|
||||
prev,
|
||||
{ "terminal:1": { ...base, text: "你好", attachments: [{ id: "a" } as never] } },
|
||||
"terminal:1",
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
draftsByScopeEqualIgnoringComposerText(
|
||||
prev,
|
||||
{ "terminal:1": prev["terminal:1"], "workspace:2": base },
|
||||
"terminal:1",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
draftsByScopeEqualIgnoringComposerText(
|
||||
prev,
|
||||
{
|
||||
"terminal:1": prev["terminal:1"],
|
||||
"workspace:2": { ...base, attachments: [{ id: "a" } as never] },
|
||||
},
|
||||
"terminal:1",
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("first composer draft is treated as text-only identity churn", () => {
|
||||
const empty = createEmptyDraft("catty");
|
||||
const created = { ...empty, text: "你" };
|
||||
assert.equal(draftsByScopeEqualIgnoringAllComposerText({}, { "terminal:1": empty }), false);
|
||||
assert.equal(draftsByScopeEqualIgnoringAllComposerText({}, { "terminal:1": created }), true);
|
||||
assert.equal(
|
||||
draftsByScopeEqualIgnoringComposerText({}, { "terminal:1": created }, "terminal:1"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
draftsByScopeEqualIgnoringAllComposerText(
|
||||
{ "terminal:1": empty },
|
||||
{ "terminal:1": created },
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
draftsByScopeEqualIgnoringAllComposerText(
|
||||
{ "terminal:1": created },
|
||||
{ "terminal:1": empty },
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
draftsByScopeEqualIgnoringAllComposerText(
|
||||
{},
|
||||
{ "terminal:1": { ...created, attachments: [{ id: "a" } as never] } },
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("draftsByScopeEqualIgnoringAllComposerText ignores typing in every scope", () => {
|
||||
const base = createEmptyDraft("catty");
|
||||
const prev = {
|
||||
"terminal:1": { ...base, text: "a" },
|
||||
"terminal:2": { ...base, text: "b", attachments: [] },
|
||||
};
|
||||
const next = {
|
||||
"terminal:1": { ...prev["terminal:1"], text: "aa", updatedAt: base.updatedAt + 1 },
|
||||
"terminal:2": { ...prev["terminal:2"], text: "bb", updatedAt: base.updatedAt + 2 },
|
||||
};
|
||||
assert.equal(draftsByScopeEqualIgnoringAllComposerText(prev, next), true);
|
||||
assert.equal(
|
||||
draftsByScopeEqualIgnoringAllComposerText(prev, {
|
||||
...next,
|
||||
"terminal:2": { ...next["terminal:2"], attachments: [{ id: "a" } as never] },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("updateDraftForScope keeps the original map when the updater is a no-op", () => {
|
||||
const draft = createEmptyDraft("catty");
|
||||
const prev = { "terminal:1": draft };
|
||||
const next = updateDraftForScope(prev, "terminal:1", "catty", (current) => current);
|
||||
assert.equal(next, prev);
|
||||
});
|
||||
|
||||
test("createEmptyDraft seeds selected agent and empty inputs", () => {
|
||||
const draft = createEmptyDraft("agent-alpha");
|
||||
|
||||
|
||||
@@ -11,6 +11,65 @@ type DraftUploadGenerationByScope = Record<string, number>;
|
||||
|
||||
const DEFAULT_PANEL_VIEW: AIPanelView = { mode: 'draft' };
|
||||
|
||||
function isComposerOnlyDraft(draft: AIDraft | undefined): boolean {
|
||||
return Boolean(
|
||||
draft
|
||||
&& draft.attachments.length === 0
|
||||
&& draft.selectedUserSkillSlugs.length === 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* First keystroke creates a missing → composer-only draft. That is still just
|
||||
* typing. Clearing a draft (`right` missing or emptied) is a real lifecycle
|
||||
* change so New Chat / send can reset the uncontrolled composer.
|
||||
*/
|
||||
function draftsEquivalentIgnoringComposerText(
|
||||
left: AIDraft | undefined,
|
||||
right: AIDraft | undefined,
|
||||
): boolean {
|
||||
if (left === right) return true;
|
||||
if (!left) return Boolean(isComposerOnlyDraft(right) && right.text.trim().length > 0);
|
||||
if (!right) return false;
|
||||
if (left.agentId !== right.agentId) return false;
|
||||
if (left.attachments !== right.attachments) return false;
|
||||
if (left.selectedUserSkillSlugs !== right.selectedUserSkillSlugs) return false;
|
||||
const leftEmpty = left.text.trim().length === 0;
|
||||
const rightEmpty = right.text.trim().length === 0;
|
||||
if (leftEmpty !== rightEmpty) return leftEmpty;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** True when every scope is unchanged except composer text/updatedAt. */
|
||||
export function draftsByScopeEqualIgnoringAllComposerText(
|
||||
prev: DraftsByScope,
|
||||
next: DraftsByScope,
|
||||
): boolean {
|
||||
if (prev === next) return true;
|
||||
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
|
||||
for (const key of keys) {
|
||||
if (!draftsEquivalentIgnoringComposerText(prev[key], next[key])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Typing only changes text/updatedAt. Sibling empty-draft creates stay ignored. */
|
||||
export function draftsByScopeEqualIgnoringComposerText(
|
||||
prev: DraftsByScope,
|
||||
next: DraftsByScope,
|
||||
scopeKey: string,
|
||||
): boolean {
|
||||
if (prev === next) return true;
|
||||
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
|
||||
for (const key of keys) {
|
||||
const left = prev[key];
|
||||
const right = next[key];
|
||||
if (key !== scopeKey && !left && isComposerOnlyDraft(right)) continue;
|
||||
if (!draftsEquivalentIgnoringComposerText(left, right)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createEmptyDraft(agentId: string): AIDraft {
|
||||
return {
|
||||
text: '',
|
||||
@@ -142,6 +201,9 @@ export function updateDraftForScope(
|
||||
): DraftsByScope {
|
||||
const currentDraft = draftsByScope[scopeKey] ?? createEmptyDraft(fallbackAgentId);
|
||||
const nextDraft = updater(currentDraft);
|
||||
if (nextDraft === currentDraft && draftsByScope[scopeKey] === currentDraft) {
|
||||
return draftsByScope;
|
||||
}
|
||||
|
||||
return {
|
||||
...draftsByScope,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type React from 'react';
|
||||
import {
|
||||
STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE,
|
||||
STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
export const AI_PANEL_DIAGNOSTIC_HIDE_KEY = STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE;
|
||||
export const AI_PANEL_DIAGNOSTIC_PROFILE_KEY = STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE;
|
||||
export const AI_PANEL_FORCE_HIDE_ALL_CONTENT = false;
|
||||
export const AI_PANEL_FORCE_HIDE_SHELL = false;
|
||||
|
||||
export type AIPanelDiagnosticPart =
|
||||
| 'all'
|
||||
| 'attachments'
|
||||
| 'header'
|
||||
| 'history'
|
||||
| 'input'
|
||||
| 'markdown'
|
||||
| 'messages'
|
||||
| 'recent'
|
||||
| 'toolcalls';
|
||||
|
||||
function readLocalStorageValue(key: string): string {
|
||||
try {
|
||||
return localStorageAdapter.readString(key) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function getAIPanelDiagnosticHiddenParts(): ReadonlySet<string> {
|
||||
if (AI_PANEL_FORCE_HIDE_ALL_CONTENT) {
|
||||
return new Set(['all']);
|
||||
}
|
||||
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_HIDE_KEY);
|
||||
return new Set(
|
||||
raw
|
||||
.split(',')
|
||||
.map((part) => part.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
export function isAIPanelDiagnosticPartHidden(
|
||||
part: AIPanelDiagnosticPart,
|
||||
hiddenParts = getAIPanelDiagnosticHiddenParts(),
|
||||
): boolean {
|
||||
return hiddenParts.has('all') || hiddenParts.has(part);
|
||||
}
|
||||
|
||||
export function isAIPanelDiagnosticsProfilingEnabled(): boolean {
|
||||
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_PROFILE_KEY).trim().toLowerCase();
|
||||
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
|
||||
}
|
||||
|
||||
export function logAIPanelProfiler(
|
||||
id: string,
|
||||
phase: 'mount' | 'update' | 'nested-update',
|
||||
actualDuration: number,
|
||||
baseDuration: number,
|
||||
): void {
|
||||
if (!isAIPanelDiagnosticsProfilingEnabled()) return;
|
||||
console.info(
|
||||
`[AI panel profile] ${id} ${phase}: actual=${actualDuration.toFixed(1)}ms base=${baseDuration.toFixed(1)}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
export function profileAIPanelCalculation<T>(label: string, calculate: () => T): T {
|
||||
if (!isAIPanelDiagnosticsProfilingEnabled()) return calculate();
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
return calculate();
|
||||
} finally {
|
||||
const elapsed = performance.now() - startedAt;
|
||||
console.info(`[AI panel profile] ${label}: ${elapsed.toFixed(1)}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAIPanelProfilerProps(id: string): Pick<React.ProfilerProps, 'id' | 'onRender'> {
|
||||
return {
|
||||
id,
|
||||
onRender: logAIPanelProfiler,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createEmptyDraft } from './aiDraftState.ts';
|
||||
import { aiSessionsStore } from './aiSessionsStore.ts';
|
||||
|
||||
test('AI sessions store does not notify listeners when only composer text changes', () => {
|
||||
const previous = aiSessionsStore.getSnapshot();
|
||||
const draft = createEmptyDraft('catty');
|
||||
const base = {
|
||||
sessions: Object.freeze([]),
|
||||
activeSessionIdMap: Object.freeze({}),
|
||||
draftsByScope: { 'terminal:1': draft },
|
||||
panelViewByScope: {},
|
||||
};
|
||||
|
||||
try {
|
||||
aiSessionsStore.setSnapshot(base);
|
||||
|
||||
let notified = 0;
|
||||
const unsubscribe = aiSessionsStore.subscribe(() => {
|
||||
notified += 1;
|
||||
});
|
||||
|
||||
aiSessionsStore.setSnapshot({
|
||||
...base,
|
||||
draftsByScope: {
|
||||
'terminal:1': { ...draft, text: 'hello', updatedAt: draft.updatedAt + 1 },
|
||||
},
|
||||
});
|
||||
assert.equal(notified, 0);
|
||||
assert.equal(aiSessionsStore.getSnapshot().draftsByScope['terminal:1']?.text, 'hello');
|
||||
|
||||
aiSessionsStore.setSnapshot({
|
||||
...base,
|
||||
draftsByScope: {},
|
||||
});
|
||||
notified = 0;
|
||||
aiSessionsStore.setSnapshot({
|
||||
...base,
|
||||
draftsByScope: {
|
||||
'terminal:1': { ...draft, text: '你好' },
|
||||
},
|
||||
});
|
||||
assert.equal(notified, 0);
|
||||
|
||||
notified = 0;
|
||||
aiSessionsStore.setSnapshot({
|
||||
...base,
|
||||
draftsByScope: {
|
||||
'terminal:1': { ...draft, text: '' },
|
||||
},
|
||||
});
|
||||
assert.equal(notified, 1);
|
||||
|
||||
notified = 0;
|
||||
aiSessionsStore.setSnapshot({
|
||||
...base,
|
||||
draftsByScope: {
|
||||
'terminal:1': { ...draft, text: 'hello', attachments: [{ id: 'a' } as never] },
|
||||
},
|
||||
});
|
||||
assert.equal(notified, 1);
|
||||
unsubscribe();
|
||||
} finally {
|
||||
aiSessionsStore.setSnapshot(previous);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import { draftsByScopeEqualIgnoringAllComposerText } from './aiDraftState';
|
||||
import type { DraftsByScope, PanelViewByScope } from './aiStateSnapshots';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
export type AISessionsSnapshot = {
|
||||
sessions: readonly AISession[];
|
||||
activeSessionIdMap: Readonly<Record<string, string | null>>;
|
||||
draftsByScope: DraftsByScope;
|
||||
panelViewByScope: PanelViewByScope;
|
||||
};
|
||||
|
||||
const EMPTY_SESSIONS: readonly AISession[] = Object.freeze([]);
|
||||
const EMPTY_MAP: Readonly<Record<string, string | null>> = Object.freeze({});
|
||||
const EMPTY_DRAFTS: DraftsByScope = Object.freeze({});
|
||||
const EMPTY_PANEL: PanelViewByScope = Object.freeze({});
|
||||
|
||||
export const EMPTY_AI_SESSIONS_SNAPSHOT: AISessionsSnapshot = Object.freeze({
|
||||
sessions: EMPTY_SESSIONS,
|
||||
activeSessionIdMap: EMPTY_MAP,
|
||||
draftsByScope: EMPTY_DRAFTS,
|
||||
panelViewByScope: EMPTY_PANEL,
|
||||
});
|
||||
|
||||
/**
|
||||
* Hot AI chat state — streaming message updates must not invalidate
|
||||
* slow AI config Context consumers (providers, permissions, etc.).
|
||||
*/
|
||||
class AISessionsStore {
|
||||
private snapshot: AISessionsSnapshot = EMPTY_AI_SESSIONS_SNAPSHOT;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getSnapshot = (): AISessionsSnapshot => this.snapshot;
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
setSnapshot(next: AISessionsSnapshot): void {
|
||||
if (
|
||||
this.snapshot.sessions === next.sessions
|
||||
&& this.snapshot.activeSessionIdMap === next.activeSessionIdMap
|
||||
&& this.snapshot.draftsByScope === next.draftsByScope
|
||||
&& this.snapshot.panelViewByScope === next.panelViewByScope
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const textOnlyDraftChange =
|
||||
this.snapshot.sessions === next.sessions
|
||||
&& this.snapshot.activeSessionIdMap === next.activeSessionIdMap
|
||||
&& this.snapshot.panelViewByScope === next.panelViewByScope
|
||||
&& draftsByScopeEqualIgnoringAllComposerText(
|
||||
this.snapshot.draftsByScope,
|
||||
next.draftsByScope,
|
||||
);
|
||||
this.snapshot = next;
|
||||
if (textOnlyDraftChange) return;
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const aiSessionsStore = new AISessionsStore();
|
||||
|
||||
export function publishAISessionsSnapshot(snapshot: AISessionsSnapshot): void {
|
||||
aiSessionsStore.setSnapshot(snapshot);
|
||||
}
|
||||
|
||||
export function getAISessionsSnapshot(): AISessionsSnapshot {
|
||||
return aiSessionsStore.getSnapshot();
|
||||
}
|
||||
|
||||
export function subscribeAISessions(listener: Listener): () => void {
|
||||
return aiSessionsStore.subscribe(listener);
|
||||
}
|
||||
|
||||
export function useAISessionsStore(): AISessionsSnapshot {
|
||||
return useSyncExternalStore(
|
||||
subscribeAISessions,
|
||||
getAISessionsSnapshot,
|
||||
getAISessionsSnapshot,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
getAppSessionRuntime,
|
||||
getAppSettingsRuntime,
|
||||
getAppVaultRuntime,
|
||||
registerAppSessionRuntime,
|
||||
registerAppSettingsRuntime,
|
||||
registerAppVaultRuntime,
|
||||
subscribeAppSessionRuntime,
|
||||
subscribeAppSettingsRuntime,
|
||||
subscribeAppVaultRuntime,
|
||||
type AppSessionRuntime,
|
||||
type AppSettingsRuntime,
|
||||
type AppVaultRuntime,
|
||||
} from './appRuntimeBridge';
|
||||
|
||||
const vaultRuntime = (label: string) => ({ label }) as unknown as AppVaultRuntime;
|
||||
const sessionRuntime = (label: string) => ({ label }) as unknown as AppSessionRuntime;
|
||||
const settingsRuntime = (label: string) => ({ label }) as unknown as AppSettingsRuntime;
|
||||
|
||||
test('vault slot exposes the last registered runtime and notifies on change', () => {
|
||||
let notifications = 0;
|
||||
const unsubscribe = subscribeAppVaultRuntime(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
|
||||
const first = vaultRuntime('first');
|
||||
registerAppVaultRuntime(first);
|
||||
assert.equal(getAppVaultRuntime(), first);
|
||||
assert.equal(notifications, 1);
|
||||
|
||||
// Re-registering the same identity is what a render with unchanged state
|
||||
// does; it must not wake subscribers.
|
||||
registerAppVaultRuntime(first);
|
||||
assert.equal(notifications, 1);
|
||||
|
||||
const second = vaultRuntime('second');
|
||||
registerAppVaultRuntime(second);
|
||||
assert.equal(getAppVaultRuntime(), second);
|
||||
assert.equal(notifications, 2);
|
||||
|
||||
unsubscribe();
|
||||
registerAppVaultRuntime(null);
|
||||
assert.equal(getAppVaultRuntime(), null);
|
||||
assert.equal(notifications, 2, 'unsubscribed listeners stay quiet');
|
||||
});
|
||||
|
||||
test('session slot is independent from the vault slot', () => {
|
||||
let vaultNotifications = 0;
|
||||
let sessionNotifications = 0;
|
||||
const unsubscribeVault = subscribeAppVaultRuntime(() => {
|
||||
vaultNotifications += 1;
|
||||
});
|
||||
const unsubscribeSession = subscribeAppSessionRuntime(() => {
|
||||
sessionNotifications += 1;
|
||||
});
|
||||
|
||||
const runtime = sessionRuntime('session');
|
||||
registerAppSessionRuntime(runtime);
|
||||
assert.equal(getAppSessionRuntime(), runtime);
|
||||
assert.equal(sessionNotifications, 1);
|
||||
assert.equal(vaultNotifications, 0);
|
||||
|
||||
unsubscribeVault();
|
||||
unsubscribeSession();
|
||||
registerAppSessionRuntime(null);
|
||||
});
|
||||
|
||||
test('settings slot is independent from the vault and session slots', () => {
|
||||
let settingsNotifications = 0;
|
||||
let otherNotifications = 0;
|
||||
const unsubscribeSettings = subscribeAppSettingsRuntime(() => {
|
||||
settingsNotifications += 1;
|
||||
});
|
||||
const unsubscribeVault = subscribeAppVaultRuntime(() => {
|
||||
otherNotifications += 1;
|
||||
});
|
||||
const unsubscribeSession = subscribeAppSessionRuntime(() => {
|
||||
otherNotifications += 1;
|
||||
});
|
||||
|
||||
const runtime = settingsRuntime('settings');
|
||||
registerAppSettingsRuntime(runtime);
|
||||
assert.equal(getAppSettingsRuntime(), runtime);
|
||||
assert.equal(settingsNotifications, 1);
|
||||
assert.equal(otherNotifications, 0);
|
||||
|
||||
// A re-render with unchanged state re-registers the same identity.
|
||||
registerAppSettingsRuntime(runtime);
|
||||
assert.equal(settingsNotifications, 1);
|
||||
|
||||
unsubscribeSettings();
|
||||
unsubscribeVault();
|
||||
unsubscribeSession();
|
||||
registerAppSettingsRuntime(null);
|
||||
assert.equal(getAppSettingsRuntime(), null);
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type { useAppLockState } from './useAppLockState';
|
||||
import type { useSessionState } from './useSessionState';
|
||||
import type { useSettingsState } from './useSettingsState';
|
||||
import type { useVaultState } from './useVaultState';
|
||||
|
||||
export type AppVaultRuntime = ReturnType<typeof useVaultState>;
|
||||
export type AppSessionRuntime = ReturnType<typeof useSessionState>;
|
||||
export type AppSettingsRuntime = ReturnType<typeof useSettingsState>;
|
||||
export type AppAppLockRuntime = ReturnType<typeof useAppLockState>;
|
||||
|
||||
/**
|
||||
* Narrow app-lock projection published on React context. The full runtime
|
||||
* changes identity on every AppLockGate render; chrome consumers (TopTabs lock
|
||||
* button, AppSideEffects deferral gates) only need these primitives, and
|
||||
* imperative callers read the full runtime through `getAppAppLockRuntime()`.
|
||||
*/
|
||||
export type AppLockChromeValue = {
|
||||
appLockEnabled: boolean;
|
||||
locked: boolean;
|
||||
initialized: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_APP_LOCK_CHROME: AppLockChromeValue = {
|
||||
appLockEnabled: false,
|
||||
locked: false,
|
||||
initialized: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* The slice of the vault runtime that `VaultPublisher` puts on the React
|
||||
* context.
|
||||
*
|
||||
* `notes` / `noteGroups` / `connectionLogs` / `shellHistory` are deliberately
|
||||
* absent. They churn on every note keystroke, session start/exit, and history
|
||||
* append, and publishing them here would re-render every context consumer for
|
||||
* data those consumers only ever read at call time. Consumers subscribe to
|
||||
* `notesStore` / `connectionLogsStore` / `shellHistoryStore` instead, or read
|
||||
* the full runtime imperatively through `getAppVaultRuntime()`.
|
||||
*
|
||||
* `exportData` is omitted for the same reason: its callback identity tracks the
|
||||
* notes arrays, so keeping it would reintroduce the churn it is meant to avoid.
|
||||
*/
|
||||
export type AppVaultContextValue = Omit<
|
||||
AppVaultRuntime,
|
||||
'notes' | 'noteGroups' | 'connectionLogs' | 'shellHistory' | 'exportData'
|
||||
>;
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
/**
|
||||
* Holds the live return of one mega hook so the component that owns the hook
|
||||
* and the components that consume it no longer have to be the same component.
|
||||
*
|
||||
* `VaultPublisher` / `SessionPublisher` / `SettingsPublisher` own
|
||||
* `useVaultState` / `useSessionState` / `useSettingsState` and expose the value
|
||||
* two ways:
|
||||
*
|
||||
* - through a React context, for render-time consumers (App). Context keeps the
|
||||
* read synchronous and non-null from the first render, which a store written
|
||||
* in a layout effect cannot do for a descendant.
|
||||
* - through this module slot, for imperative callers that need the current
|
||||
* runtime outside of a render (`getAppVaultRuntime()` in an event handler or
|
||||
* a bridge callback) and for non-descendant subscribers.
|
||||
*/
|
||||
class RuntimeSlot<T> {
|
||||
private value: T | null = null;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
get = (): T | null => this.value;
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
set(next: T | null): void {
|
||||
if (this.value === next) return;
|
||||
this.value = next;
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const vaultSlot = new RuntimeSlot<AppVaultRuntime>();
|
||||
const sessionSlot = new RuntimeSlot<AppSessionRuntime>();
|
||||
const settingsSlot = new RuntimeSlot<AppSettingsRuntime>();
|
||||
const appLockSlot = new RuntimeSlot<AppAppLockRuntime>();
|
||||
|
||||
export function registerAppVaultRuntime(runtime: AppVaultRuntime | null): void {
|
||||
vaultSlot.set(runtime);
|
||||
}
|
||||
|
||||
export function getAppVaultRuntime(): AppVaultRuntime | null {
|
||||
return vaultSlot.get();
|
||||
}
|
||||
|
||||
export function subscribeAppVaultRuntime(listener: Listener): () => void {
|
||||
return vaultSlot.subscribe(listener);
|
||||
}
|
||||
|
||||
export function registerAppSessionRuntime(runtime: AppSessionRuntime | null): void {
|
||||
sessionSlot.set(runtime);
|
||||
}
|
||||
|
||||
export function getAppSessionRuntime(): AppSessionRuntime | null {
|
||||
return sessionSlot.get();
|
||||
}
|
||||
|
||||
export function subscribeAppSessionRuntime(listener: Listener): () => void {
|
||||
return sessionSlot.subscribe(listener);
|
||||
}
|
||||
|
||||
export function registerAppSettingsRuntime(runtime: AppSettingsRuntime | null): void {
|
||||
settingsSlot.set(runtime);
|
||||
}
|
||||
|
||||
export function getAppSettingsRuntime(): AppSettingsRuntime | null {
|
||||
return settingsSlot.get();
|
||||
}
|
||||
|
||||
export function subscribeAppSettingsRuntime(listener: Listener): () => void {
|
||||
return settingsSlot.subscribe(listener);
|
||||
}
|
||||
|
||||
export function registerAppAppLockRuntime(runtime: AppAppLockRuntime | null): void {
|
||||
appLockSlot.set(runtime);
|
||||
}
|
||||
|
||||
export function getAppAppLockRuntime(): AppAppLockRuntime | null {
|
||||
return appLockSlot.get();
|
||||
}
|
||||
|
||||
export function subscribeAppAppLockRuntime(listener: Listener): () => void {
|
||||
return appLockSlot.subscribe(listener);
|
||||
}
|
||||
|
||||
export const AppVaultRuntimeContext = createContext<AppVaultContextValue | null>(null);
|
||||
export const AppSessionRuntimeContext = createContext<AppSessionRuntime | null>(null);
|
||||
export const AppSettingsRuntimeContext = createContext<AppSettingsRuntime | null>(null);
|
||||
export const AppLockChromeContext = createContext<AppLockChromeValue | null>(null);
|
||||
|
||||
/** Read the vault runtime owned by `VaultPublisher`. */
|
||||
export function useAppVaultRuntime(): AppVaultContextValue {
|
||||
const runtime = useContext(AppVaultRuntimeContext);
|
||||
if (!runtime) {
|
||||
throw new Error('useAppVaultRuntime must be rendered inside <VaultPublisher>');
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
/** Read the session runtime owned by `SessionPublisher`. */
|
||||
export function useAppSessionRuntime(): AppSessionRuntime {
|
||||
const runtime = useContext(AppSessionRuntimeContext);
|
||||
if (!runtime) {
|
||||
throw new Error('useAppSessionRuntime must be rendered inside <SessionPublisher>');
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
/** Read the settings runtime owned by `SettingsPublisher`. */
|
||||
export function useAppSettingsRuntime(): AppSettingsRuntime {
|
||||
const runtime = useContext(AppSettingsRuntimeContext);
|
||||
if (!runtime) {
|
||||
throw new Error('useAppSettingsRuntime must be rendered inside <SettingsPublisher>');
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the narrow app-lock chrome slice published by `AppLockRuntimePublisher`.
|
||||
* Falls back to an "unlocked / disabled" default so windows without a
|
||||
* publisher (isolated mounts, tests) behave as if App Lock is off.
|
||||
*/
|
||||
export function useAppLockChrome(): AppLockChromeValue {
|
||||
return useContext(AppLockChromeContext) ?? DEFAULT_APP_LOCK_CHROME;
|
||||
}
|
||||
Loaded 100 of 977 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user