Compare commits
550 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2215d52b09 | ||
|
|
c9059a4f29 | ||
|
|
4445bf578c | ||
|
|
f719350507 | ||
|
|
cfaee48553 | ||
|
|
1f05fe3efa | ||
|
|
e9c3b82c16 | ||
|
|
83fce70b20 | ||
|
|
d36c8bcbea | ||
|
|
5346752994 | ||
|
|
d267c4b6fc | ||
|
|
1a1da02e92 | ||
|
|
1adcffa7a8 | ||
|
|
7a2bedc4f4 | ||
|
|
5e753334ed | ||
|
|
a488bc466b | ||
|
|
2748cd5363 | ||
|
|
033165561d | ||
|
|
8e514f1008 | ||
|
|
0acd39603f | ||
|
|
4bdb0bbbf7 | ||
|
|
6b2c58f8f0 | ||
|
|
c0199c43cf | ||
|
|
7940b9a0a7 | ||
|
|
920914e3ee | ||
|
|
b5feb888d2 | ||
|
|
62d19974c9 | ||
|
|
932bb5032d | ||
|
|
3020d422fe | ||
|
|
bb526601bb | ||
|
|
d349c31cd6 | ||
|
|
8313cf780d | ||
|
|
29c0cc30a4 | ||
|
|
ee80048ece | ||
|
|
ec5dfcf1fa | ||
|
|
ab3b2c2055 | ||
|
|
ca8691d53d | ||
|
|
dddaf1a1cd | ||
|
|
d2469f93c8 | ||
|
|
521c9141e9 | ||
|
|
d068cc99c8 | ||
|
|
a89130d732 | ||
|
|
dcea13172d | ||
|
|
0c420d99ed | ||
|
|
a3ffefa067 | ||
|
|
80b6485d02 | ||
|
|
884d643150 | ||
|
|
96fde364df | ||
|
|
920d829299 | ||
|
|
4f7d3bccc8 | ||
|
|
a3eb6ab26c | ||
|
|
c402c45e1d | ||
|
|
d4978f267a | ||
|
|
41ee8e7160 | ||
|
|
3d9f153fe9 | ||
|
|
4332537da0 | ||
|
|
4284b9a9dc | ||
|
|
3afe6ed489 | ||
|
|
1d06e48966 | ||
|
|
3ce2481753 | ||
|
|
ce61c28162 | ||
|
|
55463441f0 | ||
|
|
c17833ae31 | ||
|
|
56bab12b5c | ||
|
|
d1482e47d6 | ||
|
|
4ee3c63768 | ||
|
|
2b067a9aae | ||
|
|
2d4a3a5602 | ||
|
|
6c57ce7b28 | ||
|
|
6a2bd0a6a1 | ||
|
|
0c4900c73d | ||
|
|
3174e9ad27 | ||
|
|
f517c85d07 | ||
|
|
0b9e3c430d | ||
|
|
1c526e6965 | ||
|
|
70ff5299b6 | ||
|
|
3ef53faef5 | ||
|
|
554bc3d2ab | ||
|
|
951a89e91e | ||
|
|
339e34e722 | ||
|
|
fe1a5ca0e5 | ||
|
|
3e89a65b39 | ||
|
|
090aae1833 | ||
|
|
8810b3cf0f | ||
|
|
087ce0f3b1 | ||
|
|
14e07741ae | ||
|
|
fe9b1b1011 | ||
|
|
7941aa6d08 | ||
|
|
b3d9908814 | ||
|
|
1006fa1da0 | ||
|
|
721b9596f5 | ||
|
|
b3fbc0972d | ||
|
|
6edc4213f4 | ||
|
|
4313977bd4 | ||
|
|
dae58ef64f | ||
|
|
945a09bdef | ||
|
|
4711fea969 | ||
|
|
f59ca56e23 | ||
|
|
3d1ab2de05 | ||
|
|
adc3343d76 | ||
|
|
944d590162 | ||
|
|
15ae17f918 | ||
|
|
65c15d8931 | ||
|
|
cbd1c84cdf | ||
|
|
0839e41b07 | ||
|
|
c27788280c | ||
|
|
fd7f516b00 | ||
|
|
33780fecde | ||
|
|
89ea1c43c5 | ||
|
|
bd2936aab2 | ||
|
|
c48ac93500 | ||
|
|
34a94df831 | ||
|
|
e87ce831b4 | ||
|
|
11c0c744f5 | ||
|
|
9546f27ca1 | ||
|
|
8a465a9adf | ||
|
|
f3b28d2283 | ||
|
|
8cfa62d945 | ||
|
|
b31ea0b9ca | ||
|
|
b2f6cabd75 | ||
|
|
92af5a5675 | ||
|
|
d50e854cbe | ||
|
|
d92dbd6091 | ||
|
|
3732bce989 | ||
|
|
ec0994288f | ||
|
|
b14c5d6147 | ||
|
|
c55f5dbdb8 | ||
|
|
a7f3008904 | ||
|
|
6833000038 | ||
|
|
485f28160d | ||
|
|
9df9f9fdfb | ||
|
|
b2720d1fd5 | ||
|
|
71419b65cd | ||
|
|
ba935099c4 | ||
|
|
1a45d39c98 | ||
|
|
3f06cb638a | ||
|
|
a225f0e207 | ||
|
|
3438f4bc88 | ||
|
|
9343cfda84 | ||
|
|
89b5b2f6b1 | ||
|
|
d080c805c2 | ||
|
|
4b41b2c20f | ||
|
|
62c4aa3ea6 | ||
|
|
5d164b4150 | ||
|
|
ac62d571ef | ||
|
|
e8d060c62f | ||
|
|
653164bee8 | ||
|
|
2a67667c0b | ||
|
|
e07e5cf442 | ||
|
|
92a9eed6bf | ||
|
|
65afa21711 | ||
|
|
f413ccfba1 | ||
|
|
58b6879c71 | ||
|
|
7fe7193344 | ||
|
|
7a19b73f54 | ||
|
|
5160230426 | ||
|
|
42b1a808a1 | ||
|
|
9dd3db4c14 | ||
|
|
e74f65729c | ||
|
|
97f53ed87f | ||
|
|
ec4512eb06 | ||
|
|
93c1f1b427 | ||
|
|
58ccd4bfb9 | ||
|
|
2fb82e1cb7 | ||
|
|
159589a09f | ||
|
|
04e1ed569d | ||
|
|
38fb5e8dd4 | ||
|
|
6f2b27206a | ||
|
|
f6eb693fac | ||
|
|
32935e4e87 | ||
|
|
f55c21fc0e | ||
|
|
26d03ace3f | ||
|
|
d85709d42d | ||
|
|
5470e19ae0 | ||
|
|
cd2c18b77c | ||
|
|
7355e29b89 | ||
|
|
64686cc237 | ||
|
|
d65440ace7 | ||
|
|
2dbeddd9aa | ||
|
|
4758345448 | ||
|
|
4d3fa93083 | ||
|
|
2746aae274 | ||
|
|
a7b22b3580 | ||
|
|
a66fcdba02 | ||
|
|
73c95fa08e | ||
|
|
3337cd620e | ||
|
|
97bd105564 | ||
|
|
554c43dfa8 | ||
|
|
c678f36504 | ||
|
|
f40a3f075b | ||
|
|
bb40ab464e | ||
|
|
4977add389 | ||
|
|
2d14655af4 | ||
|
|
025df8788b | ||
|
|
9e6d110766 | ||
|
|
347d0a445b | ||
|
|
e8be0d72de | ||
|
|
ce34f1bba8 | ||
|
|
9f4272f83c | ||
|
|
c158d52dd5 | ||
|
|
ec8dba360c | ||
|
|
8b5cc5c302 | ||
|
|
bae0c078f5 | ||
|
|
e0cda4dc5a | ||
|
|
4c0fc897a0 | ||
|
|
9ba150de82 | ||
|
|
a78647a2e8 | ||
|
|
4b7812d27f | ||
|
|
62b3cf658e | ||
|
|
74401a2084 | ||
|
|
44d25c10e1 | ||
|
|
d67c458730 | ||
|
|
44e8167300 | ||
|
|
02b16dee9b | ||
|
|
adaa8ee524 | ||
|
|
f429eb8f28 | ||
|
|
eaae884cd7 | ||
|
|
363f0ea87f | ||
|
|
b5533a73b6 | ||
|
|
6353f2c58a | ||
|
|
7e14f73769 | ||
|
|
60c2687144 | ||
|
|
7a1597bdc1 | ||
|
|
d5ae6e5cba | ||
|
|
a46080a378 | ||
|
|
f0972cc6c1 | ||
|
|
1442b42d66 | ||
|
|
24f1dc3f36 | ||
|
|
b0251e1eaf | ||
|
|
f55a1a4c15 | ||
|
|
d4b64d564b | ||
|
|
64d3b1f26a | ||
|
|
f6148d3578 | ||
|
|
c4d6d999c1 | ||
|
|
2ca5c730b8 | ||
|
|
b3a2063ca4 | ||
|
|
e6f2da48a7 | ||
|
|
a9fad5295c | ||
|
|
41822838f1 | ||
|
|
f98c578761 | ||
|
|
449d63ca3e | ||
|
|
f6f0d0ead1 | ||
|
|
dbfd50a8e0 | ||
|
|
17ffe5d1ee | ||
|
|
394cd539b3 | ||
|
|
1289223523 | ||
|
|
1d29167b97 | ||
|
|
143f6d993e | ||
|
|
7ee45ed7aa | ||
|
|
fb7b0aee86 | ||
|
|
54cd97d3f1 | ||
|
|
0bb876ea32 | ||
|
|
77a80f6dcb | ||
|
|
0989328afa | ||
|
|
c8621960d5 | ||
|
|
8fddc63777 | ||
|
|
185143cedc | ||
|
|
5ec91ea89d | ||
|
|
77c700e666 | ||
|
|
9590d3a67d | ||
|
|
2de37c3d53 | ||
|
|
994b5d1325 | ||
|
|
5bdd187365 | ||
|
|
c1959adbf6 | ||
|
|
6196c6e3c3 | ||
|
|
7980a62d55 | ||
|
|
52b18d825d | ||
|
|
70a172216a | ||
|
|
24edf6e1df | ||
|
|
a4abcab019 | ||
|
|
ca91483d01 | ||
|
|
dfcdcda189 | ||
|
|
7514f2eae3 | ||
|
|
951d1307cd | ||
|
|
0c14aed55a | ||
|
|
2fde490ee7 | ||
|
|
b6d0a3c698 | ||
|
|
5b2cf535e5 | ||
|
|
3c8ff48b4e | ||
|
|
5dcddfd0ff | ||
|
|
95bc7f018c | ||
|
|
7dee34f7d8 | ||
|
|
9b9cbb6068 | ||
|
|
940e49d2db | ||
|
|
bb25285349 | ||
|
|
be44f38911 | ||
|
|
4749bef906 | ||
|
|
797c607b0a | ||
|
|
7e3e4ce3b8 | ||
|
|
929d7dbe74 | ||
|
|
33313f71fb | ||
|
|
266e9f637c | ||
|
|
4d03c469e8 | ||
|
|
7846c5e046 | ||
|
|
a03df92dd1 | ||
|
|
a855323912 | ||
|
|
1dbda5bec3 | ||
|
|
2da63c0180 | ||
|
|
2af9cfccb3 | ||
|
|
ffb736eeea | ||
|
|
83cd65ef63 | ||
|
|
e46046081a | ||
|
|
7f75fadb31 | ||
|
|
1958648f63 | ||
|
|
e830b9362a | ||
|
|
2997ed6b3c | ||
|
|
2b03db1142 | ||
|
|
513309ba7c | ||
|
|
5918f91132 | ||
|
|
7347b04461 | ||
|
|
d8990dd4b1 | ||
|
|
538dd71084 | ||
|
|
c43f485bee | ||
|
|
839cce58ac | ||
|
|
1324bf95cb | ||
|
|
c668525d17 | ||
|
|
a21970a278 | ||
|
|
c07fd505d3 | ||
|
|
3bb47243ce | ||
|
|
d2483c5863 | ||
|
|
e2f7788c13 | ||
|
|
2e417e1dd5 | ||
|
|
b233e9609f | ||
|
|
f754378bea | ||
|
|
72e79bdc9a | ||
|
|
5d25bda560 | ||
|
|
5baff1ee63 | ||
|
|
1d14f1b0ba | ||
|
|
3f2c3e15d6 | ||
|
|
395361b559 | ||
|
|
918d58862e | ||
|
|
fea1ebf274 | ||
|
|
a56ade35a3 | ||
|
|
1b0cb918d8 | ||
|
|
869d30d4dd | ||
|
|
87388b93d9 | ||
|
|
15a269e5d4 | ||
|
|
cf6b33a3eb | ||
|
|
dfa9b109c2 | ||
|
|
55b55d77c9 | ||
|
|
4dccc11041 | ||
|
|
188e6c860a | ||
|
|
f454c56192 | ||
|
|
4480e5dc8d | ||
|
|
8426da1596 | ||
|
|
c472eaada2 | ||
|
|
71433252a1 | ||
|
|
ca42787808 | ||
|
|
c13c330747 | ||
|
|
a27b99cbf7 | ||
|
|
3d6e981758 | ||
|
|
e6d8c1381c | ||
|
|
bc3d73c683 | ||
|
|
dd5f3ddffd | ||
|
|
3959328e24 | ||
|
|
48928254fa | ||
|
|
30962c992f | ||
|
|
02e0fae051 | ||
|
|
6a94716880 | ||
|
|
2fecbb94fb | ||
|
|
6bd3968e04 | ||
|
|
528cda1f70 | ||
|
|
29bde31989 | ||
|
|
b12a2171e7 | ||
|
|
ffcd94e216 | ||
|
|
9b77fc9e3b | ||
|
|
9e57f2eb90 | ||
|
|
8cf6e9243d | ||
|
|
7a32aa0743 | ||
|
|
a1d0ce02fe | ||
|
|
adb2bc9403 | ||
|
|
7a6ed660fb | ||
|
|
035b22b467 | ||
|
|
1bce2c9808 | ||
|
|
ca2d699e55 | ||
|
|
6907fb54c8 | ||
|
|
4bae2517fe | ||
|
|
da4936ff22 | ||
|
|
2223ec34f0 | ||
|
|
ca1423051d | ||
|
|
ca8b36c7d5 | ||
|
|
b96eaf2aca | ||
|
|
663fe88b2e | ||
|
|
42da477425 | ||
|
|
474a13e4f9 | ||
|
|
3c5e12cc8b | ||
|
|
c2a01d83d7 | ||
|
|
dcc3b6fce7 | ||
|
|
fb43b53f33 | ||
|
|
b90c29f56a | ||
|
|
37092826f3 | ||
|
|
30b809a8f6 | ||
|
|
989a1aa3d7 | ||
|
|
9e5c5f826f | ||
|
|
74e0249797 | ||
|
|
d89d6d3959 | ||
|
|
66680d585f | ||
|
|
57dd2fb48b | ||
|
|
6d973f9bc8 | ||
|
|
425647eeda | ||
|
|
9109aec4ab | ||
|
|
6d5283173a | ||
|
|
ad67099ff3 | ||
|
|
02d44652df | ||
|
|
d227424096 | ||
|
|
1105f7fbb1 | ||
|
|
ef681194e3 | ||
|
|
4971a72620 | ||
|
|
8947d29717 | ||
|
|
dfaeed1ed6 | ||
|
|
443e038dcf | ||
|
|
242d35927a | ||
|
|
708ee1cd09 | ||
|
|
a2c24c2656 | ||
|
|
d91ed8dd23 | ||
|
|
689bb313f7 | ||
|
|
4ff05f7dbb | ||
|
|
f930e80dab | ||
|
|
e19b68db12 | ||
|
|
f6e67b6edb | ||
|
|
a86c74e509 | ||
|
|
bedcaddea7 | ||
|
|
78aaa6840b | ||
|
|
dff869a89d | ||
|
|
78d7b417fc | ||
|
|
27fcc4e493 | ||
|
|
b7216e9427 | ||
|
|
be4da72b21 | ||
|
|
7b903c44b0 | ||
|
|
c3c23d042f | ||
|
|
3263676996 | ||
|
|
7c6a14afda | ||
|
|
6a76287bf7 | ||
|
|
5317a4b81b | ||
|
|
2574d6d5e4 | ||
|
|
f04b1220ed | ||
|
|
ce4d156c2c | ||
|
|
ca46c9c924 | ||
|
|
f0d2c5c60d | ||
|
|
6cdf33a29d | ||
|
|
9b0b7c0eb7 | ||
|
|
5954359995 | ||
|
|
044165319e | ||
|
|
131553128a | ||
|
|
4aae4b19fc | ||
|
|
7b5fb46fd7 | ||
|
|
5bfb1f01c2 | ||
|
|
12188e11ef | ||
|
|
c0756e9981 | ||
|
|
b600aedc6f | ||
|
|
9fe915c65e | ||
|
|
1aa634a6c2 | ||
|
|
bfbab88ac2 | ||
|
|
faa7fd6dad | ||
|
|
9c6c653931 | ||
|
|
d46b63398e | ||
|
|
72bc03573c | ||
|
|
66c543cb97 | ||
|
|
fc61647c34 | ||
|
|
b2390da5b6 | ||
|
|
a3e0d4d5c1 | ||
|
|
45af36fd28 | ||
|
|
00784a6b0e | ||
|
|
de6acf0347 | ||
|
|
7c067964ee | ||
|
|
6b4cecf94f | ||
|
|
6b83f6c494 | ||
|
|
d2b58e69b0 | ||
|
|
7ffc9d427e | ||
|
|
d6db6c5db1 | ||
|
|
a528ade563 | ||
|
|
3e2edbec5e | ||
|
|
f7464f1d45 | ||
|
|
3bbe5f5fc4 | ||
|
|
e515e3d981 | ||
|
|
41ecef675c | ||
|
|
ac6f61b8cf | ||
|
|
0990c26cb2 | ||
|
|
753ce0480c | ||
|
|
974506415e | ||
|
|
51330c0443 | ||
|
|
ba761004e0 | ||
|
|
4278188292 | ||
|
|
cccb17c919 | ||
|
|
ab0e5e95b3 | ||
|
|
4102a45810 | ||
|
|
dc14255983 | ||
|
|
771eef0af9 | ||
|
|
45e9960d6b | ||
|
|
8ced017474 | ||
|
|
4a07c00a71 | ||
|
|
33cacfcd3d | ||
|
|
35b72b0992 | ||
|
|
fd77431847 | ||
|
|
c5f7540c6e | ||
|
|
b7428d0cbb | ||
|
|
02c4d97934 | ||
|
|
986f552779 | ||
|
|
42647e3572 | ||
|
|
5930d1601a | ||
|
|
df3d507e2b | ||
|
|
f8c7a9081b | ||
|
|
d8cfb0f1d9 | ||
|
|
269d790f28 | ||
|
|
0f12eab680 | ||
|
|
139fa43c43 | ||
|
|
eb30e6580e | ||
|
|
104a0c73d2 | ||
|
|
fc16739e99 | ||
|
|
dd386f218f | ||
|
|
254558771c | ||
|
|
9c9d01f372 | ||
|
|
a75b981630 | ||
|
|
2b706b7b4e | ||
|
|
8276f63c65 | ||
|
|
cac621413c | ||
|
|
897ddaddbf | ||
|
|
d51c0f526c | ||
|
|
7acd9b3b8d | ||
|
|
05345d1ac7 | ||
|
|
1f1ec8f7a6 | ||
|
|
8abba4bc7d | ||
|
|
ccf707df5a | ||
|
|
48d7a63d2e | ||
|
|
ad7f523ec2 | ||
|
|
a905b3e092 | ||
|
|
23148e88b1 | ||
|
|
23c6c55968 | ||
|
|
a53264013c | ||
|
|
7f58e039a2 | ||
|
|
4999a6884b | ||
|
|
eb8b565a77 | ||
|
|
cf103d7421 | ||
|
|
88b8cfb4da | ||
|
|
24f7a5a805 | ||
|
|
37d289be50 | ||
|
|
74f99e65d9 | ||
|
|
937608e7f3 | ||
|
|
3e1b72b869 | ||
|
|
9d04ae86f4 | ||
|
|
7beb9c1444 | ||
|
|
dd2f23b672 | ||
|
|
eac1007764 | ||
|
|
62625214a0 | ||
|
|
a6ae160932 | ||
|
|
6f1431e623 | ||
|
|
bebd161a98 | ||
|
|
3eaac53515 | ||
|
|
3a6949862d | ||
|
|
3c843c448a |
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx tsc:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
108
.github/scripts/generate-release-note.js
vendored
Normal file
108
.github/scripts/generate-release-note.js
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Determine version priority:
|
||||
// 1. VERSION env variable
|
||||
// 2. Valid version tag (v1.2.3 format)
|
||||
// 3. Short commit ID (first 7 chars of GITHUB_SHA)
|
||||
// 4. package.json version as fallback
|
||||
function getVersion() {
|
||||
if (process.env.VERSION) {
|
||||
return process.env.VERSION;
|
||||
}
|
||||
|
||||
const refName = process.env.GITHUB_REF_NAME;
|
||||
// Check if refName is a valid version tag (e.g., v1.2.3)
|
||||
if (refName && /^v\d+\.\d+\.\d+/.test(refName)) {
|
||||
return refName.replace(/^v/, '');
|
||||
}
|
||||
|
||||
// Use short commit ID
|
||||
const sha = process.env.GITHUB_SHA;
|
||||
if (sha) {
|
||||
return sha.substring(0, 7);
|
||||
}
|
||||
|
||||
// Fall back to package.json version
|
||||
try {
|
||||
const pkgPath = path.join(__dirname, '..', '..', 'package.json');
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
return pkg.version;
|
||||
} catch {
|
||||
return '0.0.0';
|
||||
}
|
||||
}
|
||||
|
||||
const version = getVersion();
|
||||
const repo = process.env.GITHUB_REPOSITORY || 'binaricat/netcatty';
|
||||
// For tag releases, use the tag; for workflow_dispatch, create a tag from version
|
||||
const tag = (process.env.GITHUB_REF_NAME && /^v\d+\.\d+\.\d+/.test(process.env.GITHUB_REF_NAME))
|
||||
? process.env.GITHUB_REF_NAME
|
||||
: `v${version}`;
|
||||
const baseUrl = `https://github.com/${repo}/releases/download/${tag}`;
|
||||
|
||||
// Filename patterns based on electron-builder.config.cjs artifactName: '${productName}-${version}-${os}-${arch}.${ext}'
|
||||
// Note: electron-builder uses different arch names for Linux packages:
|
||||
// - AppImage: x64 -> x86_64, arm64 -> arm64
|
||||
// - deb: x64 -> amd64, arm64 -> arm64
|
||||
// - rpm: x64 -> x86_64, arm64 -> aarch64
|
||||
const files = {
|
||||
mac: {
|
||||
arm64: `Netcatty-${version}-mac-arm64.dmg`,
|
||||
x64: `Netcatty-${version}-mac-x64.dmg`
|
||||
},
|
||||
win: {
|
||||
x64: `Netcatty-${version}-win-x64.exe`,
|
||||
arm64: `Netcatty-${version}-win-arm64.exe`
|
||||
},
|
||||
linux: {
|
||||
appimage: {
|
||||
x64: `Netcatty-${version}-linux-x86_64.AppImage`,
|
||||
arm64: `Netcatty-${version}-linux-arm64.AppImage`
|
||||
},
|
||||
deb: {
|
||||
x64: `Netcatty-${version}-linux-amd64.deb`,
|
||||
arm64: `Netcatty-${version}-linux-arm64.deb`
|
||||
},
|
||||
rpm: {
|
||||
x64: `Netcatty-${version}-linux-x86_64.rpm`,
|
||||
arm64: `Netcatty-${version}-linux-aarch64.rpm`
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const badges = {
|
||||
win: {
|
||||
setup_x64: `[](${baseUrl}/${files.win.x64})`,
|
||||
setup_arm64: `[](${baseUrl}/${files.win.arm64})`
|
||||
},
|
||||
mac: {
|
||||
apple_silicon: `[](${baseUrl}/${files.mac.arm64})`,
|
||||
intel: `[](${baseUrl}/${files.mac.x64})`
|
||||
},
|
||||
linux: {
|
||||
appimage_x64: `[](${baseUrl}/${files.linux.appimage.x64})`,
|
||||
appimage_arm64: `[](${baseUrl}/${files.linux.appimage.arm64})`,
|
||||
deb_x64: `[](${baseUrl}/${files.linux.deb.x64})`,
|
||||
deb_arm64: `[](${baseUrl}/${files.linux.deb.arm64})`,
|
||||
rpm_x64: `[](${baseUrl}/${files.linux.rpm.x64})`,
|
||||
rpm_arm64: `[](${baseUrl}/${files.linux.rpm.arm64})`
|
||||
}
|
||||
};
|
||||
|
||||
const content = `
|
||||
## Download based on your OS:
|
||||
|
||||
| OS | Download |
|
||||
| :--- | :--- |
|
||||
| **Windows** | ${badges.win.setup_x64} ${badges.win.setup_arm64} |
|
||||
| **macOS** | ${badges.mac.apple_silicon} ${badges.mac.intel} |
|
||||
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} |
|
||||
`;
|
||||
|
||||
fs.writeFileSync('release_notes.md', content);
|
||||
console.log('Generated release_notes.md');
|
||||
42
.github/workflows/build.yml
vendored
42
.github/workflows/build.yml
vendored
@@ -2,6 +2,11 @@ name: build-packages
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish_release:
|
||||
description: "Publish GitHub Release after build"
|
||||
type: boolean
|
||||
default: false
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
@@ -13,7 +18,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, windows-latest]
|
||||
os: [macos-latest, windows-latest, ubuntu-latest]
|
||||
env:
|
||||
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
|
||||
@@ -32,11 +37,16 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Set version from tag
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
- name: Set version
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
# Tag release: use version from tag
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
else
|
||||
# workflow_dispatch: use short commit ID
|
||||
VERSION="${GITHUB_SHA:0:7}"
|
||||
fi
|
||||
echo "Setting version to ${VERSION}"
|
||||
npm pkg set version="${VERSION}"
|
||||
|
||||
@@ -53,28 +63,31 @@ jobs:
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:win
|
||||
|
||||
- name: Build package (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
env:
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:linux
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: netcatty-${{ matrix.os }}
|
||||
path: |
|
||||
release/*.dmg
|
||||
release/*.zip
|
||||
release/*.exe
|
||||
release/*.msi
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
release/*.tar.gz
|
||||
release/*.blockmap
|
||||
release/latest*.yml
|
||||
if-no-files-found: ignore
|
||||
|
||||
release:
|
||||
name: release
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
@@ -90,20 +103,23 @@ jobs:
|
||||
- name: List artifacts
|
||||
run: ls -la artifacts/
|
||||
|
||||
- name: Generate Release Body
|
||||
run: node .github/scripts/generate-release-note.js
|
||||
env:
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
body_path: release_notes.md
|
||||
files: |
|
||||
artifacts/*.dmg
|
||||
artifacts/*.zip
|
||||
artifacts/*.exe
|
||||
artifacts/*.msi
|
||||
artifacts/*.AppImage
|
||||
artifacts/*.deb
|
||||
artifacts/*.rpm
|
||||
artifacts/*.tar.gz
|
||||
artifacts/*.blockmap
|
||||
artifacts/latest*.yml
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: false
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -22,6 +22,7 @@ coverage
|
||||
/release
|
||||
/out
|
||||
*.asar
|
||||
/public/monaco
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
@@ -32,3 +33,6 @@ coverage
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Claude Code local settings
|
||||
/.claude/settings.local.json
|
||||
|
||||
471
App.tsx
471
App.tsx
@@ -1,14 +1,20 @@
|
||||
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from './application/state/activeTabStore';
|
||||
import { useAutoSync } from './application/state/useAutoSync';
|
||||
import { useManagedSourceSync } from './application/state/useManagedSourceSync';
|
||||
import { usePortForwardingAutoStart } from './application/state/usePortForwardingAutoStart';
|
||||
import { usePortForwardingState } from './application/state/usePortForwardingState';
|
||||
import { useSessionState } from './application/state/useSessionState';
|
||||
import { useSettingsState } from './application/state/useSettingsState';
|
||||
import { useUpdateCheck } from './application/state/useUpdateCheck';
|
||||
import { useVaultState } from './application/state/useVaultState';
|
||||
import { useWindowControls } from './application/state/useWindowControls';
|
||||
import { initializeFonts } from './application/state/fontStore';
|
||||
import { initializeUIFonts } from './application/state/uiFontStore';
|
||||
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
|
||||
import { matchesKeyBinding } from './domain/models';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
|
||||
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
|
||||
import { TopTabs } from './components/TopTabs';
|
||||
import { Button } from './components/ui/button';
|
||||
@@ -17,12 +23,18 @@ import { Input } from './components/ui/input';
|
||||
import { Label } from './components/ui/label';
|
||||
import { ToastProvider, toast } from './components/ui/toast';
|
||||
import { VaultView, VaultSection } from './components/VaultView';
|
||||
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
|
||||
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
|
||||
import { cn } from './lib/utils';
|
||||
import { ConnectionLog, Host, HostProtocol, TerminalTheme } from './types';
|
||||
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
|
||||
import { LogView as LogViewType } from './application/state/useSessionState';
|
||||
import type { SftpView as SftpViewComponent } from './components/SftpView';
|
||||
import type { TerminalLayer as TerminalLayerComponent } from './components/TerminalLayer';
|
||||
|
||||
// Initialize fonts eagerly at app startup
|
||||
initializeFonts();
|
||||
initializeUIFonts();
|
||||
|
||||
// Visibility container for VaultView - isolates isActive subscription
|
||||
const VaultViewContainer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const isActive = useIsVaultActive();
|
||||
@@ -76,6 +88,9 @@ const LazyProtocolSelectDialog = lazy(() => import('./components/ProtocolSelectD
|
||||
const LazyQuickSwitcher = lazy(() =>
|
||||
import('./components/QuickSwitcher').then((m) => ({ default: m.QuickSwitcher })),
|
||||
);
|
||||
const LazyCreateWorkspaceDialog = lazy(() =>
|
||||
import('./components/CreateWorkspaceDialog').then((m) => ({ default: m.CreateWorkspaceDialog })),
|
||||
);
|
||||
|
||||
const IS_DEV = import.meta.env.DEV;
|
||||
const HOTKEY_DEBUG =
|
||||
@@ -140,11 +155,16 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const [isQuickSwitcherOpen, setIsQuickSwitcherOpen] = useState(false);
|
||||
const [isCreateWorkspaceOpen, setIsCreateWorkspaceOpen] = useState(false);
|
||||
const [quickSearch, setQuickSearch] = useState('');
|
||||
// Protocol selection dialog state for QuickSwitcher
|
||||
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null);
|
||||
// Navigation state for VaultView sections
|
||||
const [navigateToSection, setNavigateToSection] = useState<VaultSection | null>(null);
|
||||
// Keyboard-interactive authentication queue (2FA/MFA) - queue-based to handle multiple concurrent sessions
|
||||
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
|
||||
// Passphrase request queue for encrypted SSH keys
|
||||
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
|
||||
|
||||
const {
|
||||
theme,
|
||||
@@ -159,6 +179,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
isHotkeyRecording,
|
||||
sessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
} = settings;
|
||||
|
||||
const {
|
||||
@@ -171,6 +194,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
knownHosts,
|
||||
shellHistory,
|
||||
connectionLogs,
|
||||
managedSources,
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
updateIdentities,
|
||||
@@ -178,6 +202,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
updateSnippetPackages,
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
addShellHistoryEntry,
|
||||
addConnectionLog,
|
||||
updateConnectionLog,
|
||||
@@ -208,10 +233,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
submitWorkspaceRename,
|
||||
resetWorkspaceRename,
|
||||
createLocalTerminal,
|
||||
createSerialSession,
|
||||
connectToHost,
|
||||
closeSession,
|
||||
closeWorkspace,
|
||||
updateSessionStatus,
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
updateSplitSizes,
|
||||
@@ -228,11 +255,26 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
logViews,
|
||||
openLogView,
|
||||
closeLogView,
|
||||
copySession,
|
||||
} = useSessionState();
|
||||
|
||||
// isMacClient is used for window controls styling
|
||||
const isMacClient = typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
|
||||
|
||||
// Get port forwarding rules and import function
|
||||
const { rules: portForwardingRules, importRules: importPortForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
|
||||
|
||||
const portForwardingRulesForSync = useMemo(
|
||||
() =>
|
||||
portForwardingRules.map((rule) => ({
|
||||
...rule,
|
||||
status: "inactive",
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
})),
|
||||
[portForwardingRules],
|
||||
);
|
||||
|
||||
// Auto-sync hook for cloud sync
|
||||
const { syncNow: handleSyncNow } = useAutoSync({
|
||||
hosts,
|
||||
@@ -240,7 +282,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
identities,
|
||||
snippets,
|
||||
customGroups,
|
||||
portForwardingRules: undefined, // TODO: Add port forwarding rules from usePortForwardingState
|
||||
portForwardingRules: portForwardingRulesForSync,
|
||||
knownHosts,
|
||||
onApplyPayload: (payload) => {
|
||||
importDataFromString(JSON.stringify({
|
||||
@@ -250,9 +292,19 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
snippets: payload.snippets,
|
||||
customGroups: payload.customGroups,
|
||||
}));
|
||||
|
||||
if (payload.portForwardingRules) {
|
||||
importPortForwardingRules(payload.portForwardingRules);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { clearAndRemoveSource, clearAndRemoveSources, unmanageSource } = useManagedSourceSync({
|
||||
hosts,
|
||||
managedSources,
|
||||
onUpdateManagedSources: updateManagedSources,
|
||||
});
|
||||
|
||||
const handleSyncNowManual = useCallback(() => {
|
||||
return handleSyncNow({ trigger: 'manual' });
|
||||
}, [handleSyncNow]);
|
||||
@@ -279,6 +331,251 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
}, [updateState.hasUpdate, updateState.latestRelease, t, openReleasePage, dismissUpdate]);
|
||||
|
||||
// Memoize keys for port forwarding to prevent unnecessary re-renders
|
||||
const portForwardingKeys = useMemo(
|
||||
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
|
||||
[keys]
|
||||
);
|
||||
|
||||
// Auto-start port forwarding rules on app launch
|
||||
usePortForwardingAutoStart({
|
||||
hosts,
|
||||
keys: portForwardingKeys,
|
||||
});
|
||||
|
||||
// Sync tray menu data + handle tray actions
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.updateTrayMenuData) return;
|
||||
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
|
||||
const sessionsForTray = sessions.map((s) => {
|
||||
const ws = s.workspaceId ? workspaces.find((w) => w.id === s.workspaceId) : undefined;
|
||||
return {
|
||||
id: s.id,
|
||||
label: s.hostname,
|
||||
hostLabel: s.hostLabel,
|
||||
status: s.status,
|
||||
workspaceId: s.workspaceId,
|
||||
workspaceTitle: ws?.title,
|
||||
};
|
||||
});
|
||||
|
||||
void bridge.updateTrayMenuData({
|
||||
sessions: sessionsForTray,
|
||||
portForwardRules: portForwardingRules,
|
||||
});
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [sessions, portForwardingRules, workspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onTrayFocusSession || !bridge?.onTrayTogglePortForward) return;
|
||||
|
||||
const unsubscribeFocus = bridge.onTrayFocusSession((sessionId) => {
|
||||
// Find the session to check if it belongs to a workspace
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (session?.workspaceId) {
|
||||
// Session is in a workspace - navigate to workspace and focus the session
|
||||
setActiveTabId(session.workspaceId);
|
||||
setWorkspaceFocusedSession(session.workspaceId, sessionId);
|
||||
} else {
|
||||
// Standalone session or session not found - just set tab
|
||||
setActiveTabId(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
const unsubscribeToggle = bridge.onTrayTogglePortForward((ruleId, start) => {
|
||||
const rule = portForwardingRules.find((r) => r.id === ruleId);
|
||||
if (!rule) return;
|
||||
const host = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
|
||||
if (!host) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
|
||||
const keysForPf = keys.map((k) => ({ id: k.id, privateKey: k.privateKey }));
|
||||
if (start) {
|
||||
void startTunnel(rule, host, keysForPf, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
} else {
|
||||
void stopTunnel(ruleId);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeFocus?.();
|
||||
unsubscribeToggle?.();
|
||||
};
|
||||
}, [hosts, keys, portForwardingRules, sessions, setActiveTabId, setWorkspaceFocusedSession, startTunnel, stopTunnel, t]);
|
||||
|
||||
// Tray panel actions (from main process)
|
||||
useEffect(() => {
|
||||
const handlerJump = (sessionId: string) => {
|
||||
// Find the session to check if it belongs to a workspace
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (session?.workspaceId) {
|
||||
// Session is in a workspace - navigate to workspace and focus the session
|
||||
setActiveTabId(session.workspaceId);
|
||||
setWorkspaceFocusedSession(session.workspaceId, sessionId);
|
||||
} else {
|
||||
// Standalone session or session not found - just set tab
|
||||
setActiveTabId(sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
const handlerConnect = (hostId: string) => {
|
||||
const host = hosts.find((h) => h.id === hostId);
|
||||
if (!host) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
|
||||
addConnectionLog({
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: host.username,
|
||||
localHostname: "",
|
||||
saved: false,
|
||||
});
|
||||
|
||||
connectToHost(host);
|
||||
};
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onTrayPanelJumpToSession || !bridge?.onTrayPanelConnectToHost) return;
|
||||
|
||||
const unsubscribeJump = bridge.onTrayPanelJumpToSession(handlerJump);
|
||||
const unsubscribeConnect = bridge.onTrayPanelConnectToHost(handlerConnect);
|
||||
return () => {
|
||||
unsubscribeJump?.();
|
||||
unsubscribeConnect?.();
|
||||
};
|
||||
}, [addConnectionLog, connectToHost, hosts, sessions, setActiveTabId, setWorkspaceFocusedSession, t]);
|
||||
|
||||
// Keyboard-interactive authentication (2FA/MFA) event listener
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onKeyboardInteractive) return;
|
||||
|
||||
const unsubscribe = bridge.onKeyboardInteractive((request) => {
|
||||
console.log('[App] Keyboard-interactive request received:', request);
|
||||
// Add to queue instead of replacing - supports multiple concurrent sessions
|
||||
setKeyboardInteractiveQueue(prev => [...prev, {
|
||||
requestId: request.requestId,
|
||||
name: request.name,
|
||||
instructions: request.instructions,
|
||||
prompts: request.prompts,
|
||||
hostname: request.hostname,
|
||||
savedPassword: request.savedPassword,
|
||||
}]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle keyboard-interactive submit
|
||||
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[]) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondKeyboardInteractive) {
|
||||
void bridge.respondKeyboardInteractive(requestId, responses, false);
|
||||
}
|
||||
// Remove from queue by requestId
|
||||
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
|
||||
// Handle keyboard-interactive cancel
|
||||
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondKeyboardInteractive) {
|
||||
void bridge.respondKeyboardInteractive(requestId, [], true);
|
||||
}
|
||||
// Remove from queue by requestId
|
||||
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
|
||||
// Passphrase request event listener for encrypted SSH keys
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onPassphraseRequest) return;
|
||||
|
||||
const unsubscribe = bridge.onPassphraseRequest((request) => {
|
||||
console.log('[App] Passphrase request received:', request);
|
||||
setPassphraseQueue(prev => [...prev, {
|
||||
requestId: request.requestId,
|
||||
keyPath: request.keyPath,
|
||||
keyName: request.keyName,
|
||||
hostname: request.hostname,
|
||||
}]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle passphrase submit
|
||||
const handlePassphraseSubmit = useCallback((requestId: string, passphrase: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondPassphrase) {
|
||||
void bridge.respondPassphrase(requestId, passphrase, false);
|
||||
}
|
||||
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
|
||||
// Handle passphrase cancel
|
||||
const handlePassphraseCancel = useCallback((requestId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondPassphrase) {
|
||||
// Cancel = stop the entire passphrase flow
|
||||
void bridge.respondPassphrase(requestId, '', true);
|
||||
}
|
||||
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
|
||||
// Handle passphrase skip (skip this key, continue with others)
|
||||
const handlePassphraseSkip = useCallback((requestId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondPassphraseSkip) {
|
||||
// Skip = skip this key but continue asking for others
|
||||
void bridge.respondPassphraseSkip(requestId);
|
||||
} else if (bridge?.respondPassphrase) {
|
||||
// Fallback for older API
|
||||
void bridge.respondPassphrase(requestId, '', false);
|
||||
}
|
||||
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
|
||||
// Handle passphrase timeout (request expired on backend)
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onPassphraseTimeout) return;
|
||||
|
||||
const unsubscribe = bridge.onPassphraseTimeout((event) => {
|
||||
console.log('[App] Passphrase request timed out:', event.requestId);
|
||||
// Remove from queue - the modal will close automatically
|
||||
setPassphraseQueue(prev => prev.filter(r => r.requestId !== event.requestId));
|
||||
// Show a toast notification to inform user
|
||||
toast.error('Passphrase request timed out. Please try connecting again.');
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Debounce ref for moveFocus to prevent double-triggering when focus switches
|
||||
const lastMoveFocusTimeRef = useRef<number>(0);
|
||||
const MOVE_FOCUS_DEBOUNCE_MS = 200;
|
||||
@@ -473,11 +770,15 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Note: xterm terminal handles its own key interception via attachCustomKeyEventHandler
|
||||
const target = e.target as HTMLElement;
|
||||
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
|
||||
const isMonacoElement =
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
|
||||
const isXtermInput =
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
|
||||
|
||||
if (isFormElement && !isXtermInput && e.key !== 'Escape') {
|
||||
// Monaco is not always contentEditable/input, so treat it as an editor surface.
|
||||
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -501,6 +802,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const keyStr = isMac ? binding.mac : binding.pc;
|
||||
if (matchesKeyBinding(e, keyStr, isMac)) {
|
||||
if (HOTKEY_DEBUG) console.log('[Hotkeys] Matched binding:', binding.action, keyStr);
|
||||
// SFTP shortcuts are handled by SFTP-specific hooks.
|
||||
if (binding.category === 'sftp') {
|
||||
continue;
|
||||
}
|
||||
// Terminal-specific actions should be handled by the terminal
|
||||
// Don't handle them at app level
|
||||
const terminalActions = ['copy', 'paste', 'selectAll', 'clearBuffer', 'searchTerminal'];
|
||||
@@ -556,7 +861,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
(h.group || '').toLowerCase().includes(term)
|
||||
)
|
||||
: hosts;
|
||||
return filtered.slice(0, 8);
|
||||
return filtered;
|
||||
}, [hosts, quickSearch, isQuickSwitcherOpen]);
|
||||
|
||||
const handleDeleteHost = useCallback((hostId: string) => {
|
||||
@@ -607,6 +912,25 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host) => {
|
||||
const { username, hostname: localHost } = systemInfoRef.current;
|
||||
|
||||
// Handle serial hosts separately
|
||||
if (host.protocol === 'serial') {
|
||||
const portName = host.hostname.split('/').pop() || host.hostname;
|
||||
addConnectionLog({
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: host.hostname,
|
||||
username: username,
|
||||
protocol: 'serial',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
connectToHost(host);
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
|
||||
const resolvedAuth = resolveHostAuth({ host, keys, identities });
|
||||
addConnectionLog({
|
||||
@@ -623,6 +947,24 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
connectToHost(host);
|
||||
}, [addConnectionLog, connectToHost, identities, keys]);
|
||||
|
||||
// Wrapper to create serial session with logging
|
||||
const handleConnectSerial = useCallback((config: SerialConfig) => {
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
const portName = config.path.split('/').pop() || config.path;
|
||||
addConnectionLog({
|
||||
hostId: '',
|
||||
hostLabel: `Serial: ${portName}`,
|
||||
hostname: config.path,
|
||||
username: username,
|
||||
protocol: 'serial',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: hostname,
|
||||
saved: false,
|
||||
});
|
||||
createSerialSession(config);
|
||||
}, [addConnectionLog, createSerialSession]);
|
||||
|
||||
// Handle terminal data capture when session exits
|
||||
const handleTerminalDataCapture = useCallback((sessionId: string, data: string) => {
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Called', { sessionId, dataLength: data.length });
|
||||
@@ -656,10 +998,32 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
terminalData: data,
|
||||
});
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Updated log with terminalData');
|
||||
|
||||
// Auto-save session log if enabled
|
||||
if (sessionLogsEnabled && sessionLogsDir && data) {
|
||||
import('./infrastructure/services/netcattyBridge').then(({ netcattyBridge }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.autoSaveSessionLog) {
|
||||
bridge.autoSaveSessionLog({
|
||||
terminalData: data,
|
||||
hostLabel: matchingLog.hostLabel,
|
||||
hostname: matchingLog.hostname,
|
||||
hostId: matchingLog.hostId,
|
||||
startTime: matchingLog.startTime,
|
||||
format: sessionLogsFormat,
|
||||
directory: sessionLogsDir,
|
||||
}).then(result => {
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Auto-save result:', result);
|
||||
}).catch(err => {
|
||||
console.error('[handleTerminalDataCapture] Auto-save failed:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] No matching log found!');
|
||||
}
|
||||
}, [sessions, connectionLogs, updateConnectionLog]);
|
||||
}, [sessions, connectionLogs, updateConnectionLog, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat]);
|
||||
|
||||
// Check if host has multiple protocols enabled
|
||||
const hasMultipleProtocols = useCallback((host: Host) => {
|
||||
@@ -719,12 +1083,64 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
})();
|
||||
}, [openSettingsWindow, t]);
|
||||
|
||||
const hasShownCredentialProtectionWarningRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasShownCredentialProtectionWarningRef.current) return;
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const available = await getCredentialProtectionAvailability();
|
||||
if (cancelled || available !== false) return;
|
||||
hasShownCredentialProtectionWarningRef.current = true;
|
||||
|
||||
toast.warning(t('credentials.protectionUnavailable.message'), {
|
||||
title: t('credentials.protectionUnavailable.title'),
|
||||
actionLabel: t('credentials.protectionUnavailable.action'),
|
||||
duration: 10000,
|
||||
onClick: handleOpenSettings,
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [handleOpenSettings, t]);
|
||||
|
||||
const handleEndSessionDrag = useCallback(() => {
|
||||
setDraggingSessionId(null);
|
||||
}, [setDraggingSessionId]);
|
||||
|
||||
const handleRootContextMenu = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const editableSelector =
|
||||
"input, textarea, [contenteditable], .monaco-editor, .monaco-diff-editor, .monaco-inputbox, .monaco-menu-container";
|
||||
|
||||
const nativeEvent = e.nativeEvent;
|
||||
const path = typeof nativeEvent.composedPath === "function" ? nativeEvent.composedPath() : [];
|
||||
const allowFromPath = path.some(
|
||||
(node) => node instanceof Element && !!node.closest(editableSelector),
|
||||
);
|
||||
|
||||
const target = e.target;
|
||||
const targetElement =
|
||||
target instanceof Element
|
||||
? target
|
||||
: target instanceof Node
|
||||
? target.parentElement
|
||||
: null;
|
||||
const allowFromTarget = !!targetElement?.closest(editableSelector);
|
||||
|
||||
const allowNativeContextMenu = allowFromPath || allowFromTarget;
|
||||
|
||||
if (allowNativeContextMenu) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={(e) => e.preventDefault()}>
|
||||
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
|
||||
<TopTabs
|
||||
theme={theme}
|
||||
sessions={sessions}
|
||||
@@ -736,6 +1152,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
isMacClient={isMacClient}
|
||||
onCloseSession={closeSession}
|
||||
onRenameSession={startSessionRename}
|
||||
onCopySession={copySession}
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
onCloseWorkspace={closeWorkspace}
|
||||
onCloseLogView={closeLogView}
|
||||
@@ -760,10 +1177,14 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
knownHosts={knownHosts}
|
||||
shellHistory={shellHistory}
|
||||
connectionLogs={connectionLogs}
|
||||
managedSources={managedSources}
|
||||
sessions={sessions}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
onConnectSerial={handleConnectSerial}
|
||||
onDeleteHost={handleDeleteHost}
|
||||
onConnect={handleConnectToHost}
|
||||
onUpdateHosts={updateHosts}
|
||||
@@ -773,6 +1194,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onUpdateSnippetPackages={updateSnippetPackages}
|
||||
onUpdateCustomGroups={updateCustomGroups}
|
||||
onUpdateKnownHosts={updateKnownHosts}
|
||||
onUpdateManagedSources={updateManagedSources}
|
||||
onClearAndRemoveManagedSource={clearAndRemoveSource}
|
||||
onClearAndRemoveManagedSources={clearAndRemoveSources}
|
||||
onUnmanageSource={unmanageSource}
|
||||
onConvertKnownHost={convertKnownHostToHost}
|
||||
onToggleConnectionLogSaved={toggleConnectionLogSaved}
|
||||
onDeleteConnectionLog={deleteConnectionLog}
|
||||
@@ -863,8 +1288,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
setQuickSearch('');
|
||||
}}
|
||||
onCreateWorkspace={() => {
|
||||
// TODO: Implement workspace creation
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setIsCreateWorkspaceOpen(true);
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
@@ -929,6 +1354,17 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{isCreateWorkspaceOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<LazyCreateWorkspaceDialog
|
||||
isOpen={isCreateWorkspaceOpen}
|
||||
onClose={() => setIsCreateWorkspaceOpen(false)}
|
||||
hosts={hosts}
|
||||
onCreate={createWorkspaceWithHosts}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* Protocol Select Dialog for QuickSwitcher */}
|
||||
{protocolSelectHost && (
|
||||
<Suspense fallback={null}>
|
||||
@@ -939,6 +1375,27 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* Global Keyboard-Interactive Authentication Modal (2FA/MFA) - processes queue */}
|
||||
<KeyboardInteractiveModal
|
||||
request={keyboardInteractiveQueue[0] || null}
|
||||
onSubmit={handleKeyboardInteractiveSubmit}
|
||||
onCancel={handleKeyboardInteractiveCancel}
|
||||
/>
|
||||
{/* Indicator when more 2FA requests are pending */}
|
||||
{keyboardInteractiveQueue.length > 1 && (
|
||||
<div className="fixed bottom-4 right-4 z-50 bg-muted/90 backdrop-blur-sm text-sm px-3 py-1.5 rounded-full border shadow-sm">
|
||||
{keyboardInteractiveQueue.length - 1} more pending
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global Passphrase Modal for encrypted SSH keys */}
|
||||
<PassphraseModal
|
||||
request={passphraseQueue[0] || null}
|
||||
onSubmit={handlePassphraseSubmit}
|
||||
onCancel={handlePassphraseCancel}
|
||||
onSkip={handlePassphraseSkip}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
252
README.ja-JP.md
252
README.ja-JP.md
@@ -5,33 +5,26 @@
|
||||
<h1 align="center">Netcatty</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>モダンな SSH クライアント、SFTP ブラウザ & ターミナルマネージャー</strong>
|
||||
<strong>モダンな SSH クライアント、SFTP ブラウザ & ターミナルマネージャー</strong><br/>
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Electron、React、xterm.js で構築された機能豊富な SSH ワークスペース。<br/>
|
||||
ホスト管理、分割ターミナル、SFTP、ポートフォワーディング、クラウド同期 — すべてが一つに。
|
||||
分割ターミナル、Vault ビュー、SFTP ワークフロー、カスタムテーマ、キーワードハイライト — すべてが一つに。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
|
||||
|
||||
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
|
||||
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge&logo=electron"></a>
|
||||
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg">
|
||||
<img src="https://img.shields.io/badge/ダウンロード-macOS%20ARM64-000?style=for-the-badge&logo=apple" alt="macOS ARM64 をダウンロード">
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg">
|
||||
<img src="https://img.shields.io/badge/ダウンロード-macOS%20Intel-000?style=for-the-badge&logo=apple" alt="macOS Intel をダウンロード">
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe">
|
||||
<img src="https://img.shields.io/badge/ダウンロード-Windows%20x64-0078D4?style=for-the-badge&logo=windows" alt="Windows をダウンロード">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=最新版をダウンロード&color=success" alt="最新版をダウンロード">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -54,15 +47,13 @@
|
||||
# 目次 <!-- omit in toc -->
|
||||
|
||||
- [Netcatty とは](#netcatty-とは)
|
||||
- [なぜ Netcatty](#なぜ-netcatty)
|
||||
- [機能](#機能)
|
||||
- [デモ](#デモ)
|
||||
- [スクリーンショット](#スクリーンショット)
|
||||
- [ホスト管理](#ホスト管理)
|
||||
- [ターミナル](#ターミナル)
|
||||
- [SFTP](#sftp)
|
||||
- [キーチェーン](#キーチェーン)
|
||||
- [ポートフォワーディング](#ポートフォワーディング)
|
||||
- [クラウド同期](#クラウド同期)
|
||||
- [テーマとカスタマイズ](#テーマとカスタマイズ)
|
||||
- [メインウィンドウ](#メインウィンドウ)
|
||||
- [Vault ビュー](#vault-ビュー)
|
||||
- [分割ターミナル](#分割ターミナル)
|
||||
- [対応ディストリビューション](#対応ディストリビューション)
|
||||
- [はじめに](#はじめに)
|
||||
- [ビルドとパッケージ](#ビルドとパッケージ)
|
||||
@@ -78,174 +69,119 @@
|
||||
**Netcatty** は、複数のリモートサーバーを効率的に管理する必要がある開発者、システム管理者、DevOps エンジニア向けに設計された、モダンなクロスプラットフォーム SSH クライアントおよびターミナルマネージャーです。
|
||||
|
||||
- **Netcatty は** PuTTY、Termius、SecureCRT、macOS Terminal.app の代替となる SSH 接続ツール
|
||||
- **Netcatty は** デュアルペインファイルブラウザを備えた強力な SFTP クライアント
|
||||
- **Netcatty は** 強力な SFTP クライアント(ドラッグ&ドロップ + 内蔵エディタ)
|
||||
- **Netcatty は** 分割ペイン、タブ、セッション管理を備えたターミナルワークスペース
|
||||
- **Netcatty は** シェルの代替ではありません — SSH/Telnet またはローカルターミナル経由でリモートシェルに接続します
|
||||
- **Netcatty は** シェルの代替ではありません — SSH/Telnet/Mosh やローカル/シリアル経由でシェルに接続します(環境により異なります)
|
||||
|
||||
---
|
||||
|
||||
<a name="なぜ-netcatty"></a>
|
||||
# なぜ Netcatty
|
||||
|
||||
複数サーバーを日常的に扱うなら、Netcatty は「スピード」と「流れ」を重視した作りになっています:
|
||||
|
||||
- **ワークスペース中心** — 分割ペインで複数セッションを並行操作
|
||||
- **Vault の見やすさ** — グリッド/リスト/ツリーで状況に合わせて切り替え
|
||||
- **SFTP の作業感** — ドラッグ&ドロップと内蔵エディタでサクッと編集
|
||||
|
||||
---
|
||||
|
||||
<a name="機能"></a>
|
||||
# 機能
|
||||
|
||||
### 🖥️ ターミナルとセッション
|
||||
- **xterm.js ベースのターミナル**、GPU アクセラレーションレンダリング対応
|
||||
### 🗂️ Vault
|
||||
- **複数ビュー** — グリッド / リスト / ツリー
|
||||
- **高速検索** — ホストやグループを素早く見つける
|
||||
|
||||
### 🖥️ ターミナルワークスペース
|
||||
- **分割ペイン** — 水平・垂直分割でマルチタスク
|
||||
- **タブ管理** — ドラッグ&ドロップで並べ替え可能な複数セッション
|
||||
- **セッション永続化** — 再起動後もセッションを復元
|
||||
- **ブロードキャストモード** — 一度の入力で複数のターミナルに送信
|
||||
- **セッション管理** — 複数の接続を並行して扱う
|
||||
|
||||
### 🔐 SSH クライアント
|
||||
- **SSH2 プロトコル**、完全な認証サポート
|
||||
- **パスワード&キー認証**
|
||||
- **SSH 証明書**サポート
|
||||
- **ジャンプホスト / 踏み台サーバー** — 複数ホストを経由した接続
|
||||
- **プロキシサポート** — HTTP CONNECT および SOCKS5 プロキシ
|
||||
- **エージェント転送** — OpenSSH Agent および Pageant 対応
|
||||
- **環境変数** — ホストごとにカスタム環境変数を設定
|
||||
### 📁 SFTP + 内蔵エディタ
|
||||
- **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード
|
||||
- **その場で編集** — 内蔵エディタで小さな修正を素早く
|
||||
|
||||
### 📁 SFTP
|
||||
- **デュアルペインファイルブラウザ** — ローカル ↔ リモート または リモート ↔ リモート
|
||||
- **ドラッグ&ドロップ**ファイル転送
|
||||
- **キュー管理**でバッチ転送
|
||||
- **進捗追跡**、転送速度表示
|
||||
### 🎨 パーソナライズ
|
||||
- **カスタムテーマ** — UI の見た目を好みに調整
|
||||
- **キーワードハイライト** — ターミナル出力の強調表示ルールをカスタマイズ
|
||||
|
||||
### 🔑 キーチェーン
|
||||
- **SSH キー生成** — RSA、ECDSA、ED25519
|
||||
- **既存キーのインポート** — PEM、OpenSSH 形式
|
||||
- **SSH 証明書**サポート
|
||||
- **アイデンティティ管理** — 再利用可能なユーザー名+認証方式の組み合わせ
|
||||
- **公開鍵をエクスポート**してリモートホストへ
|
||||
---
|
||||
|
||||
### 🔌 ポートフォワーディング
|
||||
- **ローカルフォワーディング** — リモートサービスをローカルに公開
|
||||
- **リモートフォワーディング** — ローカルサービスをリモートに公開
|
||||
- **ダイナミックフォワーディング** — SOCKS5 プロキシ
|
||||
- **ビジュアルトンネル管理**
|
||||
<a name="デモ"></a>
|
||||
# デモ
|
||||
|
||||
### ☁️ クラウド同期
|
||||
- **エンドツーエンド暗号化同期** — デバイスを離れる前にデータを暗号化
|
||||
- **複数のプロバイダー** — GitHub Gist、S3 互換ストレージ、WebDAV、Google Drive、OneDrive
|
||||
- **ホスト、キー、スニペット、設定を同期**
|
||||
GIF で機能をさっと確認できます(素材は `screenshots/gifs/`):
|
||||
|
||||
### 🎨 テーマとカスタマイズ
|
||||
- **ライト&ダークモード**
|
||||
- **カスタムアクセントカラー**
|
||||
- **50以上のターミナル配色**
|
||||
- **フォントカスタマイズ** — JetBrains Mono、Fira Code など
|
||||
- **多言語対応** — English、简体中文 など
|
||||
### Vault ビュー:グリッド / リスト / ツリー
|
||||
状況に合わせて見え方を切り替え。グリッドで全体像、リストで密度、ツリーで階層を扱えます。
|
||||
|
||||

|
||||
|
||||
### 分割ターミナル + セッション管理
|
||||
複数セッションを分割ペインで並べて作業。関連タスクを横並びにしてコンテキストスイッチを減らします。
|
||||
|
||||

|
||||
|
||||
### SFTP:ドラッグ&ドロップ + 内蔵エディタ
|
||||
ドラッグ&ドロップでファイルを移動し、内蔵エディタでそのまま編集できます。
|
||||
|
||||

|
||||
|
||||
### ドラッグでアップロード
|
||||
ファイルをそのままドロップしてアップロードを開始。ダイアログ操作を減らせます。
|
||||
|
||||

|
||||
|
||||
### カスタムテーマ
|
||||
テーマを調整して自分の好みに合わせた見た目に。
|
||||
|
||||

|
||||
|
||||
### キーワードハイライト
|
||||
重要な出力(エラー/警告/マーカーなど)を見つけやすくするために、ハイライトをカスタマイズできます。
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
<a name="スクリーンショット"></a>
|
||||
# スクリーンショット
|
||||
|
||||
<a name="ホスト管理"></a>
|
||||
## ホスト管理
|
||||
<a name="メインウィンドウ"></a>
|
||||
## メインウィンドウ
|
||||
|
||||
Vault ビューはすべての SSH 接続を管理するコマンドセンターです。右クリックメニューで階層的なグループを作成し、グループ間でホストをドラッグ、パンくずナビゲーションでホストツリーを素早く移動できます。各ホストは接続状態、OS アイコン、クイック接続ボタンを表示。グリッドとリストビューを切り替え、強力な検索で名前、ホスト名、タグ、グループでフィルタリングできます。
|
||||
メインウィンドウは、長時間の SSH 作業を前提に設計されています。セッション、ナビゲーション、主要ツールへ素早くアクセスできます。
|
||||
|
||||
**ダークモード**
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
**ライトモード**
|
||||
<a name="vault-ビュー"></a>
|
||||
## Vault ビュー
|
||||
|
||||

|
||||
作業に合わせて見え方を切り替え:グリッドで全体像、リストでスキャン、ツリーで整理と階層ナビゲーション。
|
||||
|
||||
**リストビュー**
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
<a name="ターミナル"></a>
|
||||
## ターミナル
|
||||

|
||||
|
||||
WebGL アクセラレーション対応の xterm.js ベースのターミナルで、スムーズでレスポンシブな体験を提供。ワークスペースを水平または垂直に分割して、複数のセッションを同時に監視。ブロードキャストモードを有効にすると、すべてのターミナルに一度にコマンドを送信できます — フリート管理に最適。テーマカスタマイズパネルでは、50以上の配色スキームをライブプレビュー、フォントサイズの調整、JetBrains Mono や Fira Code を含む複数のフォントファミリーを選択できます。
|
||||

|
||||
|
||||
**分割ウィンドウ**
|
||||
<a name="分割ターミナル"></a>
|
||||
## 分割ターミナル
|
||||
|
||||
分割ペインで複数のサーバー/タスクを同時に扱えます(例:デプロイ + ログ + 監視)。
|
||||
|
||||

|
||||
|
||||
**テーマカスタマイズ**
|
||||
|
||||

|
||||
|
||||
<a name="sftp"></a>
|
||||
## SFTP
|
||||
|
||||
デュアルペイン SFTP ブラウザは、ローカルからリモート、リモートからリモートへのファイル転送をサポート。シングルクリックでディレクトリを移動、ペイン間でファイルをドラッグ&ドロップ、転送進捗をリアルタイムで監視。インターフェースにはファイル権限、サイズ、変更日時を表示。複数の転送をキューに入れ、詳細な速度と進捗インジケーターで完了を確認。コンテキストメニューから名前変更、削除、ダウンロード、アップロード操作にすばやくアクセス。
|
||||
|
||||

|
||||
|
||||
<a name="キーチェーン"></a>
|
||||
## キーチェーン
|
||||
|
||||
キーチェーンは SSH 認証情報を保管する安全な保管庫です。新しいキーを生成、既存のキーをインポート、エンタープライズ認証用の SSH 証明書を管理できます。
|
||||
|
||||
| キータイプ | アルゴリズム | 推奨用途 |
|
||||
|----------|------------|---------|
|
||||
| **ED25519** | EdDSA | モダン、高速、最も安全(推奨) |
|
||||
| **ECDSA** | NIST P-256/384/521 | 高いセキュリティ、広くサポート |
|
||||
| **RSA** | RSA 2048/4096 | レガシー互換性、ユニバーサルサポート |
|
||||
| **証明書** | CA 署名 | エンタープライズ環境、短期認証 |
|
||||
|
||||
**機能:**
|
||||
- 🔑 カスタマイズ可能なビット長でキーを生成
|
||||
- 📥 PEM/OpenSSH 形式のキーをインポート
|
||||
- 👤 再利用可能なアイデンティティを作成(ユーザー名+認証方式)
|
||||
- 📤 ワンクリックで公開鍵をリモートホストにエクスポート
|
||||
|
||||

|
||||
|
||||
<a name="ポートフォワーディング"></a>
|
||||
## ポートフォワーディング
|
||||
|
||||
直感的なビジュアルインターフェースで SSH トンネルをセットアップ。各トンネルはリアルタイムステータスを表示し、アクティブ、接続中、エラー状態を明確に示します。トンネル設定を保存してセッション間で素早く再利用。
|
||||
|
||||
| タイプ | 方向 | ユースケース | 例 |
|
||||
|-------|-----|------------|---|
|
||||
| **ローカル** | リモート → ローカル | リモートサービスをローカルマシンでアクセス | リモート MySQL `3306` を `localhost:3306` に転送 |
|
||||
| **リモート** | ローカル → リモート | ローカルサービスをリモートサーバーと共有 | ローカル開発サーバーをリモートマシンに公開 |
|
||||
| **ダイナミック** | SOCKS5 プロキシ | SSH トンネル経由で安全にブラウジング | 暗号化された SSH 接続経由でインターネットをブラウズ |
|
||||
|
||||

|
||||
|
||||
<a name="クラウド同期"></a>
|
||||
## クラウド同期
|
||||
|
||||
エンドツーエンド暗号化で、すべてのデバイス間でホスト、キー、スニペット、設定を同期。マスターパスワードがアップロード前にすべてのデータをローカルで暗号化 — クラウドプロバイダーは平文を見ることはありません。
|
||||
|
||||
| プロバイダー | 最適な用途 | セットアップ複雑度 |
|
||||
|------------|----------|-----------------|
|
||||
| **GitHub Gist** | クイックセットアップ、バージョン履歴 | ⭐ 簡単 |
|
||||
| **Google Drive** | 個人利用、大容量ストレージ | ⭐ 簡単 |
|
||||
| **OneDrive** | Microsoft エコシステムユーザー | ⭐ 簡単 |
|
||||
| **S3 互換** | AWS、MinIO、Cloudflare R2、セルフホスト | ⭐⭐ 中程度 |
|
||||
| **WebDAV** | Nextcloud、ownCloud、セルフホスト | ⭐⭐ 中程度 |
|
||||
|
||||
**同期対象:**
|
||||
- ✅ ホストと接続設定
|
||||
- ✅ SSH キーと証明書
|
||||
- ✅ アイデンティティと認証情報
|
||||
- ✅ スニペットとスクリプト
|
||||
- ✅ カスタムグループとタグ
|
||||
- ✅ ポートフォワーディングルール
|
||||
- ✅ アプリケーション設定
|
||||
|
||||

|
||||
|
||||
<a name="テーマとカスタマイズ"></a>
|
||||
## テーマとカスタマイズ
|
||||
|
||||
Netcatty を自分だけのものに。ライトモードとダークモードを切り替えたり、システム設定に従わせたり。好みに合わせてアクセントカラーを選択。アプリケーションは English や简体中文を含む複数の言語をサポートしており、コミュニティによる翻訳貢献を歓迎しています。クラウド同期を有効にすると、すべての設定がデバイス間で同期され、パーソナライズされた体験がどこでも利用できます。
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
<a name="対応ディストリビューション"></a>
|
||||
# 対応ディストリビューション
|
||||
|
||||
Netcatty は接続したホストの OS アイコンを自動的に検出・表示します:
|
||||
Netcatty は接続したホストの OS を検出し、ホスト一覧でアイコンとして表示します:
|
||||
|
||||
<p align="center">
|
||||
<img src="public/distro/ubuntu.svg" width="48" alt="Ubuntu" title="Ubuntu">
|
||||
@@ -269,11 +205,13 @@ Netcatty は接続したホストの OS アイコンを自動的に検出・表
|
||||
|
||||
### ダウンロード
|
||||
|
||||
| プラットフォーム | アーキテクチャ | ダウンロード |
|
||||
|------------------|----------------|--------------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | [Netcatty-1.0.0-mac-arm64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg) |
|
||||
| **macOS** | Intel | [Netcatty-1.0.0-mac-x64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg) |
|
||||
| **Windows** | x64 | [Netcatty-1.0.0-win-x64.exe](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe) |
|
||||
[GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) からお使いのプラットフォームに対応した最新版をダウンロードしてください。
|
||||
|
||||
| OS | サポート状況 |
|
||||
| :--- | :--- |
|
||||
| **macOS** | Universal (x64 / arm64) |
|
||||
| **Windows** | x64 / arm64 |
|
||||
| **Linux** | x64 / arm64 |
|
||||
|
||||
または [GitHub Releases](https://github.com/binaricat/Netcatty/releases) ですべてのリリースを参照してください。
|
||||
|
||||
@@ -335,7 +273,7 @@ npm run pack
|
||||
# 特定のプラットフォーム用にパッケージ
|
||||
npm run pack:mac # macOS (DMG + ZIP)
|
||||
npm run pack:win # Windows (NSIS インストーラー)
|
||||
npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
npm run pack:linux # Linux (AppImage + DEB + RPM)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -345,7 +283,7 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
| カテゴリ | テクノロジー |
|
||||
|--------|------------|
|
||||
| フレームワーク | Electron 39 |
|
||||
| フレームワーク | Electron 40 |
|
||||
| フロントエンド | React 19, TypeScript |
|
||||
| ビルドツール | Vite 7 |
|
||||
| ターミナル | xterm.js 5 |
|
||||
|
||||
263
README.md
263
README.md
@@ -5,33 +5,26 @@
|
||||
<h1 align="center">Netcatty</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Modern SSH Client, SFTP Browser & Terminal Manager</strong>
|
||||
<strong>Modern SSH Client, SFTP Browser & Terminal Manager</strong><br/>
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A beautiful, feature-rich SSH workspace built with Electron, React, and xterm.js.<br/>
|
||||
Host management, split terminals, SFTP, port forwarding, and cloud sync — all in one.
|
||||
Split terminals, Vault views, SFTP workflows, custom themes, and keyword highlighting — all in one.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
|
||||
|
||||
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
|
||||
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge&logo=electron"></a>
|
||||
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg">
|
||||
<img src="https://img.shields.io/badge/Download-macOS%20ARM64-000?style=for-the-badge&logo=apple" alt="Download macOS ARM64">
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg">
|
||||
<img src="https://img.shields.io/badge/Download-macOS%20Intel-000?style=for-the-badge&logo=apple" alt="Download macOS Intel">
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe">
|
||||
<img src="https://img.shields.io/badge/Download-Windows%20x64-0078D4?style=for-the-badge&logo=windows" alt="Download Windows">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Download%20Latest&color=success" alt="Download Latest Release">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -54,15 +47,13 @@
|
||||
# Contents <!-- omit in toc -->
|
||||
|
||||
- [What is Netcatty](#what-is-netcatty)
|
||||
- [Why Netcatty](#why-netcatty)
|
||||
- [Features](#features)
|
||||
- [Demos](#demos)
|
||||
- [Screenshots](#screenshots)
|
||||
- [Host Management](#host-management)
|
||||
- [Terminal](#terminal)
|
||||
- [SFTP](#sftp)
|
||||
- [Keychain](#keychain)
|
||||
- [Port Forwarding](#port-forwarding)
|
||||
- [Cloud Sync](#cloud-sync)
|
||||
- [Themes & Customization](#themes--customization)
|
||||
- [Main Window](#main-window)
|
||||
- [Vault Views](#vault-views)
|
||||
- [Split Terminals](#split-terminals)
|
||||
- [Supported Distros](#supported-distros)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Build & Package](#build--package)
|
||||
@@ -75,171 +66,117 @@
|
||||
<a name="what-is-netcatty"></a>
|
||||
# What is Netcatty
|
||||
|
||||
**Netcatty** is a modern SSH client and terminal manager for macOS and Windows, designed for developers, sysadmins, and DevOps engineers who need to manage multiple remote servers efficiently.
|
||||
**Netcatty** is a modern SSH client and terminal manager for macOS, Windows, and Linux, designed for developers, sysadmins, and DevOps engineers who need to manage multiple remote servers efficiently.
|
||||
|
||||
- **Netcatty is** an alternative to PuTTY, Termius, SecureCRT, and macOS Terminal.app for SSH connections
|
||||
- **Netcatty is** a powerful SFTP client with dual-pane file browser
|
||||
- **Netcatty is** a terminal workspace with split panes, tabs, and session management
|
||||
- **Netcatty is not** a shell replacement — it connects to remote shells via SSH/Telnet or local terminals
|
||||
- **Netcatty supports** SSH, local terminal, Telnet, Mosh, and Serial connections (when available)
|
||||
- **Netcatty is not** a shell replacement — it connects to shells via SSH/Telnet/Mosh or local/serial sessions
|
||||
|
||||
---
|
||||
|
||||
<a name="why-netcatty"></a>
|
||||
# Why Netcatty
|
||||
|
||||
If you regularly work with a fleet of servers, Netcatty is built for speed and flow:
|
||||
|
||||
- **Workspace-first** — split panes + tabs + session restore for “always-on” workflows
|
||||
- **Vault organization** — grid/list/tree views with fast search and drag-friendly workflows
|
||||
- **Serious SFTP** — built-in editor + drag & drop + smooth file operations
|
||||
|
||||
---
|
||||
|
||||
<a name="features"></a>
|
||||
# Features
|
||||
|
||||
### 🖥️ Terminal & Sessions
|
||||
- **xterm.js-based terminal** with GPU-accelerated rendering
|
||||
### 🗂️ Vault
|
||||
- **Multiple views** — grid / list / tree
|
||||
- **Fast search** — locate hosts and groups quickly
|
||||
|
||||
### 🖥️ Terminal Workspaces
|
||||
- **Split panes** — horizontal and vertical splits for multi-tasking
|
||||
- **Tab management** — multiple sessions with drag-to-reorder
|
||||
- **Session persistence** — restore sessions on restart
|
||||
- **Broadcast mode** — type once, send to multiple terminals
|
||||
- **Session management** — run multiple connections side-by-side
|
||||
|
||||
### 🔐 SSH Client
|
||||
- **SSH2 protocol** with full authentication support
|
||||
- **Password & key-based authentication**
|
||||
- **SSH certificates** support
|
||||
- **Jump hosts / Bastion** — chain through multiple hosts
|
||||
- **Proxy support** — HTTP CONNECT and SOCKS5 proxies
|
||||
- **Agent forwarding** — including OpenSSH Agent and Pageant
|
||||
- **Environment variables** — set custom env vars per host
|
||||
### 📁 SFTP + Built-in Editor
|
||||
- **File workflows** — drag & drop uploads/downloads
|
||||
- **Edit in place** — built-in editor for quick changes
|
||||
|
||||
### 📁 SFTP
|
||||
- **Dual-pane file browser** — local ↔ remote or remote ↔ remote
|
||||
- **Drag & drop** file transfers
|
||||
- **Queue management** for batch transfers
|
||||
- **Progress tracking** with transfer speed
|
||||
### 🎨 Personalization
|
||||
- **Custom themes** — tune the app appearance to your taste
|
||||
- **Keyword highlighting** — customize highlight rules for terminal output
|
||||
|
||||
### 🔑 Keychain
|
||||
- **Generate SSH keys** — RSA, ECDSA, ED25519
|
||||
- **Import existing keys** — PEM, OpenSSH formats
|
||||
- **SSH certificates** support
|
||||
- **Identity management** — reusable username + auth combinations
|
||||
- **Export public keys** to remote hosts
|
||||
---
|
||||
|
||||
### 🔌 Port Forwarding
|
||||
- **Local forwarding** — expose remote services locally
|
||||
- **Remote forwarding** — expose local services remotely
|
||||
- **Dynamic forwarding** — SOCKS5 proxy
|
||||
- **Visual tunnel management**
|
||||
<a name="demos"></a>
|
||||
# Demos
|
||||
|
||||
### ☁️ Cloud Sync
|
||||
- **End-to-end encrypted sync** — your data is encrypted before leaving your device
|
||||
- **Multiple providers** — GitHub Gist, S3-compatible storage, WebDAV, Google Drive, OneDrive
|
||||
- **Sync hosts, keys, snippets, and settings**
|
||||
GIF previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
|
||||
|
||||
### 🎨 Themes & Customization
|
||||
- **Light & Dark mode**
|
||||
- **Custom accent colors**
|
||||
- **50+ terminal color schemes**
|
||||
- **Font customization** — JetBrains Mono, Fira Code, and more
|
||||
- **i18n support** — English, 简体中文, and more
|
||||
### Vault views: grid / list / tree
|
||||
Switch between different Vault views to match your workflow: overview in grid, dense scanning in list, and hierarchical navigation in tree.
|
||||
|
||||

|
||||
|
||||
### Split terminals + session management
|
||||
Work in multiple sessions at once with split panes. Keep related tasks side-by-side and reduce context switching.
|
||||
|
||||

|
||||
|
||||
### SFTP: drag & drop + built-in editor
|
||||
Move files with drag & drop, then edit quickly using the built-in editor without leaving the app.
|
||||
|
||||

|
||||
|
||||
### Drag file upload
|
||||
Drop files into the app to kick off uploads without hunting through dialogs.
|
||||
|
||||

|
||||
|
||||
### Custom themes
|
||||
Make Netcatty yours: customize themes and UI appearance.
|
||||
|
||||

|
||||
|
||||
### Keyword highlighting
|
||||
Highlight important terminal output so errors, warnings, and key events stand out at a glance.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
<a name="screenshots"></a>
|
||||
# Screenshots
|
||||
|
||||
<a name="host-management"></a>
|
||||
## Host Management
|
||||
<a name="main-window"></a>
|
||||
## Main Window
|
||||
|
||||
The Vault view is your command center for managing all SSH connections. Create hierarchical groups with right-click context menus, drag hosts between groups, and use breadcrumb navigation to quickly traverse your host tree. Each host displays its connection status, OS icon, and quick-connect button. Switch between grid and list views based on your preference, and use the powerful search to filter hosts by name, hostname, tags, or group.
|
||||
The main window is designed for long-running SSH workflows: quick access to sessions, navigation, and core tools in one place.
|
||||
|
||||
**Dark Mode**
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
**Light Mode**
|
||||
<a name="vault-views"></a>
|
||||
## Vault Views
|
||||
|
||||

|
||||
Organize and navigate your hosts using the view that best fits the moment: grid for overview, list for scanning, tree for structure.
|
||||
|
||||
**List View**
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
<a name="terminal"></a>
|
||||
## Terminal
|
||||

|
||||
|
||||
Powered by xterm.js with WebGL acceleration, the terminal delivers a smooth, responsive experience. Split your workspace horizontally or vertically to monitor multiple sessions simultaneously. Enable broadcast mode to send commands to all terminals at once — perfect for fleet management. The theme customization panel offers 50+ color schemes with live preview, adjustable font size, and multiple font family options including JetBrains Mono and Fira Code.
|
||||

|
||||
|
||||
**Split Windows**
|
||||
<a name="split-terminals"></a>
|
||||
## Split Terminals
|
||||
|
||||
Split panes help you monitor multiple servers/services at the same time (deploy + logs + metrics) without juggling windows.
|
||||
|
||||

|
||||
|
||||
**Theme Customization**
|
||||
|
||||

|
||||
|
||||
<a name="sftp"></a>
|
||||
## SFTP
|
||||
|
||||
The dual-pane SFTP browser supports local-to-remote and remote-to-remote file transfers. Navigate directories with single-click, drag files between panes, and monitor transfer progress in real-time. The interface shows file permissions, sizes, and modification dates. Queue multiple transfers and watch them complete with detailed speed and progress indicators. Context menus provide quick access to rename, delete, download, and upload operations.
|
||||
|
||||

|
||||
|
||||
<a name="keychain"></a>
|
||||
## Keychain
|
||||
|
||||
The Keychain is your secure vault for SSH credentials. Generate new keys, import existing ones, or manage SSH certificates for enterprise authentication.
|
||||
|
||||
| Key Type | Algorithm | Recommended Use |
|
||||
|----------|-----------|----------------|
|
||||
| **ED25519** | EdDSA | Modern, fast, most secure (recommended) |
|
||||
| **ECDSA** | NIST P-256/384/521 | Good security, widely supported |
|
||||
| **RSA** | RSA 2048/4096 | Legacy compatibility, universal support |
|
||||
| **Certificate** | CA-signed | Enterprise environments, short-lived auth |
|
||||
|
||||
**Features:**
|
||||
- 🔑 Generate keys with customizable bit lengths
|
||||
- 📥 Import PEM/OpenSSH format keys
|
||||
- 👤 Create reusable identities (username + auth method)
|
||||
- 📤 One-click export public keys to remote hosts
|
||||
|
||||

|
||||
|
||||
<a name="port-forwarding"></a>
|
||||
## Port Forwarding
|
||||
|
||||
Set up SSH tunnels with an intuitive visual interface. Each tunnel shows real-time status with clear indicators for active, connecting, or error states. Save tunnel configurations for quick reuse across sessions.
|
||||
|
||||
| Type | Direction | Use Case | Example |
|
||||
|------|-----------|----------|--------|
|
||||
| **Local** | Remote → Local | Access remote services on your machine | Forward remote MySQL `3306` to `localhost:3306` |
|
||||
| **Remote** | Local → Remote | Share local services with remote server | Expose local dev server to remote machine |
|
||||
| **Dynamic** | SOCKS5 Proxy | Secure browsing through SSH tunnel | Browse internet via encrypted SSH connection |
|
||||
|
||||

|
||||
|
||||
<a name="cloud-sync"></a>
|
||||
## Cloud Sync
|
||||
|
||||
Keep your hosts, keys, snippets, and settings synchronized across all your devices with end-to-end encryption. Your master password encrypts all data locally before upload — the cloud provider never sees plaintext.
|
||||
|
||||
| Provider | Best For | Setup Complexity |
|
||||
|----------|----------|------------------|
|
||||
| **GitHub Gist** | Quick setup, version history | ⭐ Easy |
|
||||
| **Google Drive** | Personal use, large storage | ⭐ Easy |
|
||||
| **OneDrive** | Microsoft ecosystem users | ⭐ Easy |
|
||||
| **S3-Compatible** | AWS, MinIO, Cloudflare R2, self-hosted | ⭐⭐ Medium |
|
||||
| **WebDAV** | Nextcloud, ownCloud, self-hosted | ⭐⭐ Medium |
|
||||
|
||||
**What syncs:**
|
||||
- ✅ Hosts & connection settings
|
||||
- ✅ SSH keys & certificates
|
||||
- ✅ Identities & credentials
|
||||
- ✅ Snippets & scripts
|
||||
- ✅ Custom groups & tags
|
||||
- ✅ Port forwarding rules
|
||||
- ✅ Application preferences
|
||||
|
||||

|
||||
|
||||
<a name="themes--customization"></a>
|
||||
## Themes & Customization
|
||||
|
||||
Make Netcatty truly yours with extensive customization options. Toggle between light and dark modes, or let the app follow your system preference. Pick any accent color to match your style. The application supports multiple languages including English and 简体中文, with more translations welcome via community contributions. All preferences sync across devices when cloud sync is enabled, so your personalized experience follows you everywhere.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
<a name="supported-distros"></a>
|
||||
@@ -262,18 +199,18 @@ Netcatty automatically detects and displays OS icons for connected hosts:
|
||||
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<a name="getting-started"></a>
|
||||
# Getting Started
|
||||
|
||||
### Download
|
||||
|
||||
| Platform | Architecture | Download |
|
||||
|----------|--------------|----------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | [Netcatty-1.0.0-mac-arm64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg) |
|
||||
| **macOS** | Intel | [Netcatty-1.0.0-mac-x64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg) |
|
||||
| **Windows** | x64 | [Netcatty-1.0.0-win-x64.exe](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe) |
|
||||
Download the latest release for your platform from [GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest).
|
||||
|
||||
| OS | Support |
|
||||
| :--- | :--- |
|
||||
| **macOS** | Universal (x64 / arm64) |
|
||||
| **Windows** | x64 / arm64 |
|
||||
| **Linux** | x64 / arm64 |
|
||||
|
||||
Or browse all releases at [GitHub Releases](https://github.com/binaricat/Netcatty/releases).
|
||||
|
||||
@@ -285,7 +222,7 @@ Or browse all releases at [GitHub Releases](https://github.com/binaricat/Netcatt
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 18+ and npm
|
||||
- macOS or Windows 10+
|
||||
- macOS, Windows 10+, or Linux
|
||||
|
||||
### Development
|
||||
|
||||
@@ -335,6 +272,7 @@ npm run pack
|
||||
# Package for specific platforms
|
||||
npm run pack:mac # macOS (DMG + ZIP)
|
||||
npm run pack:win # Windows (NSIS installer)
|
||||
npm run pack:linux # Linux (AppImage + DEB + RPM)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -344,7 +282,7 @@ npm run pack:win # Windows (NSIS installer)
|
||||
|
||||
| Category | Technology |
|
||||
|----------|------------|
|
||||
| Framework | Electron 39 |
|
||||
| Framework | Electron 40 |
|
||||
| Frontend | React 19, TypeScript |
|
||||
| Build Tool | Vite 7 |
|
||||
| Terminal | xterm.js 5 |
|
||||
@@ -370,6 +308,15 @@ See [agents.md](agents.md) for architecture overview and coding conventions.
|
||||
|
||||
---
|
||||
|
||||
<a name="contributors"></a>
|
||||
# Contributors
|
||||
|
||||
Thanks to all the people who contribute!
|
||||
|
||||
See: https://github.com/binaricat/Netcatty/graphs/contributors
|
||||
|
||||
---
|
||||
|
||||
<a name="license"></a>
|
||||
# License
|
||||
|
||||
|
||||
262
README.zh-CN.md
262
README.zh-CN.md
@@ -5,33 +5,26 @@
|
||||
<h1 align="center">Netcatty</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>现代化 SSH 客户端、SFTP 浏览器 & 终端管理器</strong>
|
||||
<strong>现代化 SSH 客户端、SFTP 浏览器 & 终端管理器</strong><br/>
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
一个基于 Electron、React 和 xterm.js 构建的功能丰富的 SSH 工作空间。<br/>
|
||||
主机管理、分屏终端、SFTP、端口转发、云同步 —— 一应俱全。
|
||||
分屏终端、Vault 多视图、SFTP 工作流、自定义主题、关键词高亮 —— 一应俱全。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
|
||||
|
||||
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
|
||||
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge&logo=electron"></a>
|
||||
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg">
|
||||
<img src="https://img.shields.io/badge/下载-macOS%20ARM64-000?style=for-the-badge&logo=apple" alt="下载 macOS ARM64">
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg">
|
||||
<img src="https://img.shields.io/badge/下载-macOS%20Intel-000?style=for-the-badge&logo=apple" alt="下载 macOS Intel">
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe">
|
||||
<img src="https://img.shields.io/badge/下载-Windows%20x64-0078D4?style=for-the-badge&logo=windows" alt="下载 Windows">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=下载最新版&color=success" alt="下载最新版">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -54,15 +47,13 @@
|
||||
# 目录 <!-- omit in toc -->
|
||||
|
||||
- [Netcatty 是什么](#netcatty-是什么)
|
||||
- [为什么是 Netcatty](#为什么是-netcatty)
|
||||
- [功能特性](#功能特性)
|
||||
- [演示](#演示)
|
||||
- [界面截图](#界面截图)
|
||||
- [主机管理](#主机管理)
|
||||
- [终端](#终端)
|
||||
- [SFTP](#sftp)
|
||||
- [密钥管理](#密钥管理)
|
||||
- [端口转发](#端口转发)
|
||||
- [云同步](#云同步)
|
||||
- [主题与定制](#主题与定制)
|
||||
- [主界面](#主界面)
|
||||
- [Vault 视图](#vault-视图)
|
||||
- [分屏终端](#分屏终端)
|
||||
- [支持的发行版](#支持的发行版)
|
||||
- [快速开始](#快速开始)
|
||||
- [构建与打包](#构建与打包)
|
||||
@@ -80,172 +71,118 @@
|
||||
- **Netcatty 是** PuTTY、Termius、SecureCRT 和 macOS Terminal.app 的现代替代品
|
||||
- **Netcatty 是** 一个强大的 SFTP 客户端,支持双窗格文件浏览
|
||||
- **Netcatty 是** 一个终端工作空间,支持分屏、标签页和会话管理
|
||||
- **Netcatty 不是** Shell 替代品 —— 它通过 SSH/Telnet 或本地终端连接到远程 Shell
|
||||
- **Netcatty 支持** SSH、本地终端、Telnet、Mosh、串口(Serial)等连接方式(视环境而定)
|
||||
- **Netcatty 不是** Shell 替代品 —— 它通过 SSH/Telnet/Mosh 或本地/串口会话连接到 Shell
|
||||
|
||||
---
|
||||
|
||||
<a name="为什么是-netcatty"></a>
|
||||
# 为什么是 Netcatty
|
||||
|
||||
如果你需要同时维护多台服务器,Netcatty 更像是“工作台”而不是单一终端:
|
||||
|
||||
- **以工作区为核心** —— 分屏 + 多会话并行,适合长期驻留的工作流
|
||||
- **Vault 管理** —— 网格/列表/树形视图,配合搜索与拖拽更顺手
|
||||
- **认真做的 SFTP** —— 内置编辑器 + 拖拽上传,文件操作更丝滑
|
||||
|
||||
---
|
||||
|
||||
<a name="功能特性"></a>
|
||||
# 功能特性
|
||||
|
||||
### 🖥️ 终端与会话
|
||||
- **基于 xterm.js 的终端**,支持 GPU 加速渲染
|
||||
- **分屏功能** —— 水平和垂直分割,多任务并行
|
||||
- **标签页管理** —— 多会话支持,拖拽排序
|
||||
- **会话持久化** —— 重启后恢复会话
|
||||
- **广播模式** —— 一次输入,发送到多个终端
|
||||
### 🗂️ Vault
|
||||
- **多种视图** —— 网格 / 列表 / 树形
|
||||
- **快速搜索** —— 迅速定位主机与分组
|
||||
|
||||
### 🔐 SSH 客户端
|
||||
- **SSH2 协议**,完整的认证支持
|
||||
- **密码和密钥认证**
|
||||
- **SSH 证书**支持
|
||||
- **跳板机 / 堡垒机** —— 多主机链式连接
|
||||
- **代理支持** —— HTTP CONNECT 和 SOCKS5 代理
|
||||
- **Agent 转发** —— 支持 OpenSSH Agent 和 Pageant
|
||||
- **环境变量** —— 为每个主机设置自定义环境变量
|
||||
### 🖥️ 终端工作区
|
||||
- **分屏** —— 水平/垂直分割,多任务并行
|
||||
- **多会话管理** —— 多连接并排处理
|
||||
|
||||
### 📁 SFTP
|
||||
- **双窗格文件浏览器** —— 本地 ↔ 远程 或 远程 ↔ 远程
|
||||
- **拖放传输** 文件
|
||||
- **队列管理** 批量传输
|
||||
- **进度跟踪** 显示传输速度
|
||||
### 📁 SFTP + 内置编辑器
|
||||
- **文件工作流** —— 拖拽上传/下载更直观
|
||||
- **就地编辑** —— 内置编辑器快速修改文件
|
||||
|
||||
### 🔑 密钥管理
|
||||
- **生成 SSH 密钥** —— RSA、ECDSA、ED25519
|
||||
- **导入已有密钥** —— PEM、OpenSSH 格式
|
||||
- **SSH 证书**支持
|
||||
- **身份管理** —— 可复用的用户名 + 认证方式组合
|
||||
- **导出公钥**到远程主机
|
||||
### 🎨 个性化
|
||||
- **自定义主题** —— 按喜好调整应用外观
|
||||
- **关键词高亮** —— 自定义终端输出高亮规则
|
||||
|
||||
### 🔌 端口转发
|
||||
- **本地转发** —— 将远程服务暴露到本地
|
||||
- **远程转发** —— 将本地服务暴露到远程
|
||||
- **动态转发** —— SOCKS5 代理
|
||||
- **可视化隧道管理**
|
||||
---
|
||||
|
||||
### ☁️ 云同步
|
||||
- **端到端加密同步** —— 数据在离开设备前加密
|
||||
- **多种存储后端** —— GitHub Gist、S3 兼容存储、WebDAV、Google Drive、OneDrive
|
||||
- **同步主机、密钥、代码片段和设置**
|
||||
<a name="演示"></a>
|
||||
# 演示
|
||||
|
||||
### 🎨 主题与定制
|
||||
- **浅色 & 深色模式**
|
||||
- **自定义强调色**
|
||||
- **50+ 终端配色方案**
|
||||
- **字体自定义** —— JetBrains Mono、Fira Code 等
|
||||
- **多语言支持** —— English、简体中文 等
|
||||
GIF 预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
|
||||
|
||||
### Vault 视图:网格 / 列表 / 树形
|
||||
根据不同场景自由切换视图:网格适合总览,列表适合密集浏览,树形适合层级导航与整理。
|
||||
|
||||

|
||||
|
||||
### 分屏终端 + 会话管理
|
||||
用分屏把多个会话并排放在同一个工作区里,降低来回切换窗口/标签页的成本。
|
||||
|
||||

|
||||
|
||||
### SFTP:拖拽 + 内置编辑器
|
||||
通过拖拽完成文件传输,并用内置编辑器快速修改文件内容,不用来回切换工具。
|
||||
|
||||

|
||||
|
||||
### 拖拽文件上传
|
||||
把文件直接拖进应用即可触发上传流程,省去多层对话框与路径选择。
|
||||
|
||||

|
||||
|
||||
### 自定义主题
|
||||
按自己的审美与习惯定制主题与界面外观,让日常使用更顺手。
|
||||
|
||||

|
||||
|
||||
### 关键词高亮
|
||||
让关键输出一眼可见:错误、告警或特定标记被高亮后更容易扫到与定位。
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
<a name="界面截图"></a>
|
||||
# 界面截图
|
||||
|
||||
<a name="主机管理"></a>
|
||||
## 主机管理
|
||||
<a name="主界面"></a>
|
||||
## 主界面
|
||||
|
||||
Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建层级分组,在分组间拖拽主机,使用面包屑导航快速遍历主机树。每个主机显示连接状态、操作系统图标和快速连接按钮。根据偏好在网格和列表视图之间切换,使用强大的搜索按名称、主机名、标签或分组过滤主机。
|
||||
主界面围绕长期 SSH 工作流设计:把会话、导航和常用工具集中到同一处,减少切换成本。
|
||||
|
||||
**深色模式**
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
**浅色模式**
|
||||
<a name="vault-视图"></a>
|
||||
## Vault 视图
|
||||
|
||||

|
||||
用更适合当前任务的方式管理与浏览主机:网格看全局,列表做筛选,树形做整理与层级导航。
|
||||
|
||||
**列表视图**
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
<a name="终端"></a>
|
||||
## 终端
|
||||

|
||||
|
||||
基于 xterm.js 的 WebGL 加速终端,提供流畅、响应迅速的体验。水平或垂直分割工作区,同时监控多个会话。启用广播模式可一次向所有终端发送命令 —— 非常适合批量管理。主题定制面板提供 50+ 配色方案和实时预览、可调节字号以及多种字体选择,包括 JetBrains Mono 和 Fira Code。
|
||||

|
||||
|
||||
**分屏窗口**
|
||||
<a name="分屏终端"></a>
|
||||
## 分屏终端
|
||||
|
||||
分屏适合同时处理多个任务(例如部署 + 日志 + 排障),不用频繁切换窗口。
|
||||
|
||||

|
||||
|
||||
**主题定制**
|
||||
|
||||

|
||||
|
||||
<a name="sftp"></a>
|
||||
## SFTP
|
||||
|
||||
双窗格 SFTP 浏览器支持本地到远程和远程到远程的文件传输。单击导航目录,在窗格之间拖放文件,实时监控传输进度。界面显示文件权限、大小和修改日期。批量传输队列管理,详细的速度和进度指示器。右键菜单快速访问重命名、删除、下载和上传操作。
|
||||
|
||||

|
||||
|
||||
<a name="密钥管理"></a>
|
||||
## 密钥管理
|
||||
|
||||
密钥库是您存储 SSH 凭证的安全保险库。生成新密钥、导入已有密钥或管理企业认证的 SSH 证书。
|
||||
|
||||
| 密钥类型 | 算法 | 推荐用途 |
|
||||
|---------|------|---------|
|
||||
| **ED25519** | EdDSA | 现代、快速、最安全(推荐) |
|
||||
| **ECDSA** | NIST P-256/384/521 | 安全性好、广泛支持 |
|
||||
| **RSA** | RSA 2048/4096 | 旧版兼容、通用支持 |
|
||||
| **证书** | CA 签名 | 企业环境、短期认证 |
|
||||
|
||||
**功能:**
|
||||
- 🔑 生成可自定义位长的密钥
|
||||
- 📥 导入 PEM/OpenSSH 格式密钥
|
||||
- 👤 创建可复用身份(用户名 + 认证方式)
|
||||
- 📤 一键导出公钥到远程主机
|
||||
|
||||

|
||||
|
||||
<a name="端口转发"></a>
|
||||
## 端口转发
|
||||
|
||||
通过直观的可视化界面设置 SSH 隧道。每个隧道显示实时状态,清晰指示活动、连接中或错误状态。保存隧道配置以便跨会话快速复用。
|
||||
|
||||
| 类型 | 方向 | 使用场景 | 示例 |
|
||||
|-----|-----|---------|-----|
|
||||
| **本地** | 远程 → 本地 | 在本机访问远程服务 | 将远程 MySQL `3306` 转发到 `localhost:3306` |
|
||||
| **远程** | 本地 → 远程 | 与远程服务器共享本地服务 | 将本地开发服务器暴露给远程机器 |
|
||||
| **动态** | SOCKS5 代理 | 通过 SSH 隧道安全浏览 | 通过加密 SSH 连接浏览互联网 |
|
||||
|
||||

|
||||
|
||||
<a name="云同步"></a>
|
||||
## 云同步
|
||||
|
||||
通过端到端加密在所有设备间同步主机、密钥、代码片段和设置。主密码在上传前本地加密所有数据 —— 云服务商永远看不到明文。
|
||||
|
||||
| 服务商 | 最适合 | 配置复杂度 |
|
||||
|-------|-------|----------|
|
||||
| **GitHub Gist** | 快速设置、版本历史 | ⭐ 简单 |
|
||||
| **Google Drive** | 个人使用、大容量存储 | ⭐ 简单 |
|
||||
| **OneDrive** | 微软生态用户 | ⭐ 简单 |
|
||||
| **S3 兼容存储** | AWS、MinIO、Cloudflare R2、自托管 | ⭐⭐ 中等 |
|
||||
| **WebDAV** | Nextcloud、ownCloud、自托管 | ⭐⭐ 中等 |
|
||||
|
||||
**同步内容:**
|
||||
- ✅ 主机与连接设置
|
||||
- ✅ SSH 密钥与证书
|
||||
- ✅ 身份与凭证
|
||||
- ✅ 代码片段与脚本
|
||||
- ✅ 自定义分组与标签
|
||||
- ✅ 端口转发规则
|
||||
- ✅ 应用程序偏好设置
|
||||
|
||||

|
||||
|
||||
<a name="主题与定制"></a>
|
||||
## 主题与定制
|
||||
|
||||
让 Netcatty 真正属于你。在浅色和深色模式之间切换,或让应用跟随系统偏好。选择任意强调色来匹配你的风格。应用支持多种语言,包括 English 和简体中文,欢迎社区贡献更多翻译。启用云同步后,所有偏好设置都会跨设备同步,个性化体验随处可用。
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
<a name="支持的发行版"></a>
|
||||
# 支持的发行版
|
||||
|
||||
Netcatty 自动检测并显示已连接主机的操作系统图标:
|
||||
Netcatty 会自动识别并在主机列表中展示对应的系统图标:
|
||||
|
||||
<p align="center">
|
||||
<img src="public/distro/ubuntu.svg" width="48" alt="Ubuntu" title="Ubuntu">
|
||||
@@ -262,18 +199,18 @@ Netcatty 自动检测并显示已连接主机的操作系统图标:
|
||||
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<a name="快速开始"></a>
|
||||
# 快速开始
|
||||
|
||||
### 下载
|
||||
|
||||
| 平台 | 架构 | 下载链接 |
|
||||
|------|------|----------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | [Netcatty-1.0.0-mac-arm64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg) |
|
||||
| **macOS** | Intel | [Netcatty-1.0.0-mac-x64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg) |
|
||||
| **Windows** | x64 | [Netcatty-1.0.0-win-x64.exe](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe) |
|
||||
从 [GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) 下载适合您平台的最新版本。
|
||||
|
||||
| 操作系统 | 支持情况 |
|
||||
| :--- | :--- |
|
||||
| **macOS** | Universal (x64 / arm64) |
|
||||
| **Windows** | x64 / arm64 |
|
||||
| **Linux** | x64 / arm64 |
|
||||
|
||||
或在 [GitHub Releases](https://github.com/binaricat/Netcatty/releases) 浏览所有版本。
|
||||
|
||||
@@ -345,7 +282,7 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
| 分类 | 技术 |
|
||||
|-----|-----|
|
||||
| 框架 | Electron 39 |
|
||||
| 框架 | Electron 40 |
|
||||
| 前端 | React 19, TypeScript |
|
||||
| 构建工具 | Vite 7 |
|
||||
| 终端 | xterm.js 5 |
|
||||
@@ -371,6 +308,15 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
---
|
||||
|
||||
<a name="贡献者"></a>
|
||||
# 贡献者
|
||||
|
||||
感谢所有参与贡献的人!
|
||||
|
||||
查看:https://github.com/binaricat/Netcatty/graphs/contributors
|
||||
|
||||
---
|
||||
|
||||
<a name="开源协议"></a>
|
||||
# 开源协议
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ This project is wired around three layers: domain (pure logic), application stat
|
||||
## Data & Storage
|
||||
- Persisted keys: see `storageKeys.ts`. Use `localStorageAdapter` for all reads/writes.
|
||||
- Seed data: `config/defaultData.ts`; terminal themes: `config/terminalThemes.ts`.
|
||||
- **Temporary files**: All temporary files (e.g., SFTP downloaded files for external editing) must be written to Netcatty's dedicated temp directory via `tempDirBridge.getTempFilePath(fileName)`. Do not write directly to `os.tmpdir()`. This ensures proper cleanup and user visibility in Settings > System.
|
||||
|
||||
## Testing & Safety
|
||||
- Favor unit tests for domain helpers (e.g., `workspace.ts`, `host.ts`) and hook-level tests for application state.
|
||||
|
||||
@@ -28,6 +28,7 @@ const en: Messages = {
|
||||
'common.noResultsFound': 'No results found',
|
||||
'common.back': 'Back',
|
||||
'common.apply': 'Apply',
|
||||
'common.use': 'Use',
|
||||
'common.saveChanges': 'Save Changes',
|
||||
'common.advanced': 'Advanced',
|
||||
'common.left': 'Left',
|
||||
@@ -38,6 +39,7 @@ const en: Messages = {
|
||||
'sort.za': 'Z-a',
|
||||
'sort.newest': 'Newest to oldest',
|
||||
'sort.oldest': 'Oldest to newest',
|
||||
'sort.group': 'By group',
|
||||
'field.label': 'Label',
|
||||
'field.type': 'Type',
|
||||
'auth.keyType': 'Type {type}',
|
||||
@@ -46,12 +48,18 @@ const en: Messages = {
|
||||
// Dialogs / prompts
|
||||
'confirm.deleteHost': 'Delete Host "{name}"?',
|
||||
'confirm.deleteIdentity': 'Delete Identity "{name}"?',
|
||||
'dialog.createWorkspace.title': 'Create Workspace',
|
||||
'dialog.renameWorkspace.title': 'Rename workspace',
|
||||
'dialog.renameSession.title': 'Rename session',
|
||||
'field.name': 'Name',
|
||||
'field.selectHosts': 'Select Hosts',
|
||||
'placeholder.workspaceName': 'Workspace name',
|
||||
'placeholder.sessionName': 'Session name',
|
||||
'placeholder.searchHosts': 'Search hosts...',
|
||||
'toast.settingsUnavailable': 'Settings window is unavailable on this platform.',
|
||||
'credentials.protectionUnavailable.title': 'Credential Protection Unavailable',
|
||||
'credentials.protectionUnavailable.message': 'Saved passwords and keys cannot be auto-decrypted on this device. Re-enter credentials before connecting.',
|
||||
'credentials.protectionUnavailable.action': 'Open Settings',
|
||||
|
||||
// Settings shell
|
||||
'settings.title': 'Settings',
|
||||
@@ -60,6 +68,76 @@ const en: Messages = {
|
||||
'settings.tab.terminal': 'Terminal',
|
||||
'settings.tab.shortcuts': 'Shortcuts',
|
||||
'settings.tab.syncCloud': 'Sync & Cloud',
|
||||
'settings.tab.system': 'System',
|
||||
|
||||
// Settings > System
|
||||
'settings.system.title': 'System',
|
||||
'settings.system.description': 'System information and temporary file management.',
|
||||
'settings.system.tempDirectory': 'Temporary Files',
|
||||
'settings.system.location': 'Location',
|
||||
'settings.system.fileCount': 'Files',
|
||||
'settings.system.totalSize': 'Size',
|
||||
'settings.system.openFolder': 'Open folder',
|
||||
'settings.system.refresh': 'Refresh',
|
||||
'settings.system.clearTempFiles': 'Clear temp files',
|
||||
'settings.system.clearing': 'Clearing...',
|
||||
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
|
||||
'settings.system.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
|
||||
'settings.system.credentials.title': 'Credential Protection',
|
||||
'settings.system.credentials.status': 'Status',
|
||||
'settings.system.credentials.checking': 'Checking...',
|
||||
'settings.system.credentials.available': 'Available (OS keychain ready)',
|
||||
'settings.system.credentials.unavailable': 'Unavailable (cannot decrypt saved credentials)',
|
||||
'settings.system.credentials.unknown': 'Unknown (not supported in this environment)',
|
||||
'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 > Session Logs
|
||||
'settings.sessionLogs.title': 'Session Logs',
|
||||
'settings.sessionLogs.description': 'Configure session log export and auto-save settings.',
|
||||
'settings.sessionLogs.autoSave': 'Auto-Save',
|
||||
'settings.sessionLogs.enableAutoSave': 'Enable auto-save',
|
||||
'settings.sessionLogs.enableAutoSaveDesc': 'Automatically save session logs when terminal sessions end.',
|
||||
'settings.sessionLogs.directory': 'Save Directory',
|
||||
'settings.sessionLogs.noDirectory': 'No directory selected',
|
||||
'settings.sessionLogs.browse': 'Browse',
|
||||
'settings.sessionLogs.openFolder': 'Open folder',
|
||||
'settings.sessionLogs.directoryHint': 'Logs will be organized by host in subdirectories.',
|
||||
'settings.sessionLogs.format': 'Log Format',
|
||||
'settings.sessionLogs.formatDesc': 'Choose the format for saved log files.',
|
||||
'settings.sessionLogs.formatTxt': 'Plain Text (.txt)',
|
||||
'settings.sessionLogs.formatRaw': 'Raw with ANSI (.log)',
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.hint': 'Session logs capture all terminal output for troubleshooting and auditing purposes.',
|
||||
|
||||
// Settings > Global Hotkey (Quake Mode)
|
||||
'settings.globalHotkey.title': 'Global Hotkey',
|
||||
'settings.globalHotkey.toggleWindow': 'Toggle Window',
|
||||
'settings.globalHotkey.toggleWindowDesc': 'Press a key combination to set a global shortcut for showing/hiding the window.',
|
||||
'settings.globalHotkey.notSet': 'Not set',
|
||||
'settings.globalHotkey.reset': 'Reset to default',
|
||||
'settings.globalHotkey.closeToTray': 'Close to System Tray',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window will minimize to the system tray instead of quitting.',
|
||||
'settings.globalHotkey.hint': 'Global hotkey works system-wide to quickly show or hide the window (Quake-style terminal).',
|
||||
|
||||
// Tray Panel
|
||||
'tray.openMainWindow': 'Open Main Window',
|
||||
'tray.sessions': 'Sessions',
|
||||
'tray.portForwarding': 'Port Forwarding',
|
||||
'tray.status.connected': 'Connected',
|
||||
'tray.status.connecting': 'Connecting',
|
||||
'tray.status.disconnected': 'Disconnected',
|
||||
'tray.status.active': 'Active',
|
||||
'tray.status.inactive': 'Inactive',
|
||||
'tray.status.error': 'Error',
|
||||
'tray.recentHosts': 'Recent Hosts',
|
||||
'tray.empty.title': 'Nothing here yet',
|
||||
'tray.empty.subtitle': 'Go connect to a server, they miss you 🚀',
|
||||
'tray.quit': 'Quit Netcatty',
|
||||
|
||||
// Vault Sidebar
|
||||
'vault.sidebar.collapse': 'Collapse sidebar',
|
||||
'vault.sidebar.expand': 'Expand sidebar',
|
||||
|
||||
// Settings > Application
|
||||
'settings.application.checkUpdates': 'Check for updates',
|
||||
@@ -103,9 +181,15 @@ const en: Messages = {
|
||||
'/* Example: */\n.terminal { background: #1a1a2e !important; }\n:root { --radius: 0.25rem; }',
|
||||
'settings.appearance.language': 'Language',
|
||||
'settings.appearance.language.desc': 'Choose the UI language',
|
||||
'settings.appearance.uiFont': 'Interface Font',
|
||||
'settings.appearance.uiFont.desc': 'Choose the font for the application interface',
|
||||
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': 'Terminal Theme',
|
||||
'settings.terminal.themeModal.title': 'Select Theme',
|
||||
'settings.terminal.themeModal.darkThemes': 'Dark Themes',
|
||||
'settings.terminal.themeModal.lightThemes': 'Light Themes',
|
||||
'settings.terminal.theme.selectButton': 'Select Theme',
|
||||
'settings.terminal.section.font': 'Font',
|
||||
'settings.terminal.section.cursor': 'Cursor',
|
||||
'settings.terminal.section.keyboard': 'Keyboard',
|
||||
@@ -166,6 +250,33 @@ const en: Messages = {
|
||||
'settings.terminal.scrollback.rows': 'Number of rows *',
|
||||
'settings.terminal.keywordHighlight.title': 'Keyword highlighting',
|
||||
'settings.terminal.keywordHighlight.resetColors': 'Reset to default colors',
|
||||
'settings.terminal.section.localShell': 'Local Shell',
|
||||
'settings.terminal.localShell.shell': 'Shell executable',
|
||||
'settings.terminal.localShell.shell.desc': 'Path to the shell executable (e.g., /bin/zsh, pwsh.exe). Leave empty for system default.',
|
||||
'settings.terminal.localShell.shell.placeholder': 'System default',
|
||||
'settings.terminal.localShell.shell.detected': 'Detected',
|
||||
'settings.terminal.localShell.shell.notFound': 'Shell executable not found',
|
||||
'settings.terminal.localShell.shell.isDirectory': 'Path is a directory, not an executable',
|
||||
'settings.terminal.localShell.startDir': 'Starting directory',
|
||||
'settings.terminal.localShell.startDir.desc': 'Directory to start in when opening a local terminal. Leave empty for home directory.',
|
||||
'settings.terminal.localShell.startDir.placeholder': 'Home directory',
|
||||
'settings.terminal.localShell.startDir.notFound': 'Directory not found',
|
||||
'settings.terminal.localShell.startDir.isFile': 'Path is a file, not a directory',
|
||||
'settings.terminal.section.connection': 'Connection',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets to server. Set to 0 to disable.',
|
||||
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
|
||||
'settings.terminal.serverStats.show': 'Show Server Stats',
|
||||
'settings.terminal.serverStats.show.desc': 'Display CPU, memory, and disk usage in the terminal statusbar (Linux servers only).',
|
||||
'settings.terminal.serverStats.refreshInterval': 'Refresh Interval',
|
||||
'settings.terminal.serverStats.refreshInterval.desc': 'How often to refresh server stats.',
|
||||
'settings.terminal.serverStats.seconds': 'seconds',
|
||||
|
||||
// Settings > Terminal > Rendering
|
||||
'settings.terminal.section.rendering': 'Rendering',
|
||||
'settings.terminal.rendering.renderer': 'Renderer',
|
||||
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use Canvas on low-memory devices. Changes take effect on new terminal sessions.',
|
||||
'settings.terminal.rendering.auto': 'Auto',
|
||||
|
||||
// Settings > Shortcuts
|
||||
'settings.shortcuts.section.scheme': 'Hotkey Scheme',
|
||||
@@ -182,6 +293,7 @@ const en: Messages = {
|
||||
'settings.shortcuts.category.terminal': 'Terminal',
|
||||
'settings.shortcuts.category.navigation': 'Navigation',
|
||||
'settings.shortcuts.category.app': 'App',
|
||||
'settings.shortcuts.category.sftp': 'SFTP',
|
||||
|
||||
// Context menus / common actions
|
||||
'action.newHost': 'New Host',
|
||||
@@ -223,6 +335,7 @@ const en: Messages = {
|
||||
'sync.autoSync.vaultLocked': 'Vault is locked. Open Settings → Sync & Cloud to unlock.',
|
||||
'sync.autoSync.conflictDetected': 'Sync conflict detected. Open Settings → Sync & Cloud to resolve.',
|
||||
'sync.autoSync.syncFailed': 'Sync failed',
|
||||
'sync.credentialsUnavailable': 'This device cannot decrypt some saved credentials. Re-enter credentials locally before syncing.',
|
||||
'time.never': 'Never',
|
||||
'time.justNow': 'Just now',
|
||||
'time.minutesAgo': '{minutes}m ago',
|
||||
@@ -246,6 +359,11 @@ const en: Messages = {
|
||||
'vault.groups.createDialog.desc': 'Create a new group for organizing hosts.',
|
||||
'vault.groups.renameDialogTitle': 'Rename Group',
|
||||
'vault.groups.renameDialog.desc': 'Rename an existing group.',
|
||||
'vault.groups.deleteDialogTitle': 'Delete Group',
|
||||
'vault.groups.deleteDialog.desc': 'This will permanently delete the group and move all hosts to the root level.',
|
||||
'vault.groups.deleteDialog.managedDesc': 'This is a managed SSH config group. Deleting it will also delete all hosts and unlink from the source file.',
|
||||
'vault.groups.deleteDialog.deleteHosts': 'Also delete all hosts in this group',
|
||||
'vault.groups.ungrouped': 'Ungrouped',
|
||||
'vault.groups.field.name': 'Group Name',
|
||||
'vault.groups.placeholder.example': 'e.g. Production',
|
||||
'vault.groups.parentLabel': 'Parent',
|
||||
@@ -253,6 +371,9 @@ const en: Messages = {
|
||||
'vault.groups.errors.required': 'Group name is required.',
|
||||
'vault.groups.errors.invalidChars': "Group name cannot include '/' or '\\\\'.",
|
||||
|
||||
'vault.managedSource.unmanage': 'Unmanage',
|
||||
'vault.managedSource.unmanageSuccess': 'Successfully unmanaged group',
|
||||
|
||||
'vault.hosts.header.entries': '{count} entries',
|
||||
'vault.hosts.header.live': '{count} live',
|
||||
|
||||
@@ -261,10 +382,28 @@ const en: Messages = {
|
||||
'vault.hosts.connect': 'Connect',
|
||||
'vault.view.grid': 'Grid',
|
||||
'vault.view.list': 'List',
|
||||
'vault.view.tree': 'Tree',
|
||||
'vault.tree.expandAll': 'Expand All',
|
||||
'vault.tree.collapseAll': 'Collapse All',
|
||||
'vault.hosts.newHost': 'New Host',
|
||||
'vault.hosts.newGroup': 'New Group',
|
||||
'vault.hosts.import': 'Import',
|
||||
'vault.hosts.export': 'Export',
|
||||
'vault.hosts.export.toast.success': 'Exported {count} hosts to CSV',
|
||||
'vault.hosts.export.toast.successWithSkipped': 'Exported {count} hosts to CSV ({skipped} unsupported hosts skipped)',
|
||||
'vault.hosts.export.toast.noHosts': 'No hosts to export',
|
||||
'vault.hosts.allHosts': 'All hosts',
|
||||
'vault.hosts.copyCredentials': 'Copy Credentials',
|
||||
'vault.hosts.copyCredentials.toast.success': 'Credentials copied to clipboard',
|
||||
'vault.hosts.copyCredentials.toast.noPassword': 'No password saved for this host',
|
||||
'vault.hosts.multiSelect': 'Multi-select',
|
||||
'vault.hosts.selected': '{count} selected',
|
||||
'vault.hosts.selectAll': 'Select All',
|
||||
'vault.hosts.deselectAll': 'Deselect All',
|
||||
'vault.hosts.deleteSelected': 'Delete ({count})',
|
||||
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': 'Add data to your vault',
|
||||
@@ -281,6 +420,18 @@ const en: Messages = {
|
||||
'vault.import.toast.summary':
|
||||
'Imported {count} hosts (skipped {skipped}, duplicates {duplicates}).',
|
||||
'vault.import.toast.firstIssue': 'First issue: {issue}',
|
||||
'vault.import.sshConfig.chooseMode': 'Choose how to import your SSH config file.',
|
||||
'vault.import.sshConfig.modeQuestion': 'How would you like to import?',
|
||||
'vault.import.sshConfig.importOnly': 'Import Only',
|
||||
'vault.import.sshConfig.importOnlyDesc': 'One-time import. Changes won\'t sync back to the file.',
|
||||
'vault.import.sshConfig.managed': 'Managed Sync',
|
||||
'vault.import.sshConfig.managedDesc': 'Keep in sync. Changes will be saved back to the file.',
|
||||
'vault.import.sshConfig.managedGroup': 'ssh config',
|
||||
'vault.import.sshConfig.managedSuccess': 'Imported {count} hosts. File is now managed.',
|
||||
'vault.import.sshConfig.alreadyManaged': 'This file is already being managed.',
|
||||
'vault.import.sshConfig.alreadyManagedDesc': 'This file is already managed under group "{group}". Remove the existing managed source first if you want to re-import.',
|
||||
'vault.import.sshConfig.noFilePath': 'Cannot manage this file.',
|
||||
'vault.import.sshConfig.noFilePathDesc': 'Unable to determine the file path. Managed sync requires access to the file system.',
|
||||
|
||||
// Known Hosts
|
||||
'knownHosts.search.placeholder': 'Search known hosts...',
|
||||
@@ -356,20 +507,36 @@ const en: Messages = {
|
||||
'pf.view.list': 'List',
|
||||
'pf.rule.summary.dynamic': 'SOCKS on {bindAddress}:{localPort}',
|
||||
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
|
||||
'pf.tooltip.relayHost': 'Relay Host',
|
||||
'pf.tooltip.hostLabel': 'Host',
|
||||
'pf.tooltip.hostAddress': 'Address',
|
||||
'pf.tooltip.noHost': 'No relay host configured',
|
||||
'pf.tooltip.localDesc': 'Local port forwarding: Access remote services through SSH tunnel',
|
||||
'pf.tooltip.remoteDesc': 'Remote port forwarding: Expose local services to remote host',
|
||||
'pf.tooltip.dynamicDesc': 'Dynamic SOCKS proxy: Route traffic through SSH tunnel',
|
||||
'pf.deleteActive.title': 'Delete Active Port Forwarding?',
|
||||
'pf.deleteActive.desc': 'This port forwarding rule "{label}" is currently active. Deleting it will stop the tunnel first.',
|
||||
'pf.deleteActive.confirm': 'Stop and Delete',
|
||||
'pf.form.autoStart': 'Auto Start',
|
||||
'pf.form.autoStartDesc': 'Automatically start this rule when the app launches',
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': 'New Folder',
|
||||
'sftp.newFile': 'New File',
|
||||
'sftp.filter': 'Filter',
|
||||
'sftp.filter.placeholder': 'Filter by filename...',
|
||||
'sftp.columns.name': 'Name',
|
||||
'sftp.columns.modified': 'Modified',
|
||||
'sftp.columns.size': 'Size',
|
||||
'sftp.columns.kind': 'Kind',
|
||||
'sftp.columns.actions': 'Actions',
|
||||
'sftp.emptyDirectory': 'Empty directory',
|
||||
'sftp.nav.up': 'Go up',
|
||||
'sftp.nav.home': 'Go to home',
|
||||
'sftp.nav.refresh': 'Refresh',
|
||||
'sftp.upload': 'Upload',
|
||||
'sftp.uploadFiles': 'Upload files',
|
||||
'sftp.uploadFolder': 'Upload folder',
|
||||
'sftp.dragDropToUpload': 'Drag and drop files here to upload',
|
||||
'sftp.retry': 'Retry',
|
||||
'sftp.context.open': 'Open',
|
||||
@@ -385,18 +552,27 @@ const en: Messages = {
|
||||
'sftp.itemsCount': '{count} items',
|
||||
'sftp.selectedCount': '{count} selected',
|
||||
'sftp.path.doubleClickToEdit': 'Double-click to edit path',
|
||||
'sftp.showHiddenPaths': 'Hidden paths',
|
||||
'sftp.task.waiting': 'Waiting...',
|
||||
'sftp.status.loading': 'Loading...',
|
||||
'sftp.status.uploading': 'Uploading...',
|
||||
'sftp.status.ready': 'Ready',
|
||||
'sftp.goUp': 'Go up',
|
||||
'sftp.encoding.label': 'Filename Encoding',
|
||||
'sftp.encoding.auto': 'Auto',
|
||||
'sftp.encoding.utf8': 'UTF-8',
|
||||
'sftp.encoding.gb18030': 'GB18030',
|
||||
'sftp.goHome': 'Go to home',
|
||||
'sftp.folderName': 'Folder name',
|
||||
'sftp.folderName.placeholder': 'Enter folder name',
|
||||
'sftp.fileName': 'File name',
|
||||
'sftp.fileName.placeholder': 'Enter file name',
|
||||
'sftp.prompt.newFolderName': 'New folder name?',
|
||||
'sftp.rename.title': 'Rename',
|
||||
'sftp.rename.newName': 'New name',
|
||||
'sftp.rename.placeholder': 'Enter new name',
|
||||
'sftp.confirm.deleteOne': 'Delete "{name}"?',
|
||||
'sftp.deleteConfirm.single': 'Delete "{name}"?',
|
||||
'sftp.deleteConfirm.title': 'Delete {count} item(s)?',
|
||||
'sftp.deleteConfirm.desc': 'This action cannot be undone. The following will be deleted:',
|
||||
'sftp.error.loadFailed': 'Failed to load directory',
|
||||
@@ -404,6 +580,13 @@ const en: Messages = {
|
||||
'sftp.error.uploadFailed': 'Upload failed',
|
||||
'sftp.error.deleteFailed': 'Delete failed',
|
||||
'sftp.error.createFolderFailed': 'Failed to create folder',
|
||||
'sftp.error.createFileFailed': 'Failed to create file',
|
||||
'sftp.error.invalidFileName': 'Filename contains invalid characters: {chars}',
|
||||
'sftp.error.reservedName': 'This filename is reserved by the system',
|
||||
'sftp.overwrite.title': 'File Already Exists',
|
||||
'sftp.overwrite.desc': 'A file named "{name}" already exists. Do you want to replace it?',
|
||||
'sftp.overwrite.confirm': 'Replace',
|
||||
'sftp.error.renameFailed': 'Failed to rename',
|
||||
'sftp.picker.title': 'Select Host',
|
||||
'sftp.picker.desc': 'Pick a host for the {side} pane',
|
||||
'sftp.picker.searchPlaceholder': 'Search hosts...',
|
||||
@@ -417,11 +600,16 @@ const en: Messages = {
|
||||
'sftp.permissions.others': 'Others',
|
||||
'sftp.permissions.octal': 'Octal',
|
||||
'sftp.permissions.symbolic': 'Symbolic',
|
||||
'sftp.permissions.success': 'Permissions updated successfully',
|
||||
'sftp.permissions.failed': 'Failed to update permissions',
|
||||
'sftp.pane.local': 'Local',
|
||||
'sftp.pane.remote': 'Remote',
|
||||
'sftp.pane.selectHost': 'Select host',
|
||||
'sftp.pane.selectHostToStart': 'Select a host to start',
|
||||
'sftp.pane.chooseFilesystem': 'Choose a local or remote filesystem to browse',
|
||||
'sftp.tabs.addTab': 'Add new tab',
|
||||
'sftp.tabs.closeTab': 'Close tab',
|
||||
'sftp.tabs.newTab': 'New Tab',
|
||||
'sftp.conflict.title': 'File Conflict',
|
||||
'sftp.conflict.desc': 'A file with the same name already exists at the destination',
|
||||
'sftp.conflict.alreadyExistsSuffix': 'already exists',
|
||||
@@ -434,11 +622,112 @@ const en: Messages = {
|
||||
'sftp.conflict.action.keepBoth': 'Keep Both',
|
||||
'sftp.conflict.action.replace': 'Replace',
|
||||
|
||||
// SFTP Upload Phases
|
||||
'sftp.upload.phase.compressing': 'Compressing',
|
||||
'sftp.upload.phase.uploading': 'Uploading',
|
||||
'sftp.upload.phase.extracting': 'Extracting',
|
||||
'sftp.upload.phase.compressed': 'Compressed',
|
||||
|
||||
// SFTP File Opener
|
||||
'sftp.context.openWith': 'Open with...',
|
||||
'sftp.context.edit': 'Edit',
|
||||
'sftp.context.preview': 'Preview',
|
||||
'sftp.opener.title': 'Open with',
|
||||
'sftp.opener.desc': 'Choose an application to open this file',
|
||||
'sftp.opener.builtInEditor': 'Built-in Editor',
|
||||
'sftp.opener.editDescription': 'Edit text files',
|
||||
'sftp.opener.builtInImageViewer': 'Built-in Image Viewer',
|
||||
'sftp.opener.previewDescription': 'Preview images',
|
||||
'sftp.opener.systemApp': 'Choose Application...',
|
||||
'sftp.opener.systemAppDescription': 'Select an application from your computer',
|
||||
'sftp.opener.onlySystemApp': 'This file can only be opened with an external application',
|
||||
'sftp.opener.noAppsAvailable': 'No applications available',
|
||||
'sftp.opener.noExtension': 'files without extension',
|
||||
'sftp.opener.setDefault': 'Always use this for {ext} files',
|
||||
'sftp.opener.confirmTitle': 'Set as Default?',
|
||||
'sftp.opener.confirmDescription': 'Do you want to always use {app} for {ext} files?',
|
||||
'sftp.opener.yesRemember': 'Yes, remember this choice',
|
||||
'sftp.opener.justOnce': 'Just this once',
|
||||
'sftp.opener.confirm.title': 'Set Default Application',
|
||||
'sftp.opener.confirm.desc': 'Do you want to always open .{ext} files with this application?',
|
||||
'sftp.editor.title': 'Text Editor',
|
||||
'sftp.editor.save': 'Save to Remote',
|
||||
'sftp.editor.saving': 'Saving...',
|
||||
'sftp.editor.saved': 'Saved successfully',
|
||||
'sftp.editor.saveFailed': 'Failed to save file',
|
||||
'sftp.editor.unsavedChanges': 'You have unsaved changes. Close anyway?',
|
||||
'sftp.editor.syntaxHighlight': 'Syntax Highlighting',
|
||||
'sftp.preview.title': 'Image Preview',
|
||||
'sftp.preview.zoomIn': 'Zoom In',
|
||||
'sftp.preview.zoomOut': 'Zoom Out',
|
||||
'sftp.preview.resetZoom': 'Reset Zoom',
|
||||
'sftp.preview.fitToWindow': 'Fit to Window',
|
||||
|
||||
// Settings > SFTP File Associations
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftpFileAssociations.title': 'SFTP File Associations',
|
||||
'settings.sftpFileAssociations.desc': 'Configure default applications for opening files by extension',
|
||||
'settings.sftpFileAssociations.extension': 'Extension',
|
||||
'settings.sftpFileAssociations.application': 'Application',
|
||||
'settings.sftpFileAssociations.noAssociations': 'No file associations configured',
|
||||
'settings.sftpFileAssociations.remove': 'Remove',
|
||||
'settings.sftpFileAssociations.removeConfirm': 'Remove association for .{ext}?',
|
||||
|
||||
// Settings > SFTP Behavior
|
||||
'settings.sftp.doubleClickBehavior': 'Double-click behavior',
|
||||
'settings.sftp.doubleClickBehavior.desc': 'Choose the action when double-clicking a file in SFTP View',
|
||||
'settings.sftp.doubleClickBehavior.open': 'Open file',
|
||||
'settings.sftp.doubleClickBehavior.transfer': 'Transfer to other pane',
|
||||
'settings.sftp.doubleClickBehavior.openDesc': 'Open the file in the default application',
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host',
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': 'Auto-sync to remote',
|
||||
'settings.sftp.autoSync.desc': 'Automatically sync file changes back to the remote server when opening files with external applications',
|
||||
'settings.sftp.autoSync.enable': 'Enable auto-sync',
|
||||
'settings.sftp.autoSync.enableDesc': 'When you save a file in an external application, changes will be automatically uploaded to the remote server',
|
||||
'sftp.autoSync.success': 'File synced to remote: {fileName}',
|
||||
'sftp.autoSync.error': 'Failed to sync file: {error}',
|
||||
|
||||
// SFTP Folder Upload Progress
|
||||
'sftp.upload.progress': 'Uploading {current} of {total} files...',
|
||||
'sftp.upload.uploading': 'Uploading...',
|
||||
'sftp.upload.compressing': 'Compressing...',
|
||||
'sftp.upload.extracting': 'Extracting...',
|
||||
'sftp.upload.scanning': 'Scanning files...',
|
||||
'sftp.upload.completed': 'Completed',
|
||||
'sftp.upload.compressed': 'Compressed Transfer',
|
||||
'sftp.upload.currentFile': 'Current: {fileName}',
|
||||
'sftp.upload.cancelled': 'Upload cancelled',
|
||||
'sftp.upload.cancel': 'Cancel',
|
||||
|
||||
// SFTP Download
|
||||
'sftp.download.completed': 'Downloaded',
|
||||
'sftp.download.cancelled': 'Download cancelled',
|
||||
|
||||
// SFTP Reconnecting
|
||||
'sftp.reconnecting.title': 'Reconnecting...',
|
||||
'sftp.reconnecting.desc': 'Connection lost, attempting to reconnect',
|
||||
'sftp.reconnected': 'Connection restored',
|
||||
'sftp.error.reconnectFailed': 'Failed to reconnect. Please try again.',
|
||||
'sftp.error.connectionLostManual': 'Connection lost. Please reconnect manually.',
|
||||
'sftp.error.connectionLostReconnecting': 'Connection lost. Reconnecting...',
|
||||
'sftp.error.sessionLost': 'SFTP session lost. Please reconnect.',
|
||||
|
||||
// Settings > SFTP Show Hidden Files
|
||||
'settings.sftp.showHiddenFiles': 'Show hidden files',
|
||||
'settings.sftp.showHiddenFiles.desc': 'Display files with the Windows hidden attribute in the SFTP file browser when browsing local Windows filesystem.',
|
||||
'settings.sftp.showHiddenFiles.enable': 'Show hidden files',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': 'Display Windows hidden files when browsing local filesystem',
|
||||
|
||||
// Settings > SFTP Compressed Upload
|
||||
'settings.sftp.compressedUpload': 'Folder Compression Transfer',
|
||||
'settings.sftp.compressedUpload.desc': 'Compress folders before uploading to significantly reduce transfer time.',
|
||||
'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.',
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Search hosts or tabs',
|
||||
'qs.recentConnections': 'Recent connections',
|
||||
'qs.createWorkspace': 'Create a workspace',
|
||||
'qs.restore': 'Restore',
|
||||
'qs.jumpTo': 'Jump To',
|
||||
'qs.localTerminal': 'Local Terminal',
|
||||
|
||||
@@ -472,6 +761,12 @@ const en: Messages = {
|
||||
'hostDetails.section.address': 'Address',
|
||||
'hostDetails.hostname.placeholder': 'IP or Hostname',
|
||||
'hostDetails.section.general': 'General',
|
||||
'hostDetails.section.sftp': 'SFTP Settings',
|
||||
'hostDetails.sftp.sudo': 'Sudo Mode',
|
||||
'hostDetails.sftp.sudo.desc': 'Automatically acquire Root privileges using stored password',
|
||||
'hostDetails.sftp.sudo.passwordWarning': 'Sudo mode requires a password. Configure one above, or ensure the server allows passwordless sudo.',
|
||||
'hostDetails.sftp.encoding': 'Filename Encoding',
|
||||
'hostDetails.sftp.encoding.desc': 'Select the encoding used to decode and send SFTP filenames.',
|
||||
'hostDetails.label.placeholder': 'Label (e.g., Production Server)',
|
||||
'hostDetails.group.placeholder': 'Parent Group',
|
||||
'hostDetails.section.credentials': 'Credentials',
|
||||
@@ -480,6 +775,9 @@ const en: Messages = {
|
||||
'hostDetails.section.mosh': 'Mosh',
|
||||
'hostDetails.username.placeholder': 'Username',
|
||||
'hostDetails.password.placeholder': 'Password',
|
||||
'hostDetails.password.show': 'Show password',
|
||||
'hostDetails.password.hide': 'Hide password',
|
||||
'hostDetails.password.save': 'Save password',
|
||||
'hostDetails.identity.suggestions': 'Identities',
|
||||
'hostDetails.identity.missing': 'Identity not found',
|
||||
'hostDetails.credential.keyCertificate': 'Key, Certificate',
|
||||
@@ -490,16 +788,20 @@ const en: Messages = {
|
||||
'hostDetails.keys.empty': 'No keys available',
|
||||
'hostDetails.certs.search': 'Search certificates...',
|
||||
'hostDetails.certs.empty': 'No certificates available',
|
||||
'hostDetails.agentForwarding': 'Agent Forwarding',
|
||||
'hostDetails.jumpHosts': 'Jump Hosts',
|
||||
'hostDetails.agentForwarding': 'Forward SSH Agent',
|
||||
'hostDetails.agentForwarding.desc': 'Allow remote server to use your local SSH keys (e.g., for git operations)',
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not running',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': 'Enable OpenSSH Authentication Agent service in Windows Services (services.msc) for agent forwarding to work.',
|
||||
'hostDetails.section.agentForwarding': 'SSH Agent',
|
||||
'hostDetails.jumpHosts': 'Proxy via Hosts',
|
||||
'hostDetails.jumpHosts.hops': '{count} hop(s)',
|
||||
'hostDetails.jumpHosts.direct': 'Direct',
|
||||
'hostDetails.jumpHosts.configure': 'Configure Jump Hosts',
|
||||
'hostDetails.proxy': 'Proxy',
|
||||
'hostDetails.jumpHosts.configure': 'Configure Proxy Hosts',
|
||||
'hostDetails.proxy': 'Proxy via HTTP/SOCKS5',
|
||||
'hostDetails.proxy.none': 'None',
|
||||
'hostDetails.proxy.edit': 'Edit Proxy',
|
||||
'hostDetails.proxy.configure': 'Configure Proxy',
|
||||
'hostDetails.proxyPanel.title': 'Proxy',
|
||||
'hostDetails.proxyPanel.title': 'Proxy via HTTP/SOCKS5',
|
||||
'hostDetails.proxyPanel.hostPlaceholder': 'Proxy host',
|
||||
'hostDetails.proxyPanel.credentials': 'Credentials',
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': 'Username',
|
||||
@@ -540,6 +842,12 @@ const en: Messages = {
|
||||
'hostDetails.telnet.password': 'Telnet Password',
|
||||
'hostDetails.charset.placeholder': 'Charset (e.g. UTF-8)',
|
||||
'hostDetails.telnet.add': 'Add Telnet Protocol',
|
||||
'hostDetails.tags': 'Tags',
|
||||
'hostDetails.group': 'Group',
|
||||
'hostDetails.selectGroup': 'Select Group',
|
||||
'hostDetails.addTag': 'Add a tag...',
|
||||
'hostDetails.createTag': 'Create tag',
|
||||
'hostDetails.createGroup': 'Create group',
|
||||
|
||||
// Host form (legacy modal)
|
||||
'hostForm.title.edit': 'Edit Host',
|
||||
@@ -574,7 +882,7 @@ const en: Messages = {
|
||||
'logs.empty.title': 'No Connection Logs',
|
||||
'logs.empty.desc':
|
||||
'Your connection history will appear here when you connect to hosts or open local terminals.',
|
||||
'logs.showing': 'Showing {limit} of {total} logs.',
|
||||
'logs.loadMore': 'Load {count} more logs',
|
||||
'logs.ongoing': 'ongoing',
|
||||
'logs.localTerminal': 'Local Terminal',
|
||||
'logs.action.save': 'Save',
|
||||
@@ -585,6 +893,7 @@ const en: Messages = {
|
||||
'logView.customizeAppearance': 'Customize appearance',
|
||||
'logView.appearance': 'Appearance',
|
||||
'logView.readOnly': 'Read-only',
|
||||
'logView.export': 'Export',
|
||||
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Open SFTP',
|
||||
@@ -602,6 +911,40 @@ const en: Messages = {
|
||||
'terminal.toolbar.focus': 'Focus',
|
||||
'terminal.toolbar.focusMode': 'Focus Mode',
|
||||
'terminal.toolbar.closeSession': 'Close session',
|
||||
'terminal.toolbar.hostHighlight.title': 'Host Keyword Highlighting',
|
||||
'terminal.toolbar.hostHighlight.noRules': 'No custom highlight rules defined for this host',
|
||||
'terminal.toolbar.hostHighlight.addRule': 'Add New Rule',
|
||||
'terminal.toolbar.hostHighlight.labelPlaceholder': 'Label (e.g., Error)',
|
||||
'terminal.toolbar.hostHighlight.patternPlaceholder': 'Regex pattern (e.g., \\bfailed\\b)',
|
||||
'terminal.toolbar.hostHighlight.invalidPattern': 'Invalid regex pattern',
|
||||
'terminal.toolbar.hostHighlight.clearAll': 'Clear All',
|
||||
'terminal.toolbar.hostHighlight.changeColor': 'Change highlight color for',
|
||||
'terminal.toolbar.hostHighlight.selectColor': 'Select color for new rule',
|
||||
'terminal.serverStats.cpu': 'CPU Usage',
|
||||
'terminal.serverStats.cpuCores': 'CPU Core Usage',
|
||||
'terminal.serverStats.memory': 'Memory Usage',
|
||||
'terminal.serverStats.memoryDetails': 'Memory Details',
|
||||
'terminal.serverStats.memUsed': 'Used',
|
||||
'terminal.serverStats.memBuffers': 'Buffers',
|
||||
'terminal.serverStats.memCached': 'Cache',
|
||||
'terminal.serverStats.memFree': 'Free',
|
||||
'terminal.serverStats.swap': 'Swap',
|
||||
'terminal.serverStats.swapUsed': 'Swap Used',
|
||||
'terminal.serverStats.swapFree': 'Swap Free',
|
||||
'terminal.serverStats.swapTotal': 'Total',
|
||||
'terminal.serverStats.topProcesses': 'Top Processes by Memory',
|
||||
'terminal.serverStats.disk': 'Disk Usage (Root)',
|
||||
'terminal.serverStats.diskDetails': 'Mounted Disks',
|
||||
'terminal.serverStats.network': 'Network Speed',
|
||||
'terminal.serverStats.networkDetails': 'Network Interfaces',
|
||||
'terminal.serverStats.noData': 'No data available',
|
||||
'terminal.dragDrop.localTitle': 'Drop to Insert Paths',
|
||||
'terminal.dragDrop.localMessage': 'File paths will be inserted into the terminal',
|
||||
'terminal.dragDrop.remoteTitle': 'Drop to Upload Files',
|
||||
'terminal.dragDrop.remoteMessage': 'Files will be uploaded via SFTP',
|
||||
'terminal.dragDrop.notConnected': 'Cannot drop files - terminal is not connected',
|
||||
'terminal.dragDrop.errorTitle': 'Drop Error',
|
||||
'terminal.dragDrop.errorMessage': 'Failed to process dropped files',
|
||||
'terminal.search.placeholder': 'Search...',
|
||||
'terminal.search.noResults': 'No results',
|
||||
'terminal.search.prevMatch': 'Previous match (Shift+Enter)',
|
||||
@@ -625,6 +968,10 @@ const en: Messages = {
|
||||
'terminal.auth.selectKey': 'Select Key',
|
||||
'terminal.auth.noKeysHint': 'No keys available. Add keys in Keychain.',
|
||||
'terminal.auth.continueSave': 'Continue & Save',
|
||||
'terminal.auth.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.',
|
||||
'terminal.auth.proxyCredentialsUnavailable': 'Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.',
|
||||
'terminal.auth.keyUnavailableFallbackPassword': 'Saved SSH key is unavailable on this device. Falling back to password authentication.',
|
||||
'terminal.progress.timeoutIn': 'Timeout in {seconds}s',
|
||||
'terminal.progress.disconnected': 'Disconnected',
|
||||
'terminal.progress.cancelling': 'Cancelling...',
|
||||
@@ -632,6 +979,11 @@ const en: Messages = {
|
||||
'terminal.connection.chainOf': 'Chain {current} of {total}',
|
||||
'terminal.connection.showLogs': 'Show logs',
|
||||
'terminal.connection.hideLogs': 'Hide logs',
|
||||
'terminal.connection.protocol.ssh': 'SSH',
|
||||
'terminal.connection.protocol.telnet': 'Telnet',
|
||||
'terminal.connection.protocol.mosh': 'Mosh',
|
||||
'terminal.connection.protocol.serial': 'Serial',
|
||||
'terminal.connection.protocol.local': 'Local Shell',
|
||||
'terminal.themeModal.title': 'Terminal Appearance',
|
||||
'terminal.themeModal.tab.theme': 'Theme',
|
||||
'terminal.themeModal.tab.font': 'Font',
|
||||
@@ -893,6 +1245,7 @@ const en: Messages = {
|
||||
'tabs.closeLogViewAria': 'Close log view',
|
||||
'tabs.logPrefix': 'Log:',
|
||||
'tabs.logLocal': 'Local',
|
||||
'tabs.copyTab': 'Copy Tab',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
'keychain.edit.keyLabelPlaceholder': 'Key label',
|
||||
'keychain.edit.privateKeyRequired': 'Private key *',
|
||||
@@ -943,6 +1296,91 @@ const en: Messages = {
|
||||
'snippets.packageDialog.root': 'Root',
|
||||
'snippets.packageDialog.placeholder': 'e.g. ops/maintenance',
|
||||
'snippets.packageDialog.hint': 'Use "/" to create nested packages.',
|
||||
|
||||
// Snippets Rename Dialog
|
||||
'snippets.renameDialog.title': 'Rename Package',
|
||||
'snippets.renameDialog.currentPath': 'Current path: {path}',
|
||||
'snippets.renameDialog.placeholder': 'Enter new name',
|
||||
'snippets.renameDialog.error.empty': 'Package name cannot be empty',
|
||||
'snippets.renameDialog.error.duplicate': 'A package with this name already exists',
|
||||
'snippets.renameDialog.error.invalidChars': 'Package name can only contain letters, numbers, hyphens, and underscores',
|
||||
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': 'Keyboard Shortcut',
|
||||
'snippets.shortkey.placeholder': 'Click to set shortcut',
|
||||
'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.snippetConflict': 'This shortcut is already used by snippet: {name}',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': 'Serial',
|
||||
'serial.modal.title': 'Connect to Serial Port',
|
||||
'serial.modal.desc': 'Configure serial port connection settings',
|
||||
'serial.field.port': 'Serial Port',
|
||||
'serial.field.selectPort': 'Select a port...',
|
||||
'serial.field.baudRate': 'Baud Rate',
|
||||
'serial.field.dataBits': 'Data Bits',
|
||||
'serial.field.stopBits': 'Stop Bits',
|
||||
'serial.field.stopBits15Warning': '1.5 stop bits may not be supported on all Windows devices',
|
||||
'serial.field.parity': 'Parity',
|
||||
'serial.field.flowControl': 'Flow Control',
|
||||
'serial.noPorts': 'No serial ports detected. Connect a device and refresh.',
|
||||
'serial.field.customPort': 'Custom Port Path',
|
||||
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001 or COM1',
|
||||
'serial.type.hardware': 'Hardware',
|
||||
'serial.type.pseudo': 'Pseudo Terminal',
|
||||
'serial.type.custom': 'Custom',
|
||||
'serial.parity.none': 'None',
|
||||
'serial.parity.even': 'Even',
|
||||
'serial.parity.odd': 'Odd',
|
||||
'serial.parity.mark': 'Mark',
|
||||
'serial.parity.space': 'Space',
|
||||
'serial.flowControl.none': 'None',
|
||||
'serial.flowControl.xon/xoff': 'XON/XOFF (Software)',
|
||||
'serial.flowControl.rts/cts': 'RTS/CTS (Hardware)',
|
||||
'serial.field.localEcho': 'Force Local Echo',
|
||||
'serial.field.localEchoDesc': 'Echo typed characters locally (for devices without remote echo)',
|
||||
'serial.field.lineMode': 'Line Mode',
|
||||
'serial.field.lineModeDesc': 'Buffer input and send on Enter (instead of character-by-character)',
|
||||
'serial.connectionError': 'Failed to connect to serial port',
|
||||
'serial.field.baudRatePlaceholder': 'Select or enter baud rate...',
|
||||
'serial.field.baudRateEmpty': 'Enter a custom baud rate',
|
||||
'serial.field.customBaudRate': 'Using custom baud rate',
|
||||
'serial.field.saveConfig': 'Save Configuration',
|
||||
'serial.field.saveConfigDesc': 'Save this serial configuration to hosts for quick access',
|
||||
'serial.field.configLabel': 'Configuration Name',
|
||||
'serial.field.configLabelPlaceholder': 'e.g. Arduino Uno',
|
||||
'serial.connectAndSave': 'Connect & Save',
|
||||
'serial.edit.title': 'Serial Port Settings',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': 'Authentication Required',
|
||||
'keyboard.interactive.desc': 'The server requires additional authentication.',
|
||||
'keyboard.interactive.descWithHost': 'The server {hostname} requires additional authentication.',
|
||||
'keyboard.interactive.response': 'Response',
|
||||
'keyboard.interactive.enterCode': 'Enter verification code',
|
||||
'keyboard.interactive.enterResponse': 'Enter response',
|
||||
'keyboard.interactive.submit': 'Submit',
|
||||
'keyboard.interactive.verifying': 'Verifying...',
|
||||
'keyboard.interactive.fill': 'Fill',
|
||||
'keyboard.interactive.fillSaved': 'Fill with saved password',
|
||||
'keyboard.interactive.useSaved': 'Use saved',
|
||||
'keyboard.interactive.useSavedPassword': 'Use saved password',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH Key Passphrase',
|
||||
'passphrase.desc': 'Enter the passphrase for {keyName}',
|
||||
'passphrase.descWithHost': 'Enter the passphrase for {keyName} to connect to {hostname}',
|
||||
'passphrase.label': 'Passphrase',
|
||||
'passphrase.keyPath': 'Key',
|
||||
'passphrase.unlock': 'Unlock',
|
||||
'passphrase.unlocking': 'Unlocking...',
|
||||
'passphrase.skip': 'Skip',
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': 'Word Wrap',
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -19,6 +19,7 @@ const zhCN: Messages = {
|
||||
'common.noResultsFound': '没有匹配结果',
|
||||
'common.back': '返回',
|
||||
'common.apply': '应用',
|
||||
'common.use': '使用',
|
||||
'common.left': '左侧',
|
||||
'common.right': '右侧',
|
||||
'common.selectAHost': '选择主机',
|
||||
@@ -26,6 +27,7 @@ const zhCN: Messages = {
|
||||
'sort.za': 'Z-a',
|
||||
'sort.newest': '从新到旧',
|
||||
'sort.oldest': '从旧到新',
|
||||
'sort.group': '按分组',
|
||||
'field.label': 'Label',
|
||||
'field.type': '类型',
|
||||
'auth.keyType': '类型 {type}',
|
||||
@@ -40,6 +42,9 @@ const zhCN: Messages = {
|
||||
'placeholder.workspaceName': '工作区名称',
|
||||
'placeholder.sessionName': '会话名称',
|
||||
'toast.settingsUnavailable': '当前平台无法打开设置窗口。',
|
||||
'credentials.protectionUnavailable.title': '凭据保护不可用',
|
||||
'credentials.protectionUnavailable.message': '当前设备无法自动解密已保存的密码和密钥。连接前请重新输入凭据。',
|
||||
'credentials.protectionUnavailable.action': '打开设置',
|
||||
|
||||
// Settings shell
|
||||
'settings.title': '设置',
|
||||
@@ -48,6 +53,76 @@ const zhCN: Messages = {
|
||||
'settings.tab.terminal': '终端',
|
||||
'settings.tab.shortcuts': '快捷键',
|
||||
'settings.tab.syncCloud': '同步与云',
|
||||
'settings.tab.system': '系统',
|
||||
|
||||
// Settings > System
|
||||
'settings.system.title': '系统',
|
||||
'settings.system.description': '系统信息与临时文件管理。',
|
||||
'settings.system.tempDirectory': '临时文件',
|
||||
'settings.system.location': '位置',
|
||||
'settings.system.fileCount': '文件数量',
|
||||
'settings.system.totalSize': '占用空间',
|
||||
'settings.system.openFolder': '打开文件夹',
|
||||
'settings.system.refresh': '刷新',
|
||||
'settings.system.clearTempFiles': '清理临时文件',
|
||||
'settings.system.clearing': '清理中...',
|
||||
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
|
||||
'settings.system.tempDirectoryHint': '临时文件在使用外部应用打开远程文件时创建。SFTP 会话关闭时会自动清理。',
|
||||
'settings.system.credentials.title': '凭据保护',
|
||||
'settings.system.credentials.status': '状态',
|
||||
'settings.system.credentials.checking': '检查中...',
|
||||
'settings.system.credentials.available': '可用(系统钥匙串正常)',
|
||||
'settings.system.credentials.unavailable': '不可用(无法解密已保存凭据)',
|
||||
'settings.system.credentials.unknown': '未知(当前环境不支持)',
|
||||
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
|
||||
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': '会话日志',
|
||||
'settings.sessionLogs.description': '配置会话日志导出和自动保存设置。',
|
||||
'settings.sessionLogs.autoSave': '自动保存',
|
||||
'settings.sessionLogs.enableAutoSave': '启用自动保存',
|
||||
'settings.sessionLogs.enableAutoSaveDesc': '在终端会话结束时自动保存会话日志。',
|
||||
'settings.sessionLogs.directory': '保存目录',
|
||||
'settings.sessionLogs.noDirectory': '未选择目录',
|
||||
'settings.sessionLogs.browse': '浏览',
|
||||
'settings.sessionLogs.openFolder': '打开文件夹',
|
||||
'settings.sessionLogs.directoryHint': '日志将按主机名组织在子目录中。',
|
||||
'settings.sessionLogs.format': '日志格式',
|
||||
'settings.sessionLogs.formatDesc': '选择保存日志文件的格式。',
|
||||
'settings.sessionLogs.formatTxt': '纯文本 (.txt)',
|
||||
'settings.sessionLogs.formatRaw': '原始格式 (.log)',
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.hint': '会话日志用于记录终端输出,便于故障排查和审计。',
|
||||
|
||||
// Settings > Global Hotkey (Quake Mode)
|
||||
'settings.globalHotkey.title': '全局快捷键',
|
||||
'settings.globalHotkey.toggleWindow': '切换窗口',
|
||||
'settings.globalHotkey.toggleWindowDesc': '按下组合键以设置显示/隐藏窗口的全局快捷键。',
|
||||
'settings.globalHotkey.notSet': '未设置',
|
||||
'settings.globalHotkey.reset': '恢复默认',
|
||||
'settings.globalHotkey.closeToTray': '关闭时最小化到托盘',
|
||||
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口将最小化到系统托盘而不是退出程序。',
|
||||
'settings.globalHotkey.hint': '全局快捷键在系统范围内工作,可快速显示或隐藏窗口(下拉式终端风格)。',
|
||||
|
||||
// Tray Panel
|
||||
'tray.openMainWindow': '打开主窗口',
|
||||
'tray.sessions': '会话',
|
||||
'tray.portForwarding': '端口转发',
|
||||
'tray.status.connected': '已连接',
|
||||
'tray.status.connecting': '连接中',
|
||||
'tray.status.disconnected': '已断开',
|
||||
'tray.status.active': '已启用',
|
||||
'tray.status.inactive': '未启用',
|
||||
'tray.status.error': '错误',
|
||||
'tray.recentHosts': '最近连接的主机',
|
||||
'tray.empty.title': '一切都很安静',
|
||||
'tray.empty.subtitle': '去连接个服务器吧,它们想念你了 🚀',
|
||||
'tray.quit': '退出 Netcatty',
|
||||
|
||||
// Vault Sidebar
|
||||
'vault.sidebar.collapse': '收起侧边栏',
|
||||
'vault.sidebar.expand': '展开侧边栏',
|
||||
|
||||
// Settings > Application
|
||||
'settings.application.checkUpdates': '检查更新',
|
||||
@@ -90,6 +165,8 @@ const zhCN: Messages = {
|
||||
'/* 示例:*/\n.terminal { background: #1a1a2e !important; }\n:root { --radius: 0.25rem; }',
|
||||
'settings.appearance.language': '语言',
|
||||
'settings.appearance.language.desc': '选择界面语言',
|
||||
'settings.appearance.uiFont': '界面字体',
|
||||
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
|
||||
|
||||
// Context menus / common actions
|
||||
'action.newHost': '新建主机',
|
||||
@@ -125,6 +202,7 @@ const zhCN: Messages = {
|
||||
'sync.autoSync.vaultLocked': 'Vault 处于锁定状态。请打开 设置 → Sync & Cloud 解锁。',
|
||||
'sync.autoSync.conflictDetected': '检测到同步冲突。请打开 设置 → Sync & Cloud 处理。',
|
||||
'sync.autoSync.syncFailed': '同步失败',
|
||||
'sync.credentialsUnavailable': '当前设备无法解密部分已保存凭据。请先在本地重新输入凭据后再同步。',
|
||||
'time.never': '从未',
|
||||
'time.justNow': '刚刚',
|
||||
'time.minutesAgo': '{minutes} 分钟前',
|
||||
@@ -148,6 +226,11 @@ const zhCN: Messages = {
|
||||
'vault.groups.createDialog.desc': '创建新的分组用于组织主机。',
|
||||
'vault.groups.renameDialogTitle': '重命名分组',
|
||||
'vault.groups.renameDialog.desc': '重命名已有分组。',
|
||||
'vault.groups.deleteDialogTitle': '删除分组',
|
||||
'vault.groups.deleteDialog.desc': '这将永久删除该分组并将所有主机移动到根级别。',
|
||||
'vault.groups.deleteDialog.managedDesc': '这是一个托管的 SSH config 分组。删除后将同时删除所有主机并断开与源文件的连接。',
|
||||
'vault.groups.deleteDialog.deleteHosts': '同时删除该分组下的所有主机',
|
||||
'vault.groups.ungrouped': '未分组',
|
||||
'vault.groups.field.name': '分组名称',
|
||||
'vault.groups.placeholder.example': '例如:Production',
|
||||
'vault.groups.parentLabel': '父级',
|
||||
@@ -155,6 +238,9 @@ const zhCN: Messages = {
|
||||
'vault.groups.errors.required': '分组名称不能为空。',
|
||||
'vault.groups.errors.invalidChars': "分组名称不能包含 '/' 或 '\\\\'.",
|
||||
|
||||
'vault.managedSource.unmanage': '取消托管',
|
||||
'vault.managedSource.unmanageSuccess': '已取消托管分组',
|
||||
|
||||
'vault.hosts.header.entries': '{count} 条',
|
||||
'vault.hosts.header.live': '{count} 个在线',
|
||||
|
||||
@@ -163,10 +249,28 @@ const zhCN: Messages = {
|
||||
'vault.hosts.connect': '连接',
|
||||
'vault.view.grid': '网格',
|
||||
'vault.view.list': '列表',
|
||||
'vault.view.tree': '树形',
|
||||
'vault.tree.expandAll': '展开全部',
|
||||
'vault.tree.collapseAll': '折叠全部',
|
||||
'vault.hosts.newHost': '新建主机',
|
||||
'vault.hosts.newGroup': '新建分组',
|
||||
'vault.hosts.import': '导入',
|
||||
'vault.hosts.export': '导出',
|
||||
'vault.hosts.export.toast.success': '已导出 {count} 个主机到 CSV',
|
||||
'vault.hosts.export.toast.successWithSkipped': '已导出 {count} 个主机到 CSV(跳过 {skipped} 个不支持的主机)',
|
||||
'vault.hosts.export.toast.noHosts': '没有主机可导出',
|
||||
'vault.hosts.allHosts': '全部主机',
|
||||
'vault.hosts.copyCredentials': '复制账密信息',
|
||||
'vault.hosts.copyCredentials.toast.success': '账密信息已复制到剪贴板',
|
||||
'vault.hosts.copyCredentials.toast.noPassword': '该主机未保存密码',
|
||||
'vault.hosts.multiSelect': '多选',
|
||||
'vault.hosts.selected': '已选择 {count} 项',
|
||||
'vault.hosts.selectAll': '全选',
|
||||
'vault.hosts.deselectAll': '取消全选',
|
||||
'vault.hosts.deleteSelected': '删除 ({count})',
|
||||
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': '添加数据到你的 Vault',
|
||||
@@ -181,6 +285,18 @@ const zhCN: Messages = {
|
||||
'vault.import.toast.noNewHosts': '从 {format} 没有导入到新的主机。',
|
||||
'vault.import.toast.summary': '已导入 {count} 个主机(跳过 {skipped},重复 {duplicates})。',
|
||||
'vault.import.toast.firstIssue': '首个问题:{issue}',
|
||||
'vault.import.sshConfig.chooseMode': '选择如何导入你的 SSH config 文件。',
|
||||
'vault.import.sshConfig.modeQuestion': '你希望如何导入?',
|
||||
'vault.import.sshConfig.importOnly': '仅导入',
|
||||
'vault.import.sshConfig.importOnlyDesc': '一次性导入,修改不会同步回文件。',
|
||||
'vault.import.sshConfig.managed': '托管同步',
|
||||
'vault.import.sshConfig.managedDesc': '保持同步,修改会自动保存回文件。',
|
||||
'vault.import.sshConfig.managedGroup': 'ssh config',
|
||||
'vault.import.sshConfig.managedSuccess': '已导入 {count} 个主机,文件已托管。',
|
||||
'vault.import.sshConfig.alreadyManaged': '该文件已被托管。',
|
||||
'vault.import.sshConfig.alreadyManagedDesc': '该文件已在分组 "{group}" 下托管。如需重新导入,请先移除现有的托管源。',
|
||||
'vault.import.sshConfig.noFilePath': '无法托管此文件。',
|
||||
'vault.import.sshConfig.noFilePathDesc': '无法确定文件路径。托管同步需要访问文件系统。',
|
||||
|
||||
// Known Hosts
|
||||
'knownHosts.search.placeholder': '搜索已知主机...',
|
||||
@@ -248,14 +364,21 @@ const zhCN: Messages = {
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': '新建文件夹',
|
||||
'sftp.newFile': '新建文件',
|
||||
'sftp.filter': '筛选',
|
||||
'sftp.filter.placeholder': '按文件名筛选...',
|
||||
'sftp.columns.name': '名称',
|
||||
'sftp.columns.modified': '修改时间',
|
||||
'sftp.columns.size': '大小',
|
||||
'sftp.columns.kind': '类型',
|
||||
'sftp.columns.actions': '操作',
|
||||
'sftp.emptyDirectory': '空目录',
|
||||
'sftp.nav.up': '返回上层',
|
||||
'sftp.nav.home': '返回主目录',
|
||||
'sftp.nav.refresh': '刷新',
|
||||
'sftp.upload': '上传',
|
||||
'sftp.uploadFiles': '上传文件',
|
||||
'sftp.uploadFolder': '上传文件夹',
|
||||
'sftp.dragDropToUpload': '拖拽文件到这里上传',
|
||||
'sftp.retry': '重试',
|
||||
'sftp.context.open': '打开',
|
||||
@@ -271,18 +394,27 @@ const zhCN: Messages = {
|
||||
'sftp.itemsCount': '{count} 个项目',
|
||||
'sftp.selectedCount': '已选 {count} 个',
|
||||
'sftp.path.doubleClickToEdit': '双击编辑路径',
|
||||
'sftp.showHiddenPaths': '隐藏的路径',
|
||||
'sftp.task.waiting': '等待中...',
|
||||
'sftp.status.loading': '加载中...',
|
||||
'sftp.status.uploading': '上传中...',
|
||||
'sftp.status.ready': '就绪',
|
||||
'sftp.goUp': '上一级',
|
||||
'sftp.encoding.label': '文件名编码',
|
||||
'sftp.encoding.auto': '自动',
|
||||
'sftp.encoding.utf8': 'UTF-8',
|
||||
'sftp.encoding.gb18030': 'GB18030',
|
||||
'sftp.goHome': '返回主目录',
|
||||
'sftp.folderName': '文件夹名称',
|
||||
'sftp.folderName.placeholder': '输入文件夹名称',
|
||||
'sftp.fileName': '文件名称',
|
||||
'sftp.fileName.placeholder': '输入文件名称',
|
||||
'sftp.prompt.newFolderName': '新建文件夹名称?',
|
||||
'sftp.rename.title': '重命名',
|
||||
'sftp.rename.newName': '新名称',
|
||||
'sftp.rename.placeholder': '输入新名称',
|
||||
'sftp.confirm.deleteOne': '删除 "{name}"?',
|
||||
'sftp.deleteConfirm.single': '删除 "{name}"?',
|
||||
'sftp.deleteConfirm.title': '删除 {count} 个项目?',
|
||||
'sftp.deleteConfirm.desc': '此操作不可撤销,将删除以下内容:',
|
||||
'sftp.error.loadFailed': '加载目录失败',
|
||||
@@ -290,6 +422,13 @@ const zhCN: Messages = {
|
||||
'sftp.error.uploadFailed': '上传失败',
|
||||
'sftp.error.deleteFailed': '删除失败',
|
||||
'sftp.error.createFolderFailed': '创建文件夹失败',
|
||||
'sftp.error.createFileFailed': '创建文件失败',
|
||||
'sftp.error.invalidFileName': '文件名包含非法字符:{chars}',
|
||||
'sftp.error.reservedName': '此文件名是系统保留名称',
|
||||
'sftp.overwrite.title': '文件已存在',
|
||||
'sftp.overwrite.desc': '名为"{name}"的文件已存在。是否要替换它?',
|
||||
'sftp.overwrite.confirm': '替换',
|
||||
'sftp.error.renameFailed': '重命名失败',
|
||||
'sftp.picker.title': '选择主机',
|
||||
'sftp.picker.desc': '为{side}窗格选择主机',
|
||||
'sftp.picker.searchPlaceholder': '搜索主机...',
|
||||
@@ -298,17 +437,16 @@ const zhCN: Messages = {
|
||||
'sftp.picker.local.badge': '本地',
|
||||
'sftp.picker.noMatch': '没有匹配的主机',
|
||||
'sftp.permissions.title': '编辑权限',
|
||||
'sftp.permissions.owner': 'Owner',
|
||||
'sftp.permissions.group': 'Group',
|
||||
'sftp.permissions.others': 'Others',
|
||||
'sftp.permissions.octal': 'Octal',
|
||||
'sftp.permissions.symbolic': 'Symbolic',
|
||||
'sftp.permissions.owner': '所有者',
|
||||
'sftp.permissions.group': '群组',
|
||||
'sftp.permissions.others': '其他',
|
||||
'sftp.permissions.octal': '八进制',
|
||||
'sftp.permissions.symbolic': '符号',
|
||||
'sftp.permissions.success': '权限已更新',
|
||||
'sftp.permissions.failed': '权限更新失败',
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': '搜索主机或标签页',
|
||||
'qs.recentConnections': '最近连接',
|
||||
'qs.createWorkspace': '创建工作区',
|
||||
'qs.restore': '恢复',
|
||||
'qs.jumpTo': '跳转到',
|
||||
'qs.localTerminal': '本地终端',
|
||||
|
||||
@@ -338,6 +476,12 @@ const zhCN: Messages = {
|
||||
'hostDetails.section.address': '地址',
|
||||
'hostDetails.hostname.placeholder': 'IP 或 主机名',
|
||||
'hostDetails.section.general': '通用',
|
||||
'hostDetails.section.sftp': 'SFTP 设置',
|
||||
'hostDetails.sftp.sudo': 'Sudo 提权模式',
|
||||
'hostDetails.sftp.sudo.desc': '使用保存的密码自动获取 Root 权限',
|
||||
'hostDetails.sftp.sudo.passwordWarning': 'Sudo 模式需要密码。请在上方配置密码,或确保服务器允许免密 sudo。',
|
||||
'hostDetails.sftp.encoding': '文件名编码',
|
||||
'hostDetails.sftp.encoding.desc': '选择用于解码和发送 SFTP 文件名的编码。',
|
||||
'hostDetails.label.placeholder': '名称(例如:Production Server)',
|
||||
'hostDetails.group.placeholder': '父级 Group',
|
||||
'hostDetails.section.credentials': '凭据',
|
||||
@@ -346,6 +490,9 @@ const zhCN: Messages = {
|
||||
'hostDetails.section.mosh': 'Mosh',
|
||||
'hostDetails.username.placeholder': '用户名',
|
||||
'hostDetails.password.placeholder': '密码',
|
||||
'hostDetails.password.show': '显示密码',
|
||||
'hostDetails.password.hide': '隐藏密码',
|
||||
'hostDetails.password.save': '保存密码',
|
||||
'hostDetails.identity.suggestions': '身份',
|
||||
'hostDetails.identity.missing': '身份不存在',
|
||||
'hostDetails.credential.keyCertificate': '密钥 / 证书',
|
||||
@@ -356,12 +503,16 @@ const zhCN: Messages = {
|
||||
'hostDetails.keys.empty': '暂无密钥',
|
||||
'hostDetails.certs.search': '搜索证书…',
|
||||
'hostDetails.certs.empty': '暂无证书',
|
||||
'hostDetails.agentForwarding': '代理转发',
|
||||
'hostDetails.jumpHosts': '跳板主机',
|
||||
'hostDetails.agentForwarding': '转发 SSH 密钥',
|
||||
'hostDetails.agentForwarding.desc': '允许远程服务器使用本地 SSH 密钥(例如用于 git 操作)',
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 未运行',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': '请在 Windows 服务管理器 (services.msc) 中启用 OpenSSH Authentication Agent 服务。',
|
||||
'hostDetails.section.agentForwarding': 'SSH 代理',
|
||||
'hostDetails.jumpHosts': '通过主机代理',
|
||||
'hostDetails.jumpHosts.hops': '{count} 跳',
|
||||
'hostDetails.jumpHosts.direct': '直连',
|
||||
'hostDetails.jumpHosts.configure': '配置跳板主机',
|
||||
'hostDetails.proxy': '代理',
|
||||
'hostDetails.jumpHosts.configure': '配置代理主机',
|
||||
'hostDetails.proxy': '通过 HTTP/SOCKS5 代理',
|
||||
'hostDetails.proxy.none': '无',
|
||||
'hostDetails.proxy.edit': '编辑代理',
|
||||
'hostDetails.proxy.configure': '配置代理',
|
||||
@@ -378,6 +529,12 @@ const zhCN: Messages = {
|
||||
'hostDetails.telnet.password': 'Telnet 密码',
|
||||
'hostDetails.charset.placeholder': '字符集(例如 UTF-8)',
|
||||
'hostDetails.telnet.add': '添加 Telnet 协议',
|
||||
'hostDetails.tags': '标签',
|
||||
'hostDetails.group': '分组',
|
||||
'hostDetails.selectGroup': '选择分组',
|
||||
'hostDetails.addTag': '添加标签...',
|
||||
'hostDetails.createTag': '创建标签',
|
||||
'hostDetails.createGroup': '创建分组',
|
||||
|
||||
// Host form (legacy modal)
|
||||
'hostForm.title.edit': '编辑主机',
|
||||
@@ -411,7 +568,7 @@ const zhCN: Messages = {
|
||||
'logs.table.saved': '收藏',
|
||||
'logs.empty.title': '暂无连接日志',
|
||||
'logs.empty.desc': '当你连接主机或打开本地终端后,这里会显示连接历史。',
|
||||
'logs.showing': '显示 {limit}/{total} 条日志。',
|
||||
'logs.loadMore': '加载更多 ({count} 条)',
|
||||
'logs.ongoing': '进行中',
|
||||
'logs.localTerminal': '本地终端',
|
||||
'logs.action.save': '收藏',
|
||||
@@ -422,6 +579,7 @@ const zhCN: Messages = {
|
||||
'logView.customizeAppearance': '自定义外观',
|
||||
'logView.appearance': '外观',
|
||||
'logView.readOnly': '只读',
|
||||
'logView.export': '导出',
|
||||
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': '打开 SFTP',
|
||||
@@ -439,6 +597,40 @@ const zhCN: Messages = {
|
||||
'terminal.toolbar.focus': '聚焦',
|
||||
'terminal.toolbar.focusMode': '聚焦模式',
|
||||
'terminal.toolbar.closeSession': '关闭会话',
|
||||
'terminal.toolbar.hostHighlight.title': '主机关键字高亮',
|
||||
'terminal.toolbar.hostHighlight.noRules': '此主机未定义自定义高亮规则',
|
||||
'terminal.toolbar.hostHighlight.addRule': '添加新规则',
|
||||
'terminal.toolbar.hostHighlight.labelPlaceholder': '标签(例如:错误)',
|
||||
'terminal.toolbar.hostHighlight.patternPlaceholder': '正则表达式(例如:\\bfailed\\b)',
|
||||
'terminal.toolbar.hostHighlight.invalidPattern': '无效的正则表达式',
|
||||
'terminal.toolbar.hostHighlight.clearAll': '清除全部',
|
||||
'terminal.toolbar.hostHighlight.changeColor': '更改高亮颜色',
|
||||
'terminal.toolbar.hostHighlight.selectColor': '选择新规则的颜色',
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.memory': '内存使用',
|
||||
'terminal.serverStats.memoryDetails': '内存详情',
|
||||
'terminal.serverStats.memUsed': '已用',
|
||||
'terminal.serverStats.memBuffers': '缓冲区',
|
||||
'terminal.serverStats.memCached': '缓存',
|
||||
'terminal.serverStats.memFree': '空闲',
|
||||
'terminal.serverStats.swap': '交换空间',
|
||||
'terminal.serverStats.swapUsed': '已用交换',
|
||||
'terminal.serverStats.swapFree': '空闲交换',
|
||||
'terminal.serverStats.swapTotal': '总计',
|
||||
'terminal.serverStats.topProcesses': '内存占用前十进程',
|
||||
'terminal.serverStats.disk': '磁盘使用(根分区)',
|
||||
'terminal.serverStats.diskDetails': '已挂载磁盘',
|
||||
'terminal.serverStats.network': '网络速度',
|
||||
'terminal.serverStats.networkDetails': '网络接口',
|
||||
'terminal.serverStats.noData': '暂无数据',
|
||||
'terminal.dragDrop.localTitle': '拖放以插入路径',
|
||||
'terminal.dragDrop.localMessage': '文件路径将被插入到终端',
|
||||
'terminal.dragDrop.remoteTitle': '拖放以上传文件',
|
||||
'terminal.dragDrop.remoteMessage': '文件将通过 SFTP 上传',
|
||||
'terminal.dragDrop.notConnected': '无法拖放文件 - 终端未连接',
|
||||
'terminal.dragDrop.errorTitle': '拖放错误',
|
||||
'terminal.dragDrop.errorMessage': '处理拖放文件失败',
|
||||
'terminal.search.placeholder': '搜索…',
|
||||
'terminal.search.noResults': '无结果',
|
||||
'terminal.search.prevMatch': '上一个匹配 (Shift+Enter)',
|
||||
@@ -462,6 +654,10 @@ const zhCN: Messages = {
|
||||
'terminal.auth.selectKey': '选择密钥',
|
||||
'terminal.auth.noKeysHint': '暂无密钥,请先在钥匙串中添加。',
|
||||
'terminal.auth.continueSave': '继续并保存',
|
||||
'terminal.auth.credentialsUnavailable': '当前设备无法解密已保存凭据,请重新输入并再次保存。',
|
||||
'terminal.auth.jumpCredentialsUnavailable': '某个跳板机的已保存凭据无法在当前设备解密,请到主机设置中重新填写。',
|
||||
'terminal.auth.proxyCredentialsUnavailable': '代理凭据无法在当前设备解密,请到主机设置中重新填写代理密码。',
|
||||
'terminal.auth.keyUnavailableFallbackPassword': '已保存 SSH 密钥在当前设备不可用,改用密码认证。',
|
||||
'terminal.connectionErrorTitle': '连接错误',
|
||||
'terminal.progress.timeoutIn': '将在 {seconds}s 后超时',
|
||||
'terminal.progress.disconnected': '已断开',
|
||||
@@ -470,6 +666,11 @@ const zhCN: Messages = {
|
||||
'terminal.connection.chainOf': 'Chain {current} / {total}',
|
||||
'terminal.connection.showLogs': '显示日志',
|
||||
'terminal.connection.hideLogs': '隐藏日志',
|
||||
'terminal.connection.protocol.ssh': 'SSH',
|
||||
'terminal.connection.protocol.telnet': 'Telnet',
|
||||
'terminal.connection.protocol.mosh': 'Mosh',
|
||||
'terminal.connection.protocol.serial': '串口',
|
||||
'terminal.connection.protocol.local': '本地终端',
|
||||
'terminal.themeModal.title': 'Terminal 外观',
|
||||
'terminal.themeModal.tab.theme': '主题',
|
||||
'terminal.themeModal.tab.font': '字体',
|
||||
@@ -663,9 +864,18 @@ const zhCN: Messages = {
|
||||
'pf.view.list': '列表',
|
||||
'pf.rule.summary.dynamic': 'SOCKS 监听于 {bindAddress}:{localPort}',
|
||||
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
|
||||
'pf.tooltip.relayHost': '中转主机',
|
||||
'pf.tooltip.hostLabel': '主机',
|
||||
'pf.tooltip.hostAddress': '地址',
|
||||
'pf.tooltip.noHost': '未配置中转主机',
|
||||
'pf.tooltip.localDesc': '本地端口转发:通过 SSH 隧道访问远程服务',
|
||||
'pf.tooltip.remoteDesc': '远程端口转发:将本地服务暴露给远程主机',
|
||||
'pf.tooltip.dynamicDesc': '动态 SOCKS 代理:通过 SSH 隧道转发流量',
|
||||
'pf.deleteActive.title': '删除正在运行的端口转发?',
|
||||
'pf.deleteActive.desc': '端口转发规则 "{label}" 当前正在运行。删除前将先关闭转发连接。',
|
||||
'pf.deleteActive.confirm': '关闭并删除',
|
||||
'pf.form.autoStart': '自动启动',
|
||||
'pf.form.autoStartDesc': '应用启动时自动开启此规则',
|
||||
|
||||
// SFTP (pane + conflict)
|
||||
'sftp.pane.local': '本地',
|
||||
@@ -673,6 +883,9 @@ const zhCN: Messages = {
|
||||
'sftp.pane.selectHost': '选择主机',
|
||||
'sftp.pane.selectHostToStart': '先选择一个主机',
|
||||
'sftp.pane.chooseFilesystem': '选择要浏览的本地或远端文件系统',
|
||||
'sftp.tabs.addTab': '新建标签页',
|
||||
'sftp.tabs.closeTab': '关闭标签页',
|
||||
'sftp.tabs.newTab': '新标签页',
|
||||
'sftp.conflict.title': '文件冲突',
|
||||
'sftp.conflict.desc': '目标位置已存在同名文件',
|
||||
'sftp.conflict.alreadyExistsSuffix': '已存在',
|
||||
@@ -685,8 +898,116 @@ const zhCN: Messages = {
|
||||
'sftp.conflict.action.keepBoth': '保留两者',
|
||||
'sftp.conflict.action.replace': '替换',
|
||||
|
||||
// SFTP Upload Phases
|
||||
'sftp.upload.phase.compressing': '正在压缩',
|
||||
'sftp.upload.phase.uploading': '正在上传',
|
||||
'sftp.upload.phase.extracting': '正在解压',
|
||||
'sftp.upload.phase.compressed': '压缩传输',
|
||||
|
||||
// SFTP File Opener
|
||||
'sftp.context.openWith': '打开方式...',
|
||||
'sftp.context.edit': '编辑',
|
||||
'sftp.context.preview': '预览',
|
||||
'sftp.opener.title': '打开方式',
|
||||
'sftp.opener.desc': '选择一个应用程序来打开此文件',
|
||||
'sftp.opener.builtInEditor': '内置编辑器',
|
||||
'sftp.opener.editDescription': '编辑文本文件',
|
||||
'sftp.opener.builtInImageViewer': '内置图片预览',
|
||||
'sftp.opener.previewDescription': '预览图片',
|
||||
'sftp.opener.systemApp': '选择应用程序...',
|
||||
'sftp.opener.systemAppDescription': '从本地选择一个应用程序',
|
||||
'sftp.opener.onlySystemApp': '此文件只能用外部应用程序打开',
|
||||
'sftp.opener.noAppsAvailable': '无可用应用程序',
|
||||
'sftp.opener.noExtension': '无扩展名文件',
|
||||
'sftp.opener.setDefault': '始终使用此方式打开 {ext} 文件',
|
||||
'sftp.opener.confirmTitle': '设为默认?',
|
||||
'sftp.opener.confirmDescription': '是否始终使用 {app} 打开 {ext} 文件?',
|
||||
'sftp.opener.yesRemember': '是,记住此选择',
|
||||
'sftp.opener.justOnce': '仅此一次',
|
||||
'sftp.opener.confirm.title': '设置默认应用程序',
|
||||
'sftp.opener.confirm.desc': '是否始终使用此应用程序打开 .{ext} 文件?',
|
||||
'sftp.editor.title': '文本编辑器',
|
||||
'sftp.editor.save': '保存到远程',
|
||||
'sftp.editor.saving': '保存中...',
|
||||
'sftp.editor.saved': '保存成功',
|
||||
'sftp.editor.saveFailed': '保存文件失败',
|
||||
'sftp.editor.unsavedChanges': '您有未保存的更改。确定要关闭吗?',
|
||||
'sftp.editor.syntaxHighlight': '语法高亮',
|
||||
'sftp.preview.title': '图片预览',
|
||||
'sftp.preview.zoomIn': '放大',
|
||||
'sftp.preview.zoomOut': '缩小',
|
||||
'sftp.preview.resetZoom': '重置缩放',
|
||||
'sftp.preview.fitToWindow': '适应窗口',
|
||||
|
||||
// Settings > SFTP File Associations
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftpFileAssociations.title': 'SFTP 文件关联',
|
||||
'settings.sftpFileAssociations.desc': '配置按扩展名打开文件的默认应用程序',
|
||||
'settings.sftpFileAssociations.extension': '扩展名',
|
||||
'settings.sftpFileAssociations.application': '应用程序',
|
||||
'settings.sftpFileAssociations.noAssociations': '未配置文件关联',
|
||||
'settings.sftpFileAssociations.remove': '移除',
|
||||
'settings.sftpFileAssociations.removeConfirm': '确定移除 .{ext} 的关联吗?',
|
||||
|
||||
// Settings > SFTP Behavior
|
||||
'settings.sftp.doubleClickBehavior': '双击行为',
|
||||
'settings.sftp.doubleClickBehavior.desc': '选择在 SFTP 视图中双击文件时的操作',
|
||||
'settings.sftp.doubleClickBehavior.open': '打开文件',
|
||||
'settings.sftp.doubleClickBehavior.transfer': '传输到另一侧',
|
||||
'settings.sftp.doubleClickBehavior.openDesc': '使用默认应用程序打开文件',
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': '自动同步到远程',
|
||||
'settings.sftp.autoSync.desc': '使用外部应用程序打开文件时,自动将文件更改同步回远程服务器',
|
||||
'settings.sftp.autoSync.enable': '启用自动同步',
|
||||
'settings.sftp.autoSync.enableDesc': '在外部应用程序中保存文件时,更改将自动上传到远程服务器',
|
||||
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
|
||||
'sftp.autoSync.error': '同步文件失败:{error}',
|
||||
|
||||
// SFTP Folder Upload Progress
|
||||
'sftp.upload.progress': '正在上传 {current}/{total} 个文件...',
|
||||
'sftp.upload.uploading': '正在上传...',
|
||||
'sftp.upload.compressing': '正在压缩...',
|
||||
'sftp.upload.extracting': '正在解压...',
|
||||
'sftp.upload.scanning': '正在扫描文件...',
|
||||
'sftp.upload.completed': '已完成',
|
||||
'sftp.upload.compressed': '压缩传输',
|
||||
'sftp.upload.currentFile': '当前: {fileName}',
|
||||
'sftp.upload.cancelled': '上传已取消',
|
||||
'sftp.upload.cancel': '取消',
|
||||
|
||||
// SFTP Download
|
||||
'sftp.download.completed': '已下载',
|
||||
'sftp.download.cancelled': '下载已取消',
|
||||
|
||||
// SFTP Reconnecting
|
||||
'sftp.reconnecting.title': '正在重连...',
|
||||
'sftp.reconnecting.desc': '连接已断开,正在尝试重新连接',
|
||||
'sftp.reconnected': '连接已恢复',
|
||||
'sftp.error.reconnectFailed': '重连失败,请重试。',
|
||||
'sftp.error.connectionLostManual': '连接已断开,请手动重新连接。',
|
||||
'sftp.error.connectionLostReconnecting': '连接已断开,正在重连...',
|
||||
'sftp.error.sessionLost': 'SFTP 会话已断开,请重新连接。',
|
||||
|
||||
// Settings > SFTP Show Hidden Files
|
||||
'settings.sftp.showHiddenFiles': '显示隐藏文件',
|
||||
'settings.sftp.showHiddenFiles.desc': '在浏览本地 Windows 文件系统时,显示具有 Windows 隐藏属性的文件。',
|
||||
'settings.sftp.showHiddenFiles.enable': '显示隐藏文件',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': '浏览本地文件系统时显示 Windows 隐藏文件',
|
||||
|
||||
// Settings > SFTP Compressed Upload
|
||||
'settings.sftp.compressedUpload': '文件夹压缩传输',
|
||||
'settings.sftp.compressedUpload.desc': '上传前压缩文件夹,可大幅减少传输时间。',
|
||||
'settings.sftp.compressedUpload.enable': '启用文件夹压缩',
|
||||
'settings.sftp.compressedUpload.enableDesc': '自动使用 tar 压缩文件夹后再传输。需要服务器支持 tar 命令,不支持时自动回退到普通传输。',
|
||||
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': '终端主题',
|
||||
'settings.terminal.themeModal.title': '选择主题',
|
||||
'settings.terminal.themeModal.darkThemes': '深色主题',
|
||||
'settings.terminal.themeModal.lightThemes': '浅色主题',
|
||||
'settings.terminal.theme.selectButton': '选择主题',
|
||||
'settings.terminal.section.font': '字体',
|
||||
'settings.terminal.section.cursor': '光标',
|
||||
'settings.terminal.section.keyboard': '键盘',
|
||||
@@ -741,6 +1062,33 @@ const zhCN: Messages = {
|
||||
'settings.terminal.scrollback.rows': '行数 *',
|
||||
'settings.terminal.keywordHighlight.title': '关键字高亮',
|
||||
'settings.terminal.keywordHighlight.resetColors': '重置为默认颜色',
|
||||
'settings.terminal.section.localShell': '本地 Shell',
|
||||
'settings.terminal.localShell.shell': 'Shell 可执行文件',
|
||||
'settings.terminal.localShell.shell.desc': 'Shell 可执行文件的路径(例如 /bin/zsh、pwsh.exe)。留空使用系统默认。',
|
||||
'settings.terminal.localShell.shell.placeholder': '系统默认',
|
||||
'settings.terminal.localShell.shell.detected': '检测到',
|
||||
'settings.terminal.localShell.shell.notFound': '未找到 Shell 可执行文件',
|
||||
'settings.terminal.localShell.shell.isDirectory': '路径是目录,不是可执行文件',
|
||||
'settings.terminal.localShell.startDir': '起始目录',
|
||||
'settings.terminal.localShell.startDir.desc': '打开本地终端时的起始目录。留空使用用户主目录。',
|
||||
'settings.terminal.localShell.startDir.placeholder': '用户主目录',
|
||||
'settings.terminal.localShell.startDir.notFound': '目录不存在',
|
||||
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
|
||||
'settings.terminal.section.connection': '连接',
|
||||
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 级别保活数据包的频率(秒)。设为 0 表示禁用。',
|
||||
'settings.terminal.section.serverStats': '服务器状态(Linux)',
|
||||
'settings.terminal.serverStats.show': '显示服务器状态',
|
||||
'settings.terminal.serverStats.show.desc': '在终端状态栏显示 CPU、内存和磁盘使用情况(仅限 Linux 服务器)。',
|
||||
'settings.terminal.serverStats.refreshInterval': '刷新间隔',
|
||||
'settings.terminal.serverStats.refreshInterval.desc': '服务器状态刷新的频率。',
|
||||
'settings.terminal.serverStats.seconds': '秒',
|
||||
|
||||
// Settings > Terminal > Rendering
|
||||
'settings.terminal.section.rendering': '渲染',
|
||||
'settings.terminal.rendering.renderer': '渲染器',
|
||||
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 Canvas。更改将在新终端会话中生效。',
|
||||
'settings.terminal.rendering.auto': '自动',
|
||||
|
||||
// Settings > Shortcuts
|
||||
'settings.shortcuts.section.scheme': '快捷键方案',
|
||||
@@ -757,6 +1105,7 @@ const zhCN: Messages = {
|
||||
'settings.shortcuts.category.terminal': '终端',
|
||||
'settings.shortcuts.category.navigation': '导航',
|
||||
'settings.shortcuts.category.app': '应用',
|
||||
'settings.shortcuts.category.sftp': 'SFTP',
|
||||
|
||||
// Host Details (sub-panels)
|
||||
'hostDetails.proxyPanel.title': 'Proxy',
|
||||
@@ -882,6 +1231,7 @@ const zhCN: Messages = {
|
||||
'tabs.closeLogViewAria': '关闭日志视图',
|
||||
'tabs.logPrefix': '日志:',
|
||||
'tabs.logLocal': '本地',
|
||||
'tabs.copyTab': '复制标签页',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
'keychain.edit.keyLabelPlaceholder': '密钥 Label',
|
||||
'keychain.edit.privateKeyRequired': '私钥 *',
|
||||
@@ -932,6 +1282,91 @@ const zhCN: Messages = {
|
||||
'snippets.packageDialog.root': '根目录',
|
||||
'snippets.packageDialog.placeholder': '例如:ops/maintenance',
|
||||
'snippets.packageDialog.hint': '使用 "/" 创建嵌套代码包。',
|
||||
|
||||
// Snippets Rename Dialog
|
||||
'snippets.renameDialog.title': '重命名代码包',
|
||||
'snippets.renameDialog.currentPath': '当前路径:{path}',
|
||||
'snippets.renameDialog.placeholder': '输入新名称',
|
||||
'snippets.renameDialog.error.empty': '代码包名称不能为空',
|
||||
'snippets.renameDialog.error.duplicate': '已存在同名的代码包',
|
||||
'snippets.renameDialog.error.invalidChars': '代码包名称只能包含字母、数字、连字符和下划线',
|
||||
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': '快捷键',
|
||||
'snippets.shortkey.placeholder': '点击设置快捷键',
|
||||
'snippets.shortkey.recording': '请按下快捷键组合...',
|
||||
'snippets.shortkey.hint': '在终端中按下此快捷键可快速发送命令。',
|
||||
'snippets.shortkey.clear': '清除快捷键',
|
||||
'snippets.shortkey.error.systemConflict': '此快捷键与系统快捷键冲突',
|
||||
'snippets.shortkey.error.snippetConflict': '此快捷键已被代码片段使用:{name}',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': '串口',
|
||||
'serial.modal.title': '连接串口',
|
||||
'serial.modal.desc': '配置串口连接参数',
|
||||
'serial.field.port': '串口',
|
||||
'serial.field.selectPort': '选择串口...',
|
||||
'serial.field.baudRate': '波特率',
|
||||
'serial.field.dataBits': '数据位',
|
||||
'serial.field.stopBits': '停止位',
|
||||
'serial.field.stopBits15Warning': '1.5 停止位在 Windows 下可能不被所有设备支持',
|
||||
'serial.field.parity': '校验位',
|
||||
'serial.field.flowControl': '流控制',
|
||||
'serial.noPorts': '未检测到串口设备。请连接设备后刷新。',
|
||||
'serial.field.customPort': '自定义串口路径',
|
||||
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
|
||||
'serial.type.hardware': '硬件',
|
||||
'serial.type.pseudo': '虚拟终端',
|
||||
'serial.type.custom': '自定义',
|
||||
'serial.parity.none': '无',
|
||||
'serial.parity.even': '偶校验',
|
||||
'serial.parity.odd': '奇校验',
|
||||
'serial.parity.mark': 'Mark',
|
||||
'serial.parity.space': 'Space',
|
||||
'serial.flowControl.none': '无',
|
||||
'serial.flowControl.xon/xoff': 'XON/XOFF (软件)',
|
||||
'serial.flowControl.rts/cts': 'RTS/CTS (硬件)',
|
||||
'serial.field.localEcho': '强制本地回显',
|
||||
'serial.field.localEchoDesc': '本地回显输入字符(用于没有远程回显的设备)',
|
||||
'serial.field.lineMode': '行模式',
|
||||
'serial.field.lineModeDesc': '缓冲输入,按回车后发送(而不是逐字符发送)',
|
||||
'serial.connectionError': '连接串口失败',
|
||||
'serial.field.baudRatePlaceholder': '选择或输入波特率...',
|
||||
'serial.field.baudRateEmpty': '输入自定义波特率',
|
||||
'serial.field.customBaudRate': '使用自定义波特率',
|
||||
'serial.field.saveConfig': '保存配置',
|
||||
'serial.field.saveConfigDesc': '将此串口配置保存到主机列表以便快速访问',
|
||||
'serial.field.configLabel': '配置名称',
|
||||
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
|
||||
'serial.connectAndSave': '连接并保存',
|
||||
'serial.edit.title': '串口设置',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': '需要验证',
|
||||
'keyboard.interactive.desc': '服务器需要额外的身份验证。',
|
||||
'keyboard.interactive.descWithHost': '服务器 {hostname} 需要额外的身份验证。',
|
||||
'keyboard.interactive.response': '响应',
|
||||
'keyboard.interactive.enterCode': '输入验证码',
|
||||
'keyboard.interactive.enterResponse': '输入响应',
|
||||
'keyboard.interactive.submit': '提交',
|
||||
'keyboard.interactive.verifying': '验证中...',
|
||||
'keyboard.interactive.fill': '填入',
|
||||
'keyboard.interactive.fillSaved': '填入已保存的密码',
|
||||
'keyboard.interactive.useSaved': '使用已保存',
|
||||
'keyboard.interactive.useSavedPassword': '使用已保存的密码',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH 密钥密码',
|
||||
'passphrase.desc': '请输入 {keyName} 的密码',
|
||||
'passphrase.descWithHost': '请输入 {keyName} 的密码以连接到 {hostname}',
|
||||
'passphrase.label': '密码',
|
||||
'passphrase.keyPath': '密钥',
|
||||
'passphrase.unlock': '解锁',
|
||||
'passphrase.unlocking': '解锁中...',
|
||||
'passphrase.skip': '跳过',
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': '自动换行',
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
|
||||
146
application/state/fontStore.ts
Normal file
146
application/state/fontStore.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { TERMINAL_FONTS, type TerminalFont } from '../../infrastructure/config/fonts';
|
||||
import { getMonospaceFonts } from '../../lib/localFonts';
|
||||
|
||||
/**
|
||||
* Global font store - singleton pattern using useSyncExternalStore
|
||||
* Ensures fonts are loaded only once and shared across all components
|
||||
*/
|
||||
type Listener = () => void;
|
||||
|
||||
interface FontStoreState {
|
||||
availableFonts: TerminalFont[];
|
||||
isLoading: boolean;
|
||||
isLoaded: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
class FontStore {
|
||||
private state: FontStoreState = {
|
||||
availableFonts: TERMINAL_FONTS,
|
||||
isLoading: false,
|
||||
isLoaded: false,
|
||||
error: null,
|
||||
};
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
// Getters for individual state slices
|
||||
getAvailableFonts = (): TerminalFont[] => this.state.availableFonts;
|
||||
getIsLoading = (): boolean => this.state.isLoading;
|
||||
getIsLoaded = (): boolean => this.state.isLoaded;
|
||||
getError = (): string | null => this.state.error;
|
||||
|
||||
private notify = () => {
|
||||
// Defer listener notification to avoid "setState during render"
|
||||
Promise.resolve().then(() => {
|
||||
this.listeners.forEach(listener => listener());
|
||||
});
|
||||
};
|
||||
|
||||
private setState = (partial: Partial<FontStoreState>) => {
|
||||
this.state = { ...this.state, ...partial };
|
||||
this.notify();
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize font loading - safe to call multiple times,
|
||||
* will only load once
|
||||
*/
|
||||
initialize = async (): Promise<void> => {
|
||||
// Already loaded or currently loading
|
||||
if (this.state.isLoaded || this.state.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const localFonts = await getMonospaceFonts();
|
||||
|
||||
// Combine default fonts with local fonts, deduplicate by id
|
||||
const fontMap = new Map<string, TerminalFont>();
|
||||
|
||||
// Add default fonts first
|
||||
TERMINAL_FONTS.forEach(font => fontMap.set(font.id, font));
|
||||
|
||||
// Add local fonts with a distinct ID namespace to avoid collisions
|
||||
localFonts.forEach(font => {
|
||||
const localId = font.id.startsWith('local-') ? font.id : `local-${font.id}`;
|
||||
fontMap.set(localId, { ...font, id: localId });
|
||||
});
|
||||
|
||||
this.setState({
|
||||
availableFonts: Array.from(fontMap.values()),
|
||||
isLoading: false,
|
||||
isLoaded: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to load local fonts';
|
||||
console.warn('Failed to fetch local fonts, using defaults:', error);
|
||||
this.setState({
|
||||
availableFonts: TERMINAL_FONTS,
|
||||
isLoading: false,
|
||||
isLoaded: true,
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Find a font by ID with fallback
|
||||
*/
|
||||
getFontById = (fontId: string): TerminalFont => {
|
||||
const fonts = this.state.availableFonts;
|
||||
return fonts.find(f => f.id === fontId) || fonts[0] || TERMINAL_FONTS[0];
|
||||
};
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const fontStore = new FontStore();
|
||||
|
||||
// ============== Hooks ==============
|
||||
|
||||
/**
|
||||
* Get available fonts - triggers initialization on first use
|
||||
*/
|
||||
export const useAvailableFonts = (): TerminalFont[] => {
|
||||
// Trigger initialization on first use
|
||||
if (!fontStore.getIsLoaded() && !fontStore.getIsLoading()) {
|
||||
fontStore.initialize();
|
||||
}
|
||||
|
||||
return useSyncExternalStore(
|
||||
fontStore.subscribe,
|
||||
fontStore.getAvailableFonts
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get font loading state
|
||||
*/
|
||||
export const useFontsLoading = (): boolean => {
|
||||
return useSyncExternalStore(
|
||||
fontStore.subscribe,
|
||||
fontStore.getIsLoading
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get font by ID with fallback - useful for components that need a specific font
|
||||
*/
|
||||
export const useFontById = (fontId: string): TerminalFont => {
|
||||
const fonts = useAvailableFonts();
|
||||
return fonts.find(f => f.id === fontId) || fonts[0] || TERMINAL_FONTS[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize fonts eagerly (call at app startup)
|
||||
*/
|
||||
export const initializeFonts = (): void => {
|
||||
fontStore.initialize();
|
||||
};
|
||||
34
application/state/sftp/errors.ts
Normal file
34
application/state/sftp/errors.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export const isSessionError = (err: unknown): boolean => {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
msg.includes("session not found") ||
|
||||
msg.includes("sftp session") ||
|
||||
msg.includes("not found") ||
|
||||
msg.includes("closed") ||
|
||||
msg.includes("connection reset")
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if an error message indicates a fatal error that should stop the entire upload.
|
||||
* This includes session errors AND target directory deletion errors.
|
||||
*/
|
||||
export const isFatalUploadError = (errorMessage: string): boolean => {
|
||||
const msg = errorMessage.toLowerCase();
|
||||
return (
|
||||
// Session-related errors
|
||||
msg.includes("session not found") ||
|
||||
msg.includes("sftp session") ||
|
||||
msg.includes("connection") ||
|
||||
msg.includes("disconnected") ||
|
||||
// Target directory was deleted during upload
|
||||
msg.includes("no such file") ||
|
||||
msg.includes("enoent") ||
|
||||
msg.includes("does not exist") ||
|
||||
msg.includes("write stream error") ||
|
||||
// Directory was removed
|
||||
msg.includes("directory not found") ||
|
||||
msg.includes("not a directory")
|
||||
);
|
||||
};
|
||||
454
application/state/sftp/mockLocalFiles.ts
Normal file
454
application/state/sftp/mockLocalFiles.ts
Normal file
@@ -0,0 +1,454 @@
|
||||
import { SftpFileEntry } from "../../../domain/models";
|
||||
import { formatDate } from "./utils";
|
||||
|
||||
// Mock local file data for development (when backend is not available)
|
||||
export function buildMockLocalFiles(path: string): SftpFileEntry[] {
|
||||
// Normalize path for matching (handle both Windows and Unix paths)
|
||||
const normPath = path.replace(/\\/g, "/").replace(/\/$/, "") || "/";
|
||||
|
||||
const mockData: Record<string, SftpFileEntry[]> = {
|
||||
// Unix-style paths
|
||||
"/": [
|
||||
{
|
||||
name: "Users",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 86400000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 86400000),
|
||||
},
|
||||
{
|
||||
name: "Applications",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 172800000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 172800000),
|
||||
},
|
||||
{
|
||||
name: "System",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 259200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 259200000),
|
||||
},
|
||||
],
|
||||
"/Users": [
|
||||
{
|
||||
name: "damao",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 3600000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 3600000),
|
||||
},
|
||||
{
|
||||
name: "Shared",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 86400000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 86400000),
|
||||
},
|
||||
],
|
||||
"/Users/damao": [
|
||||
{
|
||||
name: "Desktop",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 1800000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 1800000),
|
||||
},
|
||||
{
|
||||
name: "Documents",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 7200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 7200000),
|
||||
},
|
||||
{
|
||||
name: "Downloads",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 3600000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 3600000),
|
||||
},
|
||||
{
|
||||
name: "Pictures",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 172800000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 172800000),
|
||||
},
|
||||
{
|
||||
name: "Projects",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 900000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 900000),
|
||||
},
|
||||
],
|
||||
// Windows-style paths (normalized to forward slashes for matching)
|
||||
"C:": [
|
||||
{
|
||||
name: "Users",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 86400000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 86400000),
|
||||
},
|
||||
{
|
||||
name: "Program Files",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 172800000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 172800000),
|
||||
},
|
||||
{
|
||||
name: "Windows",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 259200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 259200000),
|
||||
},
|
||||
],
|
||||
"C:/Users": [
|
||||
{
|
||||
name: "damao",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 3600000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 3600000),
|
||||
},
|
||||
{
|
||||
name: "Public",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 86400000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 86400000),
|
||||
},
|
||||
{
|
||||
name: "Default",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 172800000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 172800000),
|
||||
},
|
||||
],
|
||||
"C:/Users/damao": [
|
||||
{
|
||||
name: "Desktop",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 1800000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 1800000),
|
||||
},
|
||||
{
|
||||
name: "Documents",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 7200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 7200000),
|
||||
},
|
||||
{
|
||||
name: "Downloads",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 3600000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 3600000),
|
||||
},
|
||||
{
|
||||
name: "Pictures",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 172800000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 172800000),
|
||||
},
|
||||
{
|
||||
name: "Projects",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 900000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 900000),
|
||||
},
|
||||
],
|
||||
"C:/Users/damao/Desktop": [
|
||||
{
|
||||
name: "Netcatty",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 300000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 300000),
|
||||
},
|
||||
{
|
||||
name: "notes.txt",
|
||||
type: "file",
|
||||
size: 2048,
|
||||
sizeFormatted: "2 KB",
|
||||
lastModified: Date.now() - 86400000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 86400000),
|
||||
},
|
||||
{
|
||||
name: "screenshot.png",
|
||||
type: "file",
|
||||
size: 1048576,
|
||||
sizeFormatted: "1 MB",
|
||||
lastModified: Date.now() - 43200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 43200000),
|
||||
},
|
||||
],
|
||||
"C:/Users/damao/Desktop/Netcatty": [
|
||||
{
|
||||
name: "src",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 600000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 600000),
|
||||
},
|
||||
{
|
||||
name: "package.json",
|
||||
type: "file",
|
||||
size: 1536,
|
||||
sizeFormatted: "1.5 KB",
|
||||
lastModified: Date.now() - 3600000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 3600000),
|
||||
},
|
||||
{
|
||||
name: "README.md",
|
||||
type: "file",
|
||||
size: 4096,
|
||||
sizeFormatted: "4 KB",
|
||||
lastModified: Date.now() - 7200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 7200000),
|
||||
},
|
||||
{
|
||||
name: "tsconfig.json",
|
||||
type: "file",
|
||||
size: 512,
|
||||
sizeFormatted: "512 Bytes",
|
||||
lastModified: Date.now() - 86400000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 86400000),
|
||||
},
|
||||
],
|
||||
"C:/Users/damao/Documents": [
|
||||
{
|
||||
name: "Work",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 86400000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 86400000),
|
||||
},
|
||||
{
|
||||
name: "Personal",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 172800000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 172800000),
|
||||
},
|
||||
{
|
||||
name: "report.pdf",
|
||||
type: "file",
|
||||
size: 2097152,
|
||||
sizeFormatted: "2 MB",
|
||||
lastModified: Date.now() - 259200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 259200000),
|
||||
},
|
||||
],
|
||||
"C:/Users/damao/Downloads": [
|
||||
{
|
||||
name: "installer.exe",
|
||||
type: "file",
|
||||
size: 52428800,
|
||||
sizeFormatted: "50 MB",
|
||||
lastModified: Date.now() - 3600000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 3600000),
|
||||
},
|
||||
{
|
||||
name: "archive.zip",
|
||||
type: "file",
|
||||
size: 10485760,
|
||||
sizeFormatted: "10 MB",
|
||||
lastModified: Date.now() - 7200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 7200000),
|
||||
},
|
||||
{
|
||||
name: "document.pdf",
|
||||
type: "file",
|
||||
size: 524288,
|
||||
sizeFormatted: "512 KB",
|
||||
lastModified: Date.now() - 86400000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 86400000),
|
||||
},
|
||||
],
|
||||
"C:/Users/damao/Projects": [
|
||||
{
|
||||
name: "webapp",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 1800000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 1800000),
|
||||
},
|
||||
{
|
||||
name: "scripts",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 43200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 43200000),
|
||||
},
|
||||
],
|
||||
"/Users/damao/Desktop": [
|
||||
{
|
||||
name: "Netcatty",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 300000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 300000),
|
||||
},
|
||||
{
|
||||
name: "notes.txt",
|
||||
type: "file",
|
||||
size: 2048,
|
||||
sizeFormatted: "2 KB",
|
||||
lastModified: Date.now() - 86400000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 86400000),
|
||||
},
|
||||
{
|
||||
name: "screenshot.png",
|
||||
type: "file",
|
||||
size: 1048576,
|
||||
sizeFormatted: "1 MB",
|
||||
lastModified: Date.now() - 43200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 43200000),
|
||||
},
|
||||
],
|
||||
"/Users/damao/Desktop/Netcatty": [
|
||||
{
|
||||
name: "src",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 600000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 600000),
|
||||
},
|
||||
{
|
||||
name: "package.json",
|
||||
type: "file",
|
||||
size: 1536,
|
||||
sizeFormatted: "1.5 KB",
|
||||
lastModified: Date.now() - 3600000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 3600000),
|
||||
},
|
||||
{
|
||||
name: "README.md",
|
||||
type: "file",
|
||||
size: 4096,
|
||||
sizeFormatted: "4 KB",
|
||||
lastModified: Date.now() - 7200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 7200000),
|
||||
},
|
||||
{
|
||||
name: "tsconfig.json",
|
||||
type: "file",
|
||||
size: 512,
|
||||
sizeFormatted: "512 Bytes",
|
||||
lastModified: Date.now() - 86400000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 86400000),
|
||||
},
|
||||
],
|
||||
"/Users/damao/Documents": [
|
||||
{
|
||||
name: "Work",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 86400000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 86400000),
|
||||
},
|
||||
{
|
||||
name: "Personal",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 172800000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 172800000),
|
||||
},
|
||||
{
|
||||
name: "report.pdf",
|
||||
type: "file",
|
||||
size: 2097152,
|
||||
sizeFormatted: "2 MB",
|
||||
lastModified: Date.now() - 259200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 259200000),
|
||||
},
|
||||
],
|
||||
"/Users/damao/Downloads": [
|
||||
{
|
||||
name: "installer.exe",
|
||||
type: "file",
|
||||
size: 52428800,
|
||||
sizeFormatted: "50 MB",
|
||||
lastModified: Date.now() - 3600000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 3600000),
|
||||
},
|
||||
{
|
||||
name: "archive.zip",
|
||||
type: "file",
|
||||
size: 10485760,
|
||||
sizeFormatted: "10 MB",
|
||||
lastModified: Date.now() - 7200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 7200000),
|
||||
},
|
||||
{
|
||||
name: "document.pdf",
|
||||
type: "file",
|
||||
size: 524288,
|
||||
sizeFormatted: "512 KB",
|
||||
lastModified: Date.now() - 86400000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 86400000),
|
||||
},
|
||||
],
|
||||
"/Users/damao/Projects": [
|
||||
{
|
||||
name: "webapp",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 1800000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 1800000),
|
||||
},
|
||||
{
|
||||
name: "scripts",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: Date.now() - 43200000,
|
||||
lastModifiedFormatted: formatDate(Date.now() - 43200000),
|
||||
},
|
||||
],
|
||||
};
|
||||
return mockData[normPath] || [];
|
||||
}
|
||||
55
application/state/sftp/types.ts
Normal file
55
application/state/sftp/types.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { SftpConnection, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
|
||||
export interface SftpPane {
|
||||
id: string;
|
||||
connection: SftpConnection | null;
|
||||
files: SftpFileEntry[];
|
||||
loading: boolean;
|
||||
reconnecting: boolean;
|
||||
error: string | null;
|
||||
selectedFiles: Set<string>;
|
||||
filter: string;
|
||||
filenameEncoding: SftpFilenameEncoding;
|
||||
}
|
||||
|
||||
// Multi-tab state for left and right sides
|
||||
export interface SftpSideTabs {
|
||||
tabs: SftpPane[];
|
||||
activeTabId: string | null;
|
||||
}
|
||||
|
||||
// Constants for empty placeholder pane IDs
|
||||
export const EMPTY_LEFT_PANE_ID = "__empty_left__";
|
||||
export const EMPTY_RIGHT_PANE_ID = "__empty_right__";
|
||||
|
||||
export const createEmptyPane = (id?: string): SftpPane => ({
|
||||
id: id || crypto.randomUUID(),
|
||||
connection: null,
|
||||
files: [],
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: null,
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
filenameEncoding: "auto",
|
||||
});
|
||||
|
||||
// File watch event types
|
||||
export interface FileWatchSyncedEvent {
|
||||
watchId: string;
|
||||
localPath: string;
|
||||
remotePath: string;
|
||||
bytesWritten: number;
|
||||
}
|
||||
|
||||
export interface FileWatchErrorEvent {
|
||||
watchId: string;
|
||||
localPath: string;
|
||||
remotePath: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface SftpStateOptions {
|
||||
onFileWatchSynced?: (event: FileWatchSyncedEvent) => void;
|
||||
onFileWatchError?: (event: FileWatchErrorEvent) => void;
|
||||
}
|
||||
427
application/state/sftp/useSftpConnections.ts
Normal file
427
application/state/sftp/useSftpConnections.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { Host, Identity, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
import type { SftpPane } from "./types";
|
||||
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
|
||||
import { useSftpHostCredentials } from "./useSftpHostCredentials";
|
||||
|
||||
interface UseSftpConnectionsParams {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
leftTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
|
||||
rightTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
|
||||
leftTabs: { tabs: SftpPane[] };
|
||||
rightTabs: { tabs: SftpPane[] };
|
||||
leftPane: SftpPane;
|
||||
rightPane: SftpPane;
|
||||
setLeftTabs: React.Dispatch<React.SetStateAction<{ tabs: SftpPane[]; activeTabId: string | null }>>;
|
||||
setRightTabs: React.Dispatch<React.SetStateAction<{ tabs: SftpPane[]; activeTabId: string | null }>>;
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
updateTab: (side: "left" | "right", tabId: string, updater: (prev: SftpPane) => SftpPane) => void;
|
||||
navSeqRef: MutableRefObject<{ left: number; right: number }>;
|
||||
dirCacheRef: MutableRefObject<Map<string, { files: SftpFileEntry[]; timestamp: number }>>;
|
||||
sftpSessionsRef: MutableRefObject<Map<string, string>>;
|
||||
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
|
||||
reconnectingRef: MutableRefObject<{ left: boolean; right: boolean }>;
|
||||
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
|
||||
clearCacheForConnection: (connectionId: string) => void;
|
||||
createEmptyPane: (id?: string) => SftpPane;
|
||||
}
|
||||
|
||||
interface UseSftpConnectionsResult {
|
||||
connect: (side: "left" | "right", host: Host | "local") => Promise<void>;
|
||||
disconnect: (side: "left" | "right") => Promise<void>;
|
||||
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
|
||||
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||||
}
|
||||
|
||||
export const useSftpConnections = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
leftTabs,
|
||||
rightTabs: _rightTabs,
|
||||
leftPane,
|
||||
rightPane,
|
||||
setLeftTabs,
|
||||
setRightTabs,
|
||||
getActivePane,
|
||||
updateTab,
|
||||
navSeqRef,
|
||||
dirCacheRef,
|
||||
sftpSessionsRef,
|
||||
lastConnectedHostRef,
|
||||
reconnectingRef,
|
||||
makeCacheKey,
|
||||
clearCacheForConnection,
|
||||
createEmptyPane,
|
||||
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
|
||||
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities });
|
||||
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
|
||||
|
||||
const connect = useCallback(
|
||||
async (side: "left" | "right", host: Host | "local") => {
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
|
||||
let activeTabId: string | null = null;
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
|
||||
if (!sideTabs.activeTabId) {
|
||||
const newPane = createEmptyPane();
|
||||
activeTabId = newPane.id;
|
||||
setTabs((prev) => ({
|
||||
tabs: [...prev.tabs, newPane],
|
||||
activeTabId: newPane.id,
|
||||
}));
|
||||
} else {
|
||||
activeTabId = sideTabs.activeTabId;
|
||||
}
|
||||
|
||||
if (!activeTabId) return;
|
||||
|
||||
const connectionId = `${side}-${Date.now()}`;
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
const connectRequestId = navSeqRef.current[side];
|
||||
|
||||
lastConnectedHostRef.current[side] = host;
|
||||
|
||||
const currentPane = getActivePane(side);
|
||||
// Reset encoding to host's configured encoding or "auto" when connecting to a new host
|
||||
// This ensures proper auto-detection works and respects host-level encoding settings
|
||||
const filenameEncoding: SftpFilenameEncoding =
|
||||
host === "local" ? "auto" : (host.sftpEncoding ?? "auto");
|
||||
|
||||
if (currentPane?.connection) {
|
||||
clearCacheForConnection(currentPane.connection.id);
|
||||
}
|
||||
if (currentPane?.connection && !currentPane.connection.isLocal) {
|
||||
const oldSftpId = sftpSessionsRef.current.get(currentPane.connection.id);
|
||||
if (oldSftpId) {
|
||||
try {
|
||||
await netcattyBridge.get()?.closeSftp(oldSftpId);
|
||||
} catch {
|
||||
// Ignore errors when closing stale SFTP sessions
|
||||
}
|
||||
sftpSessionsRef.current.delete(currentPane.connection.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (host === "local") {
|
||||
let homeDir = await netcattyBridge.get()?.getHomeDir?.();
|
||||
if (!homeDir) {
|
||||
const isWindows = navigator.platform.toLowerCase().includes("win");
|
||||
homeDir = isWindows ? "C:\\Users\\damao" : "/Users/damao";
|
||||
}
|
||||
|
||||
const connection: SftpConnection = {
|
||||
id: connectionId,
|
||||
hostId: "local",
|
||||
hostLabel: "Local",
|
||||
isLocal: true,
|
||||
status: "connected",
|
||||
currentPath: homeDir,
|
||||
homeDir,
|
||||
};
|
||||
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection,
|
||||
loading: true,
|
||||
reconnecting: false,
|
||||
error: null,
|
||||
filenameEncoding, // Reset encoding for new connection
|
||||
}));
|
||||
|
||||
try {
|
||||
const files = await listLocalFiles(homeDir);
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
dirCacheRef.current.set(makeCacheKey(connectionId, homeDir, filenameEncoding), {
|
||||
files,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
reconnectingRef.current[side] = false;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
files,
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
reconnectingRef.current[side] = false;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
error: err instanceof Error ? err.message : "Failed to list directory",
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
const connection: SftpConnection = {
|
||||
id: connectionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
isLocal: false,
|
||||
status: "connecting",
|
||||
currentPath: "/",
|
||||
};
|
||||
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection,
|
||||
loading: true,
|
||||
reconnecting: prev.reconnecting,
|
||||
error: null,
|
||||
files: prev.reconnecting ? prev.files : [],
|
||||
filenameEncoding, // Reset encoding for new connection
|
||||
}));
|
||||
|
||||
try {
|
||||
const credentials = getHostCredentials(host);
|
||||
const bridge = netcattyBridge.get();
|
||||
const openSftp = bridge?.openSftp;
|
||||
if (!openSftp) throw new Error("SFTP bridge unavailable");
|
||||
|
||||
const isAuthError = (err: unknown): boolean => {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
msg.includes("authentication") ||
|
||||
msg.includes("auth") ||
|
||||
msg.includes("password") ||
|
||||
msg.includes("permission denied")
|
||||
);
|
||||
};
|
||||
|
||||
const hasKey = !!credentials.privateKey;
|
||||
const hasPassword = !!credentials.password;
|
||||
|
||||
let sftpId: string | undefined;
|
||||
if (hasKey) {
|
||||
try {
|
||||
const keyFirstCredentials = {
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
...credentials,
|
||||
};
|
||||
if (!credentials.sudo) {
|
||||
keyFirstCredentials.password = undefined;
|
||||
}
|
||||
sftpId = await openSftp(keyFirstCredentials);
|
||||
} catch (err) {
|
||||
if (hasPassword && isAuthError(err)) {
|
||||
sftpId = await openSftp({
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
...credentials,
|
||||
privateKey: undefined,
|
||||
certificate: undefined,
|
||||
publicKey: undefined,
|
||||
keyId: undefined,
|
||||
keySource: undefined,
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sftpId = await openSftp({
|
||||
sessionId: `sftp-${connectionId}`,
|
||||
...credentials,
|
||||
});
|
||||
}
|
||||
|
||||
if (!sftpId) throw new Error("Failed to open SFTP session");
|
||||
|
||||
sftpSessionsRef.current.set(connectionId, sftpId);
|
||||
|
||||
let startPath = "/";
|
||||
const statSftp = netcattyBridge.get()?.statSftp;
|
||||
if (statSftp) {
|
||||
const candidates: string[] = [];
|
||||
if (credentials.username === "root") {
|
||||
candidates.push("/root");
|
||||
} else if (credentials.username) {
|
||||
candidates.push(`/home/${credentials.username}`);
|
||||
candidates.push("/root");
|
||||
} else {
|
||||
candidates.push("/root");
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const stat = await statSftp(sftpId, candidate, filenameEncoding);
|
||||
if (stat?.type === "directory") {
|
||||
startPath = candidate;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Ignore missing/permission errors
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (credentials.username === "root") {
|
||||
try {
|
||||
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
|
||||
if (rootFiles) startPath = "/root";
|
||||
} catch {
|
||||
// Fallback path not available
|
||||
}
|
||||
} else if (credentials.username) {
|
||||
try {
|
||||
const homeFiles = await netcattyBridge.get()?.listSftp(
|
||||
sftpId,
|
||||
`/home/${credentials.username}`,
|
||||
filenameEncoding,
|
||||
);
|
||||
if (homeFiles) startPath = `/home/${credentials.username}`;
|
||||
} catch {
|
||||
// Fall through to /root check
|
||||
}
|
||||
if (startPath === "/") {
|
||||
try {
|
||||
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
|
||||
if (rootFiles) startPath = "/root";
|
||||
} catch {
|
||||
// Fallback path not available
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
|
||||
if (rootFiles) startPath = "/root";
|
||||
} catch {
|
||||
// Fallback path not available
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
dirCacheRef.current.set(makeCacheKey(connectionId, startPath, filenameEncoding), {
|
||||
files,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
reconnectingRef.current[side] = false;
|
||||
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
? {
|
||||
...prev.connection,
|
||||
status: "connected",
|
||||
currentPath: startPath,
|
||||
homeDir: startPath,
|
||||
}
|
||||
: null,
|
||||
files,
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
reconnectingRef.current[side] = false;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
? {
|
||||
...prev.connection,
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Connection failed",
|
||||
}
|
||||
: null,
|
||||
error: err instanceof Error ? err.message : "Connection failed",
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
getHostCredentials,
|
||||
getActivePane,
|
||||
updateTab,
|
||||
clearCacheForConnection,
|
||||
makeCacheKey,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
],
|
||||
);
|
||||
|
||||
const initialConnectDoneRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialConnectDoneRef.current && leftTabs.tabs.length === 0) {
|
||||
initialConnectDoneRef.current = true;
|
||||
setTimeout(() => {
|
||||
connect("left", "local");
|
||||
}, 0);
|
||||
}
|
||||
}, [connect, leftTabs.tabs.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const attemptReconnect = async (side: "left" | "right") => {
|
||||
const lastHost = lastConnectedHostRef.current[side];
|
||||
if (lastHost && reconnectingRef.current[side]) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
if (reconnectingRef.current[side]) {
|
||||
connect(side, lastHost);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (leftPane.reconnecting && reconnectingRef.current.left) {
|
||||
attemptReconnect("left");
|
||||
}
|
||||
if (rightPane.reconnecting && reconnectingRef.current.right) {
|
||||
attemptReconnect("right");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [leftPane.reconnecting, rightPane.reconnecting, connect]);
|
||||
|
||||
const disconnect = useCallback(
|
||||
async (side: "left" | "right") => {
|
||||
const pane = getActivePane(side);
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
const activeTabId = sideTabs.activeTabId;
|
||||
|
||||
if (!pane || !activeTabId) return;
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
|
||||
if (pane.connection) {
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
}
|
||||
|
||||
reconnectingRef.current[side] = false;
|
||||
lastConnectedHostRef.current[side] = null;
|
||||
|
||||
if (pane.connection && !pane.connection.isLocal) {
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (sftpId) {
|
||||
try {
|
||||
await netcattyBridge.get()?.closeSftp(sftpId);
|
||||
} catch {
|
||||
// Ignore errors when closing SFTP session during disconnect
|
||||
}
|
||||
sftpSessionsRef.current.delete(pane.connection.id);
|
||||
}
|
||||
}
|
||||
|
||||
updateTab(side, activeTabId, () => createEmptyPane(activeTabId));
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[getActivePane, clearCacheForConnection, updateTab],
|
||||
);
|
||||
|
||||
return {
|
||||
connect,
|
||||
disconnect,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
};
|
||||
};
|
||||
63
application/state/sftp/useSftpDirectoryListing.ts
Normal file
63
application/state/sftp/useSftpDirectoryListing.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useCallback } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { buildMockLocalFiles } from "./mockLocalFiles";
|
||||
import { formatFileSize, formatDate } from "./utils";
|
||||
|
||||
export const useSftpDirectoryListing = () => {
|
||||
const getMockLocalFiles = useCallback((path: string): SftpFileEntry[] => {
|
||||
return buildMockLocalFiles(path);
|
||||
}, []);
|
||||
|
||||
const listLocalFiles = useCallback(
|
||||
async (path: string): Promise<SftpFileEntry[]> => {
|
||||
const rawFiles = await netcattyBridge.get()?.listLocalDir?.(path);
|
||||
if (!rawFiles) {
|
||||
return getMockLocalFiles(path);
|
||||
}
|
||||
|
||||
return rawFiles.map((f) => {
|
||||
const size = parseInt(f.size) || 0;
|
||||
const lastModified = new Date(f.lastModified).getTime();
|
||||
return {
|
||||
name: f.name,
|
||||
type: f.type as "file" | "directory" | "symlink",
|
||||
size,
|
||||
sizeFormatted: formatFileSize(size),
|
||||
lastModified,
|
||||
lastModifiedFormatted: formatDate(lastModified),
|
||||
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
|
||||
hidden: f.hidden,
|
||||
};
|
||||
});
|
||||
},
|
||||
[getMockLocalFiles],
|
||||
);
|
||||
|
||||
const listRemoteFiles = useCallback(
|
||||
async (sftpId: string, path: string, encoding?: SftpFilenameEncoding): Promise<SftpFileEntry[]> => {
|
||||
const rawFiles = await netcattyBridge.get()?.listSftp(sftpId, path, encoding);
|
||||
if (!rawFiles) return [];
|
||||
|
||||
return rawFiles.map((f) => {
|
||||
const size = parseInt(f.size) || 0;
|
||||
const lastModified = new Date(f.lastModified).getTime();
|
||||
return {
|
||||
name: f.name,
|
||||
type: f.type as "file" | "directory" | "symlink",
|
||||
size,
|
||||
sizeFormatted: formatFileSize(size),
|
||||
lastModified,
|
||||
lastModifiedFormatted: formatDate(lastModified),
|
||||
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
};
|
||||
};
|
||||
449
application/state/sftp/useSftpExternalOperations.ts
Normal file
449
application/state/sftp/useSftpExternalOperations.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
import React, { useCallback, useRef, useMemo } from "react";
|
||||
import { TransferTask, TransferStatus } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { SftpPane } from "./types";
|
||||
import { joinPath } from "./utils";
|
||||
import {
|
||||
UploadController,
|
||||
uploadFromDataTransfer,
|
||||
UploadBridge,
|
||||
UploadCallbacks,
|
||||
UploadResult,
|
||||
UploadTaskInfo,
|
||||
} from "../../../lib/uploadService";
|
||||
|
||||
// Re-export UploadResult for external usage
|
||||
export type { UploadResult };
|
||||
|
||||
interface UseSftpExternalOperationsParams {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
refresh: (side: "left" | "right") => Promise<void>;
|
||||
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
|
||||
addExternalUpload?: (task: TransferTask) => void;
|
||||
updateExternalUpload?: (taskId: string, updates: Partial<TransferTask>) => void;
|
||||
dismissExternalUpload?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
interface SftpExternalOperationsResult {
|
||||
readTextFile: (side: "left" | "right", filePath: string) => Promise<string>;
|
||||
readBinaryFile: (side: "left" | "right", filePath: string) => Promise<ArrayBuffer>;
|
||||
writeTextFile: (side: "left" | "right", filePath: string, content: string) => Promise<void>;
|
||||
downloadToTempAndOpen: (
|
||||
side: "left" | "right",
|
||||
remotePath: string,
|
||||
fileName: string,
|
||||
appPath: string,
|
||||
options?: { enableWatch?: boolean }
|
||||
) => Promise<{ localTempPath: string; watchId?: string }>;
|
||||
uploadExternalFiles: (
|
||||
side: "left" | "right",
|
||||
dataTransfer: DataTransfer
|
||||
) => Promise<UploadResult[]>;
|
||||
cancelExternalUpload: () => Promise<void>;
|
||||
selectApplication: () => Promise<{ path: string; name: string } | null>;
|
||||
}
|
||||
|
||||
export const useSftpExternalOperations = (
|
||||
params: UseSftpExternalOperationsParams
|
||||
): SftpExternalOperationsResult => {
|
||||
const { getActivePane, refresh, sftpSessionsRef, addExternalUpload, updateExternalUpload, dismissExternalUpload } = params;
|
||||
|
||||
// Upload controller for cancellation support
|
||||
const uploadControllerRef = useRef<UploadController | null>(null);
|
||||
|
||||
const readTextFile = useCallback(
|
||||
async (side: "left" | "right", filePath: string): Promise<string> => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("No connection available");
|
||||
}
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.readLocalFile) {
|
||||
const buffer = await bridge.readLocalFile(filePath);
|
||||
return new TextDecoder().decode(buffer);
|
||||
}
|
||||
throw new Error("Local file reading not supported");
|
||||
}
|
||||
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge) {
|
||||
throw new Error("Bridge not available");
|
||||
}
|
||||
|
||||
return await bridge.readSftp(sftpId, filePath, pane.filenameEncoding);
|
||||
},
|
||||
[getActivePane, sftpSessionsRef],
|
||||
);
|
||||
|
||||
const readBinaryFile = useCallback(
|
||||
async (side: "left" | "right", filePath: string): Promise<ArrayBuffer> => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("No connection available");
|
||||
}
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.readLocalFile) {
|
||||
return await bridge.readLocalFile(filePath);
|
||||
}
|
||||
throw new Error("Local file reading not supported");
|
||||
}
|
||||
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.readSftpBinary) {
|
||||
throw new Error("Binary file reading not supported");
|
||||
}
|
||||
|
||||
return await bridge.readSftpBinary(sftpId, filePath, pane.filenameEncoding);
|
||||
},
|
||||
[getActivePane, sftpSessionsRef],
|
||||
);
|
||||
|
||||
const writeTextFile = useCallback(
|
||||
async (side: "left" | "right", filePath: string, content: string): Promise<void> => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("No connection available");
|
||||
}
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.writeLocalFile) {
|
||||
const data = new TextEncoder().encode(content);
|
||||
await bridge.writeLocalFile(filePath, data.buffer);
|
||||
return;
|
||||
}
|
||||
throw new Error("Local file writing not supported");
|
||||
}
|
||||
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge) {
|
||||
throw new Error("Bridge not available");
|
||||
}
|
||||
|
||||
await bridge.writeSftp(sftpId, filePath, content, pane.filenameEncoding);
|
||||
},
|
||||
[getActivePane, sftpSessionsRef],
|
||||
);
|
||||
|
||||
const downloadToTempAndOpen = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
remotePath: string,
|
||||
fileName: string,
|
||||
appPath: string,
|
||||
options?: { enableWatch?: boolean }
|
||||
): Promise<{ localTempPath: string; watchId?: string }> => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("No connection available");
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.downloadSftpToTemp || !bridge?.openWithApplication) {
|
||||
throw new Error("System app opening not supported");
|
||||
}
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
await bridge.openWithApplication(remotePath, appPath);
|
||||
return { localTempPath: remotePath };
|
||||
}
|
||||
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
console.log("[SFTP] Downloading file to temp", { sftpId, remotePath, fileName });
|
||||
const localTempPath = await bridge.downloadSftpToTemp(
|
||||
sftpId,
|
||||
remotePath,
|
||||
fileName,
|
||||
pane.filenameEncoding,
|
||||
);
|
||||
console.log("[SFTP] File downloaded to temp", { localTempPath });
|
||||
|
||||
if (bridge.registerTempFile) {
|
||||
try {
|
||||
await bridge.registerTempFile(sftpId, localTempPath);
|
||||
} catch (err) {
|
||||
console.warn("[SFTP] Failed to register temp file for cleanup:", err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[SFTP] Opening with application", { localTempPath, appPath });
|
||||
await bridge.openWithApplication(localTempPath, appPath);
|
||||
console.log("[SFTP] Application launched");
|
||||
|
||||
let watchId: string | undefined;
|
||||
console.log("[SFTP] Auto-sync enabled check", { enableWatch: options?.enableWatch, hasStartFileWatch: !!bridge.startFileWatch });
|
||||
if (options?.enableWatch && bridge.startFileWatch) {
|
||||
try {
|
||||
console.log("[SFTP] Starting file watch", { localTempPath, remotePath, sftpId });
|
||||
const result = await bridge.startFileWatch(
|
||||
localTempPath,
|
||||
remotePath,
|
||||
sftpId,
|
||||
pane.filenameEncoding,
|
||||
);
|
||||
watchId = result.watchId;
|
||||
console.log("[SFTP] File watch started successfully", { watchId, localTempPath, remotePath });
|
||||
} catch (err) {
|
||||
console.warn("[SFTP] Failed to start file watch:", err);
|
||||
}
|
||||
} else {
|
||||
console.log("[SFTP] File watching not enabled or not available");
|
||||
}
|
||||
|
||||
return { localTempPath, watchId };
|
||||
},
|
||||
[getActivePane, sftpSessionsRef],
|
||||
);
|
||||
|
||||
// Create upload callbacks that translate to TransferTask updates
|
||||
const createUploadCallbacks = useCallback((
|
||||
connectionId: string,
|
||||
targetPath: string
|
||||
): UploadCallbacks => {
|
||||
return {
|
||||
onScanningStart: (taskId: string) => {
|
||||
if (addExternalUpload) {
|
||||
const scanningTask: TransferTask = {
|
||||
id: taskId,
|
||||
fileName: "Scanning files...",
|
||||
sourcePath: "local",
|
||||
targetPath,
|
||||
sourceConnectionId: "external",
|
||||
targetConnectionId: connectionId,
|
||||
direction: "upload",
|
||||
status: "pending" as TransferStatus,
|
||||
totalBytes: 0,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: true,
|
||||
};
|
||||
addExternalUpload(scanningTask);
|
||||
}
|
||||
},
|
||||
onScanningEnd: (taskId: string) => {
|
||||
if (dismissExternalUpload) {
|
||||
dismissExternalUpload(taskId);
|
||||
}
|
||||
},
|
||||
onTaskCreated: (task: UploadTaskInfo) => {
|
||||
if (addExternalUpload) {
|
||||
const transferTask: TransferTask = {
|
||||
id: task.id,
|
||||
fileName: task.displayName,
|
||||
sourcePath: "local",
|
||||
targetPath: joinPath(targetPath, task.fileName),
|
||||
sourceConnectionId: "external",
|
||||
targetConnectionId: connectionId,
|
||||
direction: "upload",
|
||||
status: "transferring" as TransferStatus,
|
||||
totalBytes: task.totalBytes,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: task.isDirectory,
|
||||
};
|
||||
addExternalUpload(transferTask);
|
||||
}
|
||||
},
|
||||
onTaskProgress: (taskId: string, progress) => {
|
||||
if (updateExternalUpload) {
|
||||
updateExternalUpload(taskId, {
|
||||
transferredBytes: progress.transferred,
|
||||
speed: progress.speed,
|
||||
});
|
||||
}
|
||||
},
|
||||
onTaskCompleted: (taskId: string, totalBytes: number) => {
|
||||
if (updateExternalUpload) {
|
||||
updateExternalUpload(taskId, {
|
||||
status: "completed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
transferredBytes: totalBytes,
|
||||
speed: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
onTaskFailed: (taskId: string, error: string) => {
|
||||
if (updateExternalUpload) {
|
||||
updateExternalUpload(taskId, {
|
||||
status: "failed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
error,
|
||||
speed: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
onTaskCancelled: (taskId: string) => {
|
||||
if (updateExternalUpload) {
|
||||
updateExternalUpload(taskId, {
|
||||
status: "cancelled" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}, [addExternalUpload, updateExternalUpload, dismissExternalUpload]);
|
||||
|
||||
// Create upload bridge that wraps netcattyBridge
|
||||
const createUploadBridge = useMemo((): UploadBridge => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return {
|
||||
writeLocalFile: bridge?.writeLocalFile,
|
||||
mkdirLocal: bridge?.mkdirLocal,
|
||||
mkdirSftp: async (sftpId: string, path: string) => {
|
||||
const b = netcattyBridge.get();
|
||||
if (b?.mkdirSftp) {
|
||||
await b.mkdirSftp(sftpId, path);
|
||||
}
|
||||
},
|
||||
writeSftpBinary: bridge?.writeSftpBinary,
|
||||
// Wrap writeSftpBinaryWithProgress to adapt UploadBridge interface to NetcattyBridge interface
|
||||
// UploadBridge: (sftpId, path, data, taskId, onProgress, onComplete, onError)
|
||||
// NetcattyBridge: (sftpId, path, content, transferId, encoding, onProgress, onComplete, onError)
|
||||
writeSftpBinaryWithProgress: bridge?.writeSftpBinaryWithProgress
|
||||
? async (sftpId, path, data, taskId, onProgress, onComplete, onError) => {
|
||||
const b = netcattyBridge.get();
|
||||
if (!b?.writeSftpBinaryWithProgress) return undefined;
|
||||
// Pass undefined for encoding to use session default, and forward callbacks
|
||||
return b.writeSftpBinaryWithProgress(
|
||||
sftpId,
|
||||
path,
|
||||
data,
|
||||
taskId,
|
||||
undefined, // encoding - use session default
|
||||
onProgress,
|
||||
onComplete,
|
||||
onError
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
cancelSftpUpload: bridge?.cancelSftpUpload,
|
||||
// Stream transfer for large files (avoids loading into memory)
|
||||
startStreamTransfer: bridge?.startStreamTransfer
|
||||
? async (options, onProgress, onComplete, onError) => {
|
||||
const b = netcattyBridge.get();
|
||||
if (!b?.startStreamTransfer) {
|
||||
return { transferId: options.transferId, error: 'Stream transfer not available' };
|
||||
}
|
||||
try {
|
||||
const result = await b.startStreamTransfer(options, onProgress, onComplete, onError);
|
||||
return result;
|
||||
} catch (error) {
|
||||
return {
|
||||
transferId: options.transferId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
cancelTransfer: bridge?.cancelTransfer,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const uploadExternalFiles = useCallback(
|
||||
async (side: "left" | "right", dataTransfer: DataTransfer): Promise<UploadResult[]> => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("No active connection");
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge) {
|
||||
throw new Error("Bridge not available");
|
||||
}
|
||||
|
||||
const sftpId = pane.connection.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(pane.connection.id) || null;
|
||||
|
||||
if (!pane.connection.isLocal && !sftpId) {
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
// Create a new upload controller for this upload
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
|
||||
const callbacks = createUploadCallbacks(pane.connection.id, pane.connection.currentPath);
|
||||
|
||||
try {
|
||||
const results = await uploadFromDataTransfer(
|
||||
dataTransfer,
|
||||
{
|
||||
targetPath: pane.connection.currentPath,
|
||||
sftpId,
|
||||
isLocal: pane.connection.isLocal,
|
||||
bridge: createUploadBridge,
|
||||
joinPath,
|
||||
callbacks,
|
||||
},
|
||||
controller
|
||||
);
|
||||
|
||||
await refresh(side);
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error("[SFTP] Upload failed:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
uploadControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[getActivePane, refresh, sftpSessionsRef, createUploadCallbacks, createUploadBridge],
|
||||
);
|
||||
|
||||
const cancelExternalUpload = useCallback(async () => {
|
||||
const controller = uploadControllerRef.current;
|
||||
if (controller) {
|
||||
logger.info("[SFTP] Cancelling external upload");
|
||||
await controller.cancel();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const selectApplication = useCallback(
|
||||
async (): Promise<{ path: string; name: string } | null> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.selectApplication) {
|
||||
return null;
|
||||
}
|
||||
return await bridge.selectApplication();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
readTextFile,
|
||||
readBinaryFile,
|
||||
writeTextFile,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
};
|
||||
};
|
||||
27
application/state/sftp/useSftpFileWatch.ts
Normal file
27
application/state/sftp/useSftpFileWatch.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useEffect } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { FileWatchErrorEvent, FileWatchSyncedEvent, SftpStateOptions } from "./types";
|
||||
|
||||
export const useSftpFileWatch = (options?: SftpStateOptions) => {
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onFileWatchSynced || !bridge?.onFileWatchError) return;
|
||||
|
||||
const unsubscribeSynced = bridge.onFileWatchSynced((payload: FileWatchSyncedEvent) => {
|
||||
options?.onFileWatchSynced?.(payload);
|
||||
});
|
||||
|
||||
const unsubscribeError = bridge.onFileWatchError((payload: FileWatchErrorEvent) => {
|
||||
options?.onFileWatchError?.(payload);
|
||||
});
|
||||
|
||||
return () => {
|
||||
try {
|
||||
unsubscribeSynced?.();
|
||||
unsubscribeError?.();
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
};
|
||||
}, [options]);
|
||||
};
|
||||
75
application/state/sftp/useSftpHostCredentials.ts
Normal file
75
application/state/sftp/useSftpHostCredentials.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Host, Identity, SSHKey } from "../../../domain/models";
|
||||
import { resolveHostAuth } from "../../../domain/sshAuth";
|
||||
|
||||
interface UseSftpHostCredentialsParams {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
}
|
||||
|
||||
export const useSftpHostCredentials = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
}: UseSftpHostCredentialsParams) =>
|
||||
useCallback(
|
||||
(host: Host): NetcattySSHOptions => {
|
||||
const resolved = resolveHostAuth({ host, keys, identities });
|
||||
const key = resolved.key || null;
|
||||
|
||||
const proxyConfig = host.proxyConfig
|
||||
? {
|
||||
type: host.proxyConfig.type,
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
username: host.proxyConfig.username,
|
||||
password: host.proxyConfig.password,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
let jumpHosts: NetcattyJumpHost[] | undefined;
|
||||
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
|
||||
jumpHosts = host.hostChain.hostIds
|
||||
.map((hostId) => hosts.find((h) => h.id === hostId))
|
||||
.filter((h): h is Host => !!h)
|
||||
.map((jumpHost) => {
|
||||
const jumpAuth = resolveHostAuth({
|
||||
host: jumpHost,
|
||||
keys,
|
||||
identities,
|
||||
});
|
||||
const jumpKey = jumpAuth.key;
|
||||
return {
|
||||
hostname: jumpHost.hostname,
|
||||
port: jumpHost.port || 22,
|
||||
username: jumpAuth.username || "root",
|
||||
password: jumpAuth.password,
|
||||
privateKey: jumpKey?.privateKey,
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
|
||||
publicKey: jumpKey?.publicKey,
|
||||
keyId: jumpAuth.keyId,
|
||||
keySource: jumpKey?.source,
|
||||
label: jumpHost.label,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: host.hostname,
|
||||
username: resolved.username,
|
||||
port: host.port || 22,
|
||||
password: resolved.password,
|
||||
privateKey: key?.privateKey,
|
||||
certificate: key?.certificate,
|
||||
publicKey: key?.publicKey,
|
||||
keyId: resolved.keyId,
|
||||
keySource: key?.source,
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
sudo: host.sftpSudo,
|
||||
};
|
||||
},
|
||||
[hosts, identities, keys],
|
||||
);
|
||||
624
application/state/sftp/useSftpPaneActions.ts
Normal file
624
application/state/sftp/useSftpPaneActions.ts
Normal file
@@ -0,0 +1,624 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { SftpPane } from "./types";
|
||||
import { getParentPath, isNavigableDirectory, isWindowsRoot, joinPath } from "./utils";
|
||||
|
||||
interface UseSftpPaneActionsParams {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
|
||||
updateActiveTab: (side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => void;
|
||||
leftTabsRef: React.MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
|
||||
rightTabsRef: React.MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
|
||||
navSeqRef: React.MutableRefObject<{ left: number; right: number }>;
|
||||
dirCacheRef: React.MutableRefObject<Map<string, { files: SftpFileEntry[]; timestamp: number }>>;
|
||||
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
|
||||
lastConnectedHostRef: React.MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
|
||||
reconnectingRef: React.MutableRefObject<{ left: boolean; right: boolean }>;
|
||||
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
|
||||
clearCacheForConnection: (connectionId: string) => void;
|
||||
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
|
||||
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||||
handleSessionError: (side: "left" | "right", error: Error) => void;
|
||||
isSessionError: (err: unknown) => boolean;
|
||||
dirCacheTtlMs: number;
|
||||
}
|
||||
|
||||
interface UseSftpPaneActionsResult {
|
||||
navigateTo: (side: "left" | "right", path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
refresh: (side: "left" | "right") => Promise<void>;
|
||||
navigateUp: (side: "left" | "right") => Promise<void>;
|
||||
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
|
||||
toggleSelection: (side: "left" | "right", fileName: string, multiSelect: boolean) => void;
|
||||
rangeSelect: (side: "left" | "right", fileNames: string[]) => void;
|
||||
clearSelection: (side: "left" | "right") => void;
|
||||
selectAll: (side: "left" | "right") => void;
|
||||
setFilter: (side: "left" | "right", filter: string) => void;
|
||||
getFilteredFiles: (pane: SftpPane) => SftpFileEntry[];
|
||||
createDirectory: (side: "left" | "right", name: string) => Promise<void>;
|
||||
createFile: (side: "left" | "right", name: string) => Promise<void>;
|
||||
deleteFiles: (side: "left" | "right", fileNames: string[]) => Promise<void>;
|
||||
deleteFilesAtPath: (
|
||||
side: "left" | "right",
|
||||
connectionId: string,
|
||||
path: string,
|
||||
fileNames: string[],
|
||||
) => Promise<void>;
|
||||
renameFile: (side: "left" | "right", oldName: string, newName: string) => Promise<void>;
|
||||
changePermissions: (side: "left" | "right", filePath: string, mode: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpPaneActions = ({
|
||||
getActivePane,
|
||||
updateTab,
|
||||
updateActiveTab,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
navSeqRef,
|
||||
dirCacheRef,
|
||||
sftpSessionsRef,
|
||||
lastConnectedHostRef,
|
||||
reconnectingRef,
|
||||
makeCacheKey,
|
||||
clearCacheForConnection,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
handleSessionError,
|
||||
isSessionError,
|
||||
dirCacheTtlMs,
|
||||
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
|
||||
const navigateTo = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
path: string,
|
||||
options?: { force?: boolean },
|
||||
) => {
|
||||
console.log("[SFTP navigateTo] called", { side, path, force: options?.force });
|
||||
|
||||
const pane = getActivePane(side);
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
const activeTabId = sideTabs.activeTabId;
|
||||
|
||||
console.log("[SFTP navigateTo] state check", {
|
||||
paneId: pane?.id,
|
||||
hasConnection: !!pane?.connection,
|
||||
activeTabId,
|
||||
currentPath: pane?.connection?.currentPath,
|
||||
});
|
||||
|
||||
if (!pane?.connection || !activeTabId) {
|
||||
console.log("[SFTP navigateTo] No pane/connection/activeTabId, returning early");
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++navSeqRef.current[side];
|
||||
const cacheKey = makeCacheKey(pane.connection.id, path, pane.filenameEncoding);
|
||||
const cached = options?.force
|
||||
? undefined
|
||||
: dirCacheRef.current.get(cacheKey);
|
||||
|
||||
if (
|
||||
cached &&
|
||||
Date.now() - cached.timestamp < dirCacheTtlMs &&
|
||||
cached.files
|
||||
) {
|
||||
console.log("[SFTP navigateTo] Using cached files for path", { path, cacheKey });
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
? { ...prev.connection, currentPath: path }
|
||||
: null,
|
||||
files: cached.files,
|
||||
loading: false,
|
||||
error: null,
|
||||
selectedFiles: new Set(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[SFTP navigateTo] Fetching files from server for path", { path });
|
||||
updateTab(side, activeTabId, (prev) => ({ ...prev, loading: true, error: null }));
|
||||
|
||||
try {
|
||||
let files: SftpFileEntry[];
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
files = await listLocalFiles(path);
|
||||
} else {
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: null,
|
||||
files: [],
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: "SFTP session lost. Please reconnect.",
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
files = await listRemoteFiles(sftpId, path, pane.filenameEncoding);
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
sftpSessionsRef.current.delete(pane.connection.id);
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: null,
|
||||
files: [],
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: "SFTP session expired. Please reconnect.",
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
throw err as Error;
|
||||
}
|
||||
}
|
||||
|
||||
if (navSeqRef.current[side] !== requestId) return;
|
||||
|
||||
dirCacheRef.current.set(cacheKey, {
|
||||
files,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
? { ...prev.connection, currentPath: path }
|
||||
: null,
|
||||
files,
|
||||
loading: false,
|
||||
selectedFiles: new Set(),
|
||||
}));
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== requestId) return;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to list directory",
|
||||
loading: false,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[
|
||||
getActivePane,
|
||||
updateTab,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
navSeqRef,
|
||||
dirCacheRef,
|
||||
makeCacheKey,
|
||||
dirCacheTtlMs,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
sftpSessionsRef,
|
||||
clearCacheForConnection,
|
||||
isSessionError,
|
||||
],
|
||||
);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (side: "left" | "right") => {
|
||||
const pane = getActivePane(side);
|
||||
if (pane?.connection) {
|
||||
await navigateTo(side, pane.connection.currentPath, { force: true });
|
||||
} else if (!pane?.connection && pane?.error) {
|
||||
const lastHost = lastConnectedHostRef.current[side];
|
||||
if (lastHost && !reconnectingRef.current[side]) {
|
||||
reconnectingRef.current[side] = true;
|
||||
updateActiveTab(side, (prev) => ({
|
||||
...prev,
|
||||
reconnecting: true,
|
||||
error: "sftp.reconnecting.title",
|
||||
}));
|
||||
} else if (!lastHost) {
|
||||
updateActiveTab(side, (prev) => ({
|
||||
...prev,
|
||||
error: "sftp.error.connectionLostManual",
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
[getActivePane, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef],
|
||||
);
|
||||
|
||||
const navigateUp = useCallback(
|
||||
async (side: "left" | "right") => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) return;
|
||||
|
||||
const currentPath = pane.connection.currentPath;
|
||||
const isAtRoot = currentPath === "/" || isWindowsRoot(currentPath);
|
||||
|
||||
if (!isAtRoot) {
|
||||
const parentPath = getParentPath(currentPath);
|
||||
await navigateTo(side, parentPath);
|
||||
}
|
||||
},
|
||||
[getActivePane, navigateTo],
|
||||
);
|
||||
|
||||
const openEntry = useCallback(
|
||||
async (side: "left" | "right", entry: SftpFileEntry) => {
|
||||
console.log("[SFTP openEntry] called", { side, entryName: entry.name, entryType: entry.type });
|
||||
|
||||
const pane = getActivePane(side);
|
||||
console.log("[SFTP openEntry] getActivePane result", {
|
||||
paneId: pane?.id,
|
||||
hasConnection: !!pane?.connection,
|
||||
currentPath: pane?.connection?.currentPath,
|
||||
});
|
||||
|
||||
if (!pane?.connection) {
|
||||
console.log("[SFTP openEntry] No pane or connection, returning early");
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.name === "..") {
|
||||
const currentPath = pane.connection.currentPath;
|
||||
const isAtRoot = currentPath === "/" || isWindowsRoot(currentPath);
|
||||
console.log("[SFTP openEntry] Navigating up from '..'", {
|
||||
currentPath,
|
||||
isAtRoot,
|
||||
isWindowsRoot: isWindowsRoot(currentPath),
|
||||
});
|
||||
|
||||
if (!isAtRoot) {
|
||||
const parentPath = getParentPath(currentPath);
|
||||
console.log("[SFTP openEntry] Calculated parent path", { currentPath, parentPath });
|
||||
await navigateTo(side, parentPath);
|
||||
} else {
|
||||
console.log("[SFTP openEntry] Already at root, not navigating");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNavigableDirectory(entry)) {
|
||||
const newPath = joinPath(pane.connection.currentPath, entry.name);
|
||||
console.log("[SFTP openEntry] Navigating into directory", { currentPath: pane.connection.currentPath, entryName: entry.name, newPath });
|
||||
await navigateTo(side, newPath);
|
||||
}
|
||||
},
|
||||
[getActivePane, navigateTo],
|
||||
);
|
||||
|
||||
const toggleSelection = useCallback(
|
||||
(side: "left" | "right", fileName: string, multiSelect: boolean) => {
|
||||
updateActiveTab(side, (prev) => {
|
||||
const newSelection = new Set(multiSelect ? prev.selectedFiles : []);
|
||||
if (newSelection.has(fileName)) {
|
||||
newSelection.delete(fileName);
|
||||
} else {
|
||||
newSelection.add(fileName);
|
||||
}
|
||||
return { ...prev, selectedFiles: newSelection };
|
||||
});
|
||||
},
|
||||
[updateActiveTab],
|
||||
);
|
||||
|
||||
const rangeSelect = useCallback(
|
||||
(side: "left" | "right", fileNames: string[]) => {
|
||||
const newSelection = new Set<string>();
|
||||
for (const name of fileNames) {
|
||||
if (name && name !== "..") {
|
||||
newSelection.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: newSelection }));
|
||||
},
|
||||
[updateActiveTab],
|
||||
);
|
||||
|
||||
const clearSelection = useCallback((side: "left" | "right") => {
|
||||
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: new Set() }));
|
||||
}, [updateActiveTab]);
|
||||
|
||||
const selectAll = useCallback(
|
||||
(side: "left" | "right") => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane) return;
|
||||
|
||||
updateActiveTab(side, (prev) => ({
|
||||
...prev,
|
||||
selectedFiles: new Set(
|
||||
pane.files.filter((f) => f.name !== "..").map((f) => f.name),
|
||||
),
|
||||
}));
|
||||
},
|
||||
[getActivePane, updateActiveTab],
|
||||
);
|
||||
|
||||
const setFilter = useCallback((side: "left" | "right", filter: string) => {
|
||||
updateActiveTab(side, (prev) => ({ ...prev, filter }));
|
||||
}, [updateActiveTab]);
|
||||
|
||||
const getFilteredFiles = useCallback((pane: SftpPane): SftpFileEntry[] => {
|
||||
const term = pane.filter.trim().toLowerCase();
|
||||
if (!term) return pane.files;
|
||||
return pane.files.filter(
|
||||
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const createDirectory = useCallback(
|
||||
async (side: "left" | "right", name: string) => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) return;
|
||||
|
||||
const fullPath = joinPath(pane.connection.currentPath, name);
|
||||
|
||||
try {
|
||||
if (pane.connection.isLocal) {
|
||||
await netcattyBridge.get()?.mkdirLocal?.(fullPath);
|
||||
} else {
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
handleSessionError(side, new Error("SFTP session not found"));
|
||||
return;
|
||||
}
|
||||
await netcattyBridge.get()?.mkdirSftp(sftpId, fullPath, pane.filenameEncoding);
|
||||
}
|
||||
await refresh(side);
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
handleSessionError(side, err as Error);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||||
);
|
||||
|
||||
const createFile = useCallback(
|
||||
async (side: "left" | "right", name: string) => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) return;
|
||||
|
||||
const fullPath = joinPath(pane.connection.currentPath, name);
|
||||
|
||||
try {
|
||||
if (pane.connection.isLocal) {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.writeLocalFile) {
|
||||
const emptyBuffer = new ArrayBuffer(0);
|
||||
await bridge.writeLocalFile(fullPath, emptyBuffer);
|
||||
} else {
|
||||
throw new Error("Local file writing not supported");
|
||||
}
|
||||
} else {
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
handleSessionError(side, new Error("SFTP session not found"));
|
||||
return;
|
||||
}
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.writeSftpBinary) {
|
||||
const emptyBuffer = new ArrayBuffer(0);
|
||||
await bridge.writeSftpBinary(sftpId, fullPath, emptyBuffer, pane.filenameEncoding);
|
||||
} else if (bridge?.writeSftp) {
|
||||
await bridge.writeSftp(sftpId, fullPath, "", pane.filenameEncoding);
|
||||
} else {
|
||||
throw new Error("No write method available");
|
||||
}
|
||||
}
|
||||
await refresh(side);
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
handleSessionError(side, err as Error);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||||
);
|
||||
|
||||
const deleteFiles = useCallback(
|
||||
async (side: "left" | "right", fileNames: string[]) => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) return;
|
||||
|
||||
try {
|
||||
for (const name of fileNames) {
|
||||
const fullPath = joinPath(pane.connection.currentPath, name);
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
await netcattyBridge.get()?.deleteLocalFile?.(fullPath);
|
||||
} else {
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
handleSessionError(side, new Error("SFTP session not found"));
|
||||
return;
|
||||
}
|
||||
await netcattyBridge.get()?.deleteSftp?.(sftpId, fullPath, pane.filenameEncoding);
|
||||
}
|
||||
}
|
||||
await refresh(side);
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
handleSessionError(side, err as Error);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||||
);
|
||||
|
||||
const deleteFilesAtPath = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
connectionId: string,
|
||||
path: string,
|
||||
fileNames: string[],
|
||||
) => {
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
const pane = sideTabs.tabs.find((tab) => tab.connection?.id === connectionId);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("Source pane is no longer available");
|
||||
}
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge) {
|
||||
throw new Error("Netcatty bridge not available");
|
||||
}
|
||||
|
||||
try {
|
||||
for (const name of fileNames) {
|
||||
const fullPath = joinPath(path, name);
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
if (!bridge.deleteLocalFile) {
|
||||
throw new Error("Local delete unavailable");
|
||||
}
|
||||
await bridge.deleteLocalFile(fullPath);
|
||||
} else {
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
const error = new Error("SFTP session not found");
|
||||
handleSessionError(side, error);
|
||||
throw error;
|
||||
}
|
||||
if (!bridge.deleteSftp) {
|
||||
throw new Error("SFTP delete unavailable");
|
||||
}
|
||||
await bridge.deleteSftp(sftpId, fullPath, pane.filenameEncoding);
|
||||
}
|
||||
}
|
||||
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
|
||||
if (sideTabs.activeTabId === pane.id && pane.connection.currentPath === path) {
|
||||
await refresh(side);
|
||||
} else {
|
||||
updateTab(side, pane.id, (prev) => {
|
||||
if (!prev.connection || prev.connection.id !== connectionId) return prev;
|
||||
if (prev.connection.currentPath !== path) return prev;
|
||||
|
||||
const removeSet = new Set(fileNames);
|
||||
const filteredFiles = prev.files.filter((file) => !removeSet.has(file.name));
|
||||
const nextSelection = new Set(prev.selectedFiles);
|
||||
for (const name of fileNames) {
|
||||
nextSelection.delete(name);
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
files: filteredFiles,
|
||||
selectedFiles: nextSelection,
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
handleSessionError(side, err as Error);
|
||||
throw err;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[
|
||||
clearCacheForConnection,
|
||||
handleSessionError,
|
||||
isSessionError,
|
||||
leftTabsRef,
|
||||
refresh,
|
||||
rightTabsRef,
|
||||
sftpSessionsRef,
|
||||
updateTab,
|
||||
],
|
||||
);
|
||||
|
||||
const renameFile = useCallback(
|
||||
async (side: "left" | "right", oldName: string, newName: string) => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) return;
|
||||
|
||||
const oldPath = joinPath(pane.connection.currentPath, oldName);
|
||||
const newPath = joinPath(pane.connection.currentPath, newName);
|
||||
|
||||
try {
|
||||
if (pane.connection.isLocal) {
|
||||
await netcattyBridge.get()?.renameLocalFile?.(oldPath, newPath);
|
||||
} else {
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
handleSessionError(side, new Error("SFTP session not found"));
|
||||
return;
|
||||
}
|
||||
await netcattyBridge.get()?.renameSftp?.(sftpId, oldPath, newPath, pane.filenameEncoding);
|
||||
}
|
||||
await refresh(side);
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
handleSessionError(side, err as Error);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||||
);
|
||||
|
||||
const changePermissions = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
filePath: string,
|
||||
mode: string,
|
||||
) => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection || pane.connection.isLocal) {
|
||||
logger.warn("Cannot change permissions on local files");
|
||||
return;
|
||||
}
|
||||
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId || !netcattyBridge.get()?.chmodSftp) {
|
||||
handleSessionError(side, new Error("SFTP session not found"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await netcattyBridge.get()!.chmodSftp!(sftpId, filePath, mode, pane.filenameEncoding);
|
||||
await refresh(side);
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
handleSessionError(side, err as Error);
|
||||
return;
|
||||
}
|
||||
logger.error("Failed to change permissions:", err);
|
||||
}
|
||||
},
|
||||
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||||
);
|
||||
|
||||
return {
|
||||
navigateTo,
|
||||
refresh,
|
||||
navigateUp,
|
||||
openEntry,
|
||||
toggleSelection,
|
||||
rangeSelect,
|
||||
clearSelection,
|
||||
selectAll,
|
||||
setFilter,
|
||||
getFilteredFiles,
|
||||
createDirectory,
|
||||
createFile,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
renameFile,
|
||||
changePermissions,
|
||||
};
|
||||
};
|
||||
19
application/state/sftp/useSftpSessionCleanup.ts
Normal file
19
application/state/sftp/useSftpSessionCleanup.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
|
||||
export const useSftpSessionCleanup = (sftpSessionsRef: MutableRefObject<Map<string, string>>) => {
|
||||
useEffect(() => {
|
||||
const sessionsRef = sftpSessionsRef.current;
|
||||
|
||||
return () => {
|
||||
sessionsRef.forEach(async (sftpId) => {
|
||||
try {
|
||||
await netcattyBridge.get()?.closeSftp(sftpId);
|
||||
} catch {
|
||||
// Ignore errors when closing SFTP sessions during cleanup
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [sftpSessionsRef]);
|
||||
};
|
||||
78
application/state/sftp/useSftpSessionErrors.ts
Normal file
78
application/state/sftp/useSftpSessionErrors.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useCallback } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { Host } from "../../../domain/models";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
interface UseSftpSessionErrorsParams {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
leftTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
|
||||
rightTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
|
||||
updateActiveTab: (
|
||||
side: "left" | "right",
|
||||
updater: (prev: SftpPane) => SftpPane,
|
||||
) => void;
|
||||
sftpSessionsRef: MutableRefObject<Map<string, string>>;
|
||||
clearCacheForConnection: (connectionId: string) => void;
|
||||
navSeqRef: MutableRefObject<{ left: number; right: number }>;
|
||||
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
|
||||
reconnectingRef: MutableRefObject<{ left: boolean; right: boolean }>;
|
||||
}
|
||||
|
||||
export const useSftpSessionErrors = ({
|
||||
getActivePane,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
updateActiveTab,
|
||||
sftpSessionsRef,
|
||||
clearCacheForConnection,
|
||||
navSeqRef,
|
||||
lastConnectedHostRef,
|
||||
reconnectingRef,
|
||||
}: UseSftpSessionErrorsParams) =>
|
||||
useCallback(
|
||||
(side: "left" | "right", _error: Error) => {
|
||||
const pane = getActivePane(side);
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
|
||||
if (!pane || !sideTabs.activeTabId) return;
|
||||
|
||||
if (pane.connection) {
|
||||
sftpSessionsRef.current.delete(pane.connection.id);
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
}
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
|
||||
const lastHost = lastConnectedHostRef.current[side];
|
||||
if (lastHost && pane.files.length > 0 && !reconnectingRef.current[side]) {
|
||||
reconnectingRef.current[side] = true;
|
||||
updateActiveTab(side, (prev) => ({
|
||||
...prev,
|
||||
reconnecting: true,
|
||||
error: "sftp.error.connectionLostReconnecting",
|
||||
}));
|
||||
} else {
|
||||
updateActiveTab(side, (prev) => ({
|
||||
...prev,
|
||||
connection: null,
|
||||
files: [],
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: "sftp.error.sessionLost",
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
}));
|
||||
}
|
||||
},
|
||||
[
|
||||
getActivePane,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
updateActiveTab,
|
||||
sftpSessionsRef,
|
||||
clearCacheForConnection,
|
||||
navSeqRef,
|
||||
lastConnectedHostRef,
|
||||
reconnectingRef,
|
||||
],
|
||||
);
|
||||
247
application/state/sftp/useSftpTabsState.ts
Normal file
247
application/state/sftp/useSftpTabsState.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { createEmptyPane, EMPTY_LEFT_PANE_ID, EMPTY_RIGHT_PANE_ID, SftpPane, SftpSideTabs } from "./types";
|
||||
import { logger } from "../../../lib/logger";
|
||||
|
||||
export interface SftpTabsState {
|
||||
leftTabs: SftpSideTabs;
|
||||
rightTabs: SftpSideTabs;
|
||||
leftTabsRef: React.MutableRefObject<SftpSideTabs>;
|
||||
rightTabsRef: React.MutableRefObject<SftpSideTabs>;
|
||||
setLeftTabs: React.Dispatch<React.SetStateAction<SftpSideTabs>>;
|
||||
setRightTabs: React.Dispatch<React.SetStateAction<SftpSideTabs>>;
|
||||
leftPane: SftpPane;
|
||||
rightPane: SftpPane;
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
|
||||
updateActiveTab: (side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => void;
|
||||
addTab: (side: "left" | "right") => string;
|
||||
closeTab: (side: "left" | "right", tabId: string) => void;
|
||||
selectTab: (side: "left" | "right", tabId: string) => void;
|
||||
reorderTabs: (
|
||||
side: "left" | "right",
|
||||
draggedId: string,
|
||||
targetId: string,
|
||||
position: "before" | "after",
|
||||
) => void;
|
||||
moveTabToOtherSide: (fromSide: "left" | "right", tabId: string) => void;
|
||||
getTabsInfo: (side: "left" | "right") => Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
isLocal: boolean;
|
||||
hostId: string | null;
|
||||
}>;
|
||||
getActiveTabId: (side: "left" | "right") => string | null;
|
||||
}
|
||||
|
||||
export const useSftpTabsState = (): SftpTabsState => {
|
||||
const [leftTabs, setLeftTabs] = useState<SftpSideTabs>({
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
});
|
||||
const [rightTabs, setRightTabs] = useState<SftpSideTabs>({
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
});
|
||||
|
||||
const leftTabsRef = useRef(leftTabs);
|
||||
const rightTabsRef = useRef(rightTabs);
|
||||
leftTabsRef.current = leftTabs;
|
||||
rightTabsRef.current = rightTabs;
|
||||
|
||||
const getActivePane = useCallback((side: "left" | "right"): SftpPane | null => {
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
if (!sideTabs.activeTabId) return null;
|
||||
return sideTabs.tabs.find((t) => t.id === sideTabs.activeTabId) || null;
|
||||
}, []);
|
||||
|
||||
const leftPane = useMemo(() => {
|
||||
const pane = leftTabs.activeTabId
|
||||
? leftTabs.tabs.find((t) => t.id === leftTabs.activeTabId)
|
||||
: null;
|
||||
return pane || createEmptyPane(EMPTY_LEFT_PANE_ID);
|
||||
}, [leftTabs]);
|
||||
|
||||
const rightPane = useMemo(() => {
|
||||
const pane = rightTabs.activeTabId
|
||||
? rightTabs.tabs.find((t) => t.id === rightTabs.activeTabId)
|
||||
: null;
|
||||
return pane || createEmptyPane(EMPTY_RIGHT_PANE_ID);
|
||||
}, [rightTabs]);
|
||||
|
||||
const updateTab = useCallback(
|
||||
(side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => {
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
setTabs((prev) => ({
|
||||
...prev,
|
||||
tabs: prev.tabs.map((t) => (t.id === tabId ? updater(t) : t)),
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateActiveTab = useCallback(
|
||||
(side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => {
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
if (!sideTabs.activeTabId) return;
|
||||
updateTab(side, sideTabs.activeTabId, updater);
|
||||
},
|
||||
[updateTab],
|
||||
);
|
||||
|
||||
const addTab = useCallback(
|
||||
(side: "left" | "right") => {
|
||||
const newPane = createEmptyPane();
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
setTabs((prev) => ({
|
||||
tabs: [...prev.tabs, newPane],
|
||||
activeTabId: newPane.id,
|
||||
}));
|
||||
return newPane.id;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const closeTab = useCallback(
|
||||
(side: "left" | "right", tabId: string) => {
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
setTabs((prev) => {
|
||||
const tabIndex = prev.tabs.findIndex((t) => t.id === tabId);
|
||||
if (tabIndex === -1) return prev;
|
||||
|
||||
let newActiveTabId: string | null = null;
|
||||
if (prev.tabs.length > 1) {
|
||||
if (prev.activeTabId === tabId) {
|
||||
const nextIndex = tabIndex < prev.tabs.length - 1 ? tabIndex + 1 : tabIndex - 1;
|
||||
newActiveTabId = prev.tabs[nextIndex]?.id || null;
|
||||
} else {
|
||||
newActiveTabId = prev.activeTabId;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tabs: prev.tabs.filter((t) => t.id !== tabId),
|
||||
activeTabId: newActiveTabId,
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const selectTab = useCallback(
|
||||
(side: "left" | "right", tabId: string) => {
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
setTabs((prev) => ({
|
||||
...prev,
|
||||
activeTabId: tabId,
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const reorderTabs = useCallback(
|
||||
(
|
||||
side: "left" | "right",
|
||||
draggedId: string,
|
||||
targetId: string,
|
||||
position: "before" | "after",
|
||||
) => {
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
setTabs((prev) => {
|
||||
const tabs = [...prev.tabs];
|
||||
const draggedIndex = tabs.findIndex((t) => t.id === draggedId);
|
||||
const targetIndex = tabs.findIndex((t) => t.id === targetId);
|
||||
|
||||
if (draggedIndex === -1 || targetIndex === -1) return prev;
|
||||
|
||||
const [draggedTab] = tabs.splice(draggedIndex, 1);
|
||||
const insertIndex = position === "before" ? targetIndex : targetIndex + 1;
|
||||
const adjustedIndex = draggedIndex < targetIndex ? insertIndex - 1 : insertIndex;
|
||||
tabs.splice(adjustedIndex, 0, draggedTab);
|
||||
|
||||
return { ...prev, tabs };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const moveTabToOtherSide = useCallback(
|
||||
(fromSide: "left" | "right", tabId: string) => {
|
||||
const sourceTabs = fromSide === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
const setSourceTabs = fromSide === "left" ? setLeftTabs : setRightTabs;
|
||||
const setTargetTabs = fromSide === "left" ? setRightTabs : setLeftTabs;
|
||||
|
||||
const tabToMove = sourceTabs.tabs.find((t) => t.id === tabId);
|
||||
if (!tabToMove) return;
|
||||
|
||||
logger.info("[SFTP] Moving tab to other side", {
|
||||
fromSide,
|
||||
toSide: fromSide === "left" ? "right" : "left",
|
||||
tabId,
|
||||
hostLabel: tabToMove.connection?.hostLabel,
|
||||
});
|
||||
|
||||
setSourceTabs((prev) => {
|
||||
const newTabs = prev.tabs.filter((t) => t.id !== tabId);
|
||||
let newActiveTabId: string | null = null;
|
||||
if (newTabs.length > 0) {
|
||||
if (prev.activeTabId === tabId) {
|
||||
newActiveTabId = newTabs[0].id;
|
||||
} else {
|
||||
newActiveTabId = prev.activeTabId;
|
||||
}
|
||||
}
|
||||
return { tabs: newTabs, activeTabId: newActiveTabId };
|
||||
});
|
||||
|
||||
setTargetTabs((prev) => ({
|
||||
tabs: [...prev.tabs, tabToMove],
|
||||
activeTabId: tabToMove.id,
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const DEFAULT_TAB_LABEL = "New Tab";
|
||||
|
||||
const getTabsInfo = useCallback(
|
||||
(side: "left" | "right") => {
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
return sideTabs.tabs.map((pane) => ({
|
||||
id: pane.id,
|
||||
label: pane.connection?.hostLabel || DEFAULT_TAB_LABEL,
|
||||
isLocal: pane.connection?.isLocal || false,
|
||||
hostId: pane.connection?.hostId || null,
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getActiveTabId = useCallback(
|
||||
(side: "left" | "right") => {
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
return sideTabs.activeTabId;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
leftTabs,
|
||||
rightTabs,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
setLeftTabs,
|
||||
setRightTabs,
|
||||
leftPane,
|
||||
rightPane,
|
||||
getActivePane,
|
||||
updateTab,
|
||||
updateActiveTab,
|
||||
addTab,
|
||||
closeTab,
|
||||
selectTab,
|
||||
reorderTabs,
|
||||
moveTabToOtherSide,
|
||||
getTabsInfo,
|
||||
getActiveTabId,
|
||||
};
|
||||
};
|
||||
878
application/state/sftp/useSftpTransfers.ts
Normal file
878
application/state/sftp/useSftpTransfers.ts
Normal file
@@ -0,0 +1,878 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
FileConflict,
|
||||
SftpFileEntry,
|
||||
SftpFilenameEncoding,
|
||||
TransferDirection,
|
||||
TransferStatus,
|
||||
TransferTask,
|
||||
} from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { SftpPane } from "./types";
|
||||
import { joinPath } from "./utils";
|
||||
|
||||
interface UseSftpTransfersParams {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
refresh: (side: "left" | "right") => Promise<void>;
|
||||
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
|
||||
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
|
||||
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||||
handleSessionError: (side: "left" | "right", error: Error) => void;
|
||||
}
|
||||
|
||||
interface UseSftpTransfersResult {
|
||||
transfers: TransferTask[];
|
||||
conflicts: FileConflict[];
|
||||
activeTransfersCount: number;
|
||||
startTransfer: (
|
||||
sourceFiles: { name: string; isDirectory: boolean }[],
|
||||
sourceSide: "left" | "right",
|
||||
targetSide: "left" | "right",
|
||||
options?: {
|
||||
sourcePane?: SftpPane;
|
||||
sourcePath?: string;
|
||||
sourceConnectionId?: string;
|
||||
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
|
||||
},
|
||||
) => Promise<TransferResult[]>;
|
||||
addExternalUpload: (task: TransferTask) => void;
|
||||
updateExternalUpload: (taskId: string, updates: Partial<TransferTask>) => void;
|
||||
cancelTransfer: (transferId: string) => Promise<void>;
|
||||
retryTransfer: (transferId: string) => Promise<void>;
|
||||
clearCompletedTransfers: () => void;
|
||||
dismissTransfer: (transferId: string) => void;
|
||||
resolveConflict: (conflictId: string, action: "replace" | "skip" | "duplicate") => Promise<void>;
|
||||
}
|
||||
|
||||
interface TransferResult {
|
||||
id: string;
|
||||
fileName: string;
|
||||
originalFileName?: string;
|
||||
status: TransferStatus;
|
||||
}
|
||||
|
||||
export const useSftpTransfers = ({
|
||||
getActivePane,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
handleSessionError,
|
||||
}: UseSftpTransfersParams): UseSftpTransfersResult => {
|
||||
const [transfers, setTransfers] = useState<TransferTask[]>([]);
|
||||
const [conflicts, setConflicts] = useState<FileConflict[]>([]);
|
||||
|
||||
const progressIntervalsRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
// Track cancelled task IDs for checking during async operations
|
||||
const cancelledTasksRef = useRef<Set<string>>(new Set());
|
||||
const completionHandlersRef = useRef<Map<string, (result: TransferResult) => void | Promise<void>>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const intervalsRef = progressIntervalsRef.current;
|
||||
return () => {
|
||||
intervalsRef.forEach((interval) => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
intervalsRef.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startProgressSimulation = useCallback(
|
||||
(taskId: string, estimatedBytes: number) => {
|
||||
const existing = progressIntervalsRef.current.get(taskId);
|
||||
if (existing) clearInterval(existing);
|
||||
|
||||
const baseSpeed = Math.max(50000, Math.min(500000, estimatedBytes / 10));
|
||||
const variability = 0.3;
|
||||
|
||||
let transferred = 0;
|
||||
const interval = setInterval(() => {
|
||||
const speedFactor = 1 + (Math.random() - 0.5) * variability;
|
||||
const chunkSize = Math.floor(baseSpeed * speedFactor * 0.1);
|
||||
transferred = Math.min(transferred + chunkSize, estimatedBytes);
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== taskId || t.status !== "transferring") return t;
|
||||
return {
|
||||
...t,
|
||||
transferredBytes: transferred,
|
||||
totalBytes: estimatedBytes,
|
||||
speed: chunkSize * 10,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (transferred >= estimatedBytes * 0.95) {
|
||||
clearInterval(interval);
|
||||
progressIntervalsRef.current.delete(taskId);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
progressIntervalsRef.current.set(taskId, interval);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const stopProgressSimulation = useCallback((taskId: string) => {
|
||||
const interval = progressIntervalsRef.current.get(taskId);
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
progressIntervalsRef.current.delete(taskId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const transferFile = async (
|
||||
task: TransferTask,
|
||||
sourceSftpId: string | null,
|
||||
targetSftpId: string | null,
|
||||
sourceIsLocal: boolean,
|
||||
targetIsLocal: boolean,
|
||||
sourceEncoding: SftpFilenameEncoding,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string, // The original top-level task ID for cancellation checking
|
||||
): Promise<void> => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
if (netcattyBridge.get()?.startStreamTransfer) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
transferId: task.id,
|
||||
sourcePath: task.sourcePath,
|
||||
targetPath: task.targetPath,
|
||||
sourceType: sourceIsLocal ? ("local" as const) : ("sftp" as const),
|
||||
targetType: targetIsLocal ? ("local" as const) : ("sftp" as const),
|
||||
sourceSftpId: sourceSftpId || undefined,
|
||||
targetSftpId: targetSftpId || undefined,
|
||||
totalBytes: task.totalBytes || undefined,
|
||||
sourceEncoding: sourceIsLocal ? undefined : sourceEncoding,
|
||||
targetEncoding: targetIsLocal ? undefined : targetEncoding,
|
||||
};
|
||||
|
||||
const onProgress = (
|
||||
transferred: number,
|
||||
total: number,
|
||||
speed: number,
|
||||
) => {
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== task.id) return t;
|
||||
if (t.status === "cancelled") return t;
|
||||
return {
|
||||
...t,
|
||||
transferredBytes: transferred,
|
||||
totalBytes: total || t.totalBytes,
|
||||
speed,
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onComplete = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onError = (error: string) => {
|
||||
reject(new Error(error));
|
||||
};
|
||||
|
||||
netcattyBridge.require().startStreamTransfer!(
|
||||
options,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onError,
|
||||
).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
let content: ArrayBuffer | string;
|
||||
|
||||
if (sourceIsLocal) {
|
||||
content =
|
||||
(await netcattyBridge.get()?.readLocalFile?.(task.sourcePath)) ||
|
||||
new ArrayBuffer(0);
|
||||
} else if (sourceSftpId) {
|
||||
if (netcattyBridge.get()?.readSftpBinary) {
|
||||
content = await netcattyBridge.get()!.readSftpBinary!(
|
||||
sourceSftpId,
|
||||
task.sourcePath,
|
||||
sourceEncoding,
|
||||
);
|
||||
} else {
|
||||
content =
|
||||
(await netcattyBridge.get()?.readSftp(sourceSftpId, task.sourcePath, sourceEncoding)) || "";
|
||||
}
|
||||
} else {
|
||||
throw new Error("No source connection");
|
||||
}
|
||||
|
||||
if (targetIsLocal) {
|
||||
if (content instanceof ArrayBuffer) {
|
||||
await netcattyBridge.get()?.writeLocalFile?.(task.targetPath, content);
|
||||
} else {
|
||||
const encoder = new TextEncoder();
|
||||
await netcattyBridge.get()?.writeLocalFile?.(
|
||||
task.targetPath,
|
||||
encoder.encode(content).buffer,
|
||||
);
|
||||
}
|
||||
} else if (targetSftpId) {
|
||||
if (content instanceof ArrayBuffer && netcattyBridge.get()?.writeSftpBinary) {
|
||||
await netcattyBridge.get()!.writeSftpBinary!(
|
||||
targetSftpId,
|
||||
task.targetPath,
|
||||
content,
|
||||
targetEncoding,
|
||||
);
|
||||
} else {
|
||||
const text =
|
||||
content instanceof ArrayBuffer
|
||||
? new TextDecoder().decode(content)
|
||||
: content;
|
||||
await netcattyBridge.get()?.writeSftp(targetSftpId, task.targetPath, text, targetEncoding);
|
||||
}
|
||||
} else {
|
||||
throw new Error("No target connection");
|
||||
}
|
||||
};
|
||||
|
||||
const transferDirectory = async (
|
||||
task: TransferTask,
|
||||
sourceSftpId: string | null,
|
||||
targetSftpId: string | null,
|
||||
sourceIsLocal: boolean,
|
||||
targetIsLocal: boolean,
|
||||
sourceEncoding: SftpFilenameEncoding,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string, // The original top-level task ID for cancellation checking
|
||||
) => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
if (targetIsLocal) {
|
||||
await netcattyBridge.get()?.mkdirLocal?.(task.targetPath);
|
||||
} else if (targetSftpId) {
|
||||
await netcattyBridge.get()?.mkdirSftp(targetSftpId, task.targetPath, targetEncoding);
|
||||
}
|
||||
|
||||
let files: SftpFileEntry[];
|
||||
if (sourceIsLocal) {
|
||||
files = await listLocalFiles(task.sourcePath);
|
||||
} else if (sourceSftpId) {
|
||||
files = await listRemoteFiles(sourceSftpId, task.sourcePath, sourceEncoding);
|
||||
} else {
|
||||
throw new Error("No source connection");
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (file.name === "..") continue;
|
||||
|
||||
// Check if root task was cancelled during iteration
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const childTask: TransferTask = {
|
||||
...task,
|
||||
id: crypto.randomUUID(),
|
||||
fileName: file.name,
|
||||
originalFileName: file.name,
|
||||
sourcePath: joinPath(task.sourcePath, file.name),
|
||||
targetPath: joinPath(task.targetPath, file.name),
|
||||
isDirectory: file.type === "directory",
|
||||
parentTaskId: task.id,
|
||||
};
|
||||
|
||||
if (file.type === "directory") {
|
||||
await transferDirectory(
|
||||
childTask,
|
||||
sourceSftpId,
|
||||
targetSftpId,
|
||||
sourceIsLocal,
|
||||
targetIsLocal,
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
rootTaskId,
|
||||
);
|
||||
} else {
|
||||
await transferFile(
|
||||
childTask,
|
||||
sourceSftpId,
|
||||
targetSftpId,
|
||||
sourceIsLocal,
|
||||
targetIsLocal,
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
rootTaskId,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const processTransfer = async (
|
||||
task: TransferTask,
|
||||
sourcePane: SftpPane,
|
||||
targetPane: SftpPane,
|
||||
targetSide: "left" | "right",
|
||||
): Promise<TransferStatus> => {
|
||||
const updateTask = (updates: Partial<TransferTask>) => {
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => (t.id === task.id ? { ...t, ...updates } : t)),
|
||||
);
|
||||
};
|
||||
|
||||
// Initialize encoding early to avoid temporal dead zone issues
|
||||
const sourceEncoding: SftpFilenameEncoding = sourcePane.connection?.isLocal
|
||||
? "auto"
|
||||
: sourcePane.filenameEncoding || "auto";
|
||||
const targetEncoding: SftpFilenameEncoding = targetPane.connection?.isLocal
|
||||
? "auto"
|
||||
: targetPane.filenameEncoding || "auto";
|
||||
|
||||
let actualFileSize = task.totalBytes;
|
||||
if (!task.isDirectory && actualFileSize === 0) {
|
||||
try {
|
||||
const sourceSftpId = sourcePane.connection?.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(sourcePane.connection!.id);
|
||||
|
||||
if (sourcePane.connection?.isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
|
||||
if (stat) actualFileSize = stat.size;
|
||||
} else if (sourceSftpId) {
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
task.sourcePath,
|
||||
sourceEncoding,
|
||||
);
|
||||
if (stat) actualFileSize = stat.size;
|
||||
}
|
||||
} catch {
|
||||
// Ignore stat errors
|
||||
}
|
||||
}
|
||||
|
||||
const estimatedSize =
|
||||
actualFileSize > 0
|
||||
? actualFileSize
|
||||
: task.isDirectory
|
||||
? 1024 * 1024
|
||||
: 256 * 1024;
|
||||
|
||||
const hasStreamingTransfer = !!netcattyBridge.get()?.startStreamTransfer;
|
||||
|
||||
updateTask({
|
||||
status: "transferring",
|
||||
totalBytes: estimatedSize,
|
||||
transferredBytes: 0,
|
||||
startTime: Date.now(),
|
||||
});
|
||||
|
||||
const sourceSftpId = sourcePane.connection?.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(sourcePane.connection!.id);
|
||||
const targetSftpId = targetPane.connection?.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(targetPane.connection!.id);
|
||||
|
||||
if (!sourcePane.connection?.isLocal && !sourceSftpId) {
|
||||
const sourceSide = targetSide === "left" ? "right" : "left";
|
||||
handleSessionError(sourceSide, new Error("Source SFTP session lost"));
|
||||
throw new Error("Source SFTP session not found");
|
||||
}
|
||||
|
||||
if (!targetPane.connection?.isLocal && !targetSftpId) {
|
||||
handleSessionError(targetSide, new Error("Target SFTP session lost"));
|
||||
throw new Error("Target SFTP session not found");
|
||||
}
|
||||
|
||||
let useSimulatedProgress = false;
|
||||
if (!hasStreamingTransfer || task.isDirectory) {
|
||||
useSimulatedProgress = true;
|
||||
startProgressSimulation(task.id, estimatedSize);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!task.skipConflictCheck && !task.isDirectory && targetPane.connection) {
|
||||
let targetExists = false;
|
||||
let existingStat: { size: number; mtime: number } | null = null;
|
||||
let sourceStat: { size: number; mtime: number } | null = null;
|
||||
|
||||
try {
|
||||
if (sourcePane.connection.isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
|
||||
if (stat) {
|
||||
sourceStat = {
|
||||
size: stat.size,
|
||||
mtime: stat.lastModified || Date.now(),
|
||||
};
|
||||
}
|
||||
} else if (sourceSftpId) {
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
task.sourcePath,
|
||||
sourceEncoding,
|
||||
);
|
||||
if (stat) {
|
||||
sourceStat = {
|
||||
size: stat.size,
|
||||
mtime: stat.lastModified || Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
if (targetPane.connection.isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(task.targetPath);
|
||||
if (stat) {
|
||||
targetExists = true;
|
||||
existingStat = {
|
||||
size: stat.size,
|
||||
mtime: stat.lastModified || Date.now(),
|
||||
};
|
||||
}
|
||||
} else if (targetSftpId) {
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
targetSftpId,
|
||||
task.targetPath,
|
||||
targetEncoding,
|
||||
);
|
||||
if (stat) {
|
||||
targetExists = true;
|
||||
existingStat = {
|
||||
size: stat.size,
|
||||
mtime: stat.lastModified || Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (targetExists && existingStat) {
|
||||
stopProgressSimulation(task.id);
|
||||
|
||||
const newConflict: FileConflict = {
|
||||
transferId: task.id,
|
||||
fileName: task.fileName,
|
||||
sourcePath: task.sourcePath,
|
||||
targetPath: task.targetPath,
|
||||
existingSize: existingStat.size,
|
||||
newSize: sourceStat?.size || estimatedSize,
|
||||
existingModified: existingStat.mtime,
|
||||
newModified: sourceStat?.mtime || Date.now(),
|
||||
};
|
||||
setConflicts((prev) => [...prev, newConflict]);
|
||||
updateTask({
|
||||
status: "pending",
|
||||
totalBytes: sourceStat?.size || estimatedSize,
|
||||
});
|
||||
return "pending";
|
||||
}
|
||||
}
|
||||
|
||||
if (task.isDirectory) {
|
||||
await transferDirectory(
|
||||
task,
|
||||
sourceSftpId,
|
||||
targetSftpId,
|
||||
sourcePane.connection!.isLocal,
|
||||
targetPane.connection!.isLocal,
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
task.id, // rootTaskId - this is the top-level task
|
||||
);
|
||||
} else {
|
||||
await transferFile(
|
||||
task,
|
||||
sourceSftpId,
|
||||
targetSftpId,
|
||||
sourcePane.connection!.isLocal,
|
||||
targetPane.connection!.isLocal,
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
task.id, // rootTaskId - this is the top-level task
|
||||
);
|
||||
}
|
||||
|
||||
if (useSimulatedProgress) {
|
||||
stopProgressSimulation(task.id);
|
||||
}
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== task.id) return t;
|
||||
return {
|
||||
...t,
|
||||
status: "completed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
transferredBytes: t.totalBytes,
|
||||
speed: 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
await refresh(targetSide);
|
||||
const completionHandler = completionHandlersRef.current.get(task.id);
|
||||
if (completionHandler) {
|
||||
try {
|
||||
await completionHandler({
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
originalFileName: task.originalFileName ?? task.fileName,
|
||||
status: "completed",
|
||||
});
|
||||
} finally {
|
||||
completionHandlersRef.current.delete(task.id);
|
||||
}
|
||||
}
|
||||
return "completed";
|
||||
} catch (err) {
|
||||
if (useSimulatedProgress) {
|
||||
stopProgressSimulation(task.id);
|
||||
}
|
||||
|
||||
// Check if this was a cancellation
|
||||
const isCancelled = cancelledTasksRef.current.has(task.id) ||
|
||||
(err instanceof Error && err.message === "Transfer cancelled");
|
||||
|
||||
if (isCancelled) {
|
||||
// Don't update status - cancelTransfer already set it to cancelled
|
||||
const completionHandler = completionHandlersRef.current.get(task.id);
|
||||
if (completionHandler) {
|
||||
try {
|
||||
await completionHandler({
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
originalFileName: task.originalFileName ?? task.fileName,
|
||||
status: "cancelled",
|
||||
});
|
||||
} finally {
|
||||
completionHandlersRef.current.delete(task.id);
|
||||
}
|
||||
}
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
updateTask({
|
||||
status: "failed",
|
||||
error: err instanceof Error ? err.message : "Transfer failed",
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
const completionHandler = completionHandlersRef.current.get(task.id);
|
||||
if (completionHandler) {
|
||||
try {
|
||||
await completionHandler({
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
originalFileName: task.originalFileName ?? task.fileName,
|
||||
status: "failed",
|
||||
});
|
||||
} finally {
|
||||
completionHandlersRef.current.delete(task.id);
|
||||
}
|
||||
}
|
||||
return "failed";
|
||||
}
|
||||
};
|
||||
|
||||
const startTransfer = useCallback(
|
||||
async (
|
||||
sourceFiles: { name: string; isDirectory: boolean }[],
|
||||
sourceSide: "left" | "right",
|
||||
targetSide: "left" | "right",
|
||||
options?: {
|
||||
sourcePane?: SftpPane;
|
||||
sourcePath?: string;
|
||||
sourceConnectionId?: string;
|
||||
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
|
||||
},
|
||||
) => {
|
||||
const sourcePane = options?.sourcePane ?? getActivePane(sourceSide);
|
||||
const targetPane = getActivePane(targetSide);
|
||||
|
||||
if (!sourcePane?.connection || !targetPane?.connection) return [];
|
||||
|
||||
const sourceEncoding: SftpFilenameEncoding = sourcePane.connection.isLocal
|
||||
? "auto"
|
||||
: sourcePane.filenameEncoding || "auto";
|
||||
|
||||
const sourcePath = options?.sourcePath ?? sourcePane.connection.currentPath;
|
||||
const targetPath = targetPane.connection.currentPath;
|
||||
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
|
||||
|
||||
const sourceSftpId = sourcePane.connection.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(sourceConnectionId);
|
||||
|
||||
const newTasks: TransferTask[] = [];
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
const direction: TransferDirection =
|
||||
sourcePane.connection!.isLocal && !targetPane.connection!.isLocal
|
||||
? "upload"
|
||||
: !sourcePane.connection!.isLocal && targetPane.connection!.isLocal
|
||||
? "download"
|
||||
: "remote-to-remote";
|
||||
|
||||
let fileSize = 0;
|
||||
if (!file.isDirectory) {
|
||||
try {
|
||||
const fullPath = joinPath(sourcePath, file.name);
|
||||
if (sourcePane.connection!.isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(fullPath);
|
||||
if (stat) fileSize = stat.size;
|
||||
} else if (sourceSftpId) {
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
fullPath,
|
||||
sourceEncoding,
|
||||
);
|
||||
if (stat) fileSize = stat.size;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
newTasks.push({
|
||||
id: crypto.randomUUID(),
|
||||
fileName: file.name,
|
||||
originalFileName: file.name,
|
||||
sourcePath: joinPath(sourcePath, file.name),
|
||||
targetPath: joinPath(targetPath, file.name),
|
||||
sourceConnectionId,
|
||||
targetConnectionId: targetPane.connection!.id,
|
||||
direction,
|
||||
status: "pending" as TransferStatus,
|
||||
totalBytes: fileSize,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: file.isDirectory,
|
||||
});
|
||||
}
|
||||
|
||||
setTransfers((prev) => [...prev, ...newTasks]);
|
||||
|
||||
if (options?.onTransferComplete) {
|
||||
for (const task of newTasks) {
|
||||
completionHandlersRef.current.set(task.id, options.onTransferComplete);
|
||||
}
|
||||
}
|
||||
|
||||
const results: TransferResult[] = [];
|
||||
|
||||
for (const task of newTasks) {
|
||||
const status = await processTransfer(task, sourcePane, targetPane, targetSide);
|
||||
results.push({
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
originalFileName: task.originalFileName ?? task.fileName,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[getActivePane, sftpSessionsRef],
|
||||
);
|
||||
|
||||
const cancelTransfer = useCallback(
|
||||
async (transferId: string) => {
|
||||
// Add to cancelled set so async operations can check
|
||||
cancelledTasksRef.current.add(transferId);
|
||||
|
||||
stopProgressSimulation(transferId);
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === transferId
|
||||
? {
|
||||
...t,
|
||||
status: "cancelled" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
}
|
||||
: t,
|
||||
),
|
||||
);
|
||||
|
||||
setConflicts((prev) => prev.filter((c) => c.transferId !== transferId));
|
||||
|
||||
if (netcattyBridge.get()?.cancelTransfer) {
|
||||
try {
|
||||
await netcattyBridge.get()!.cancelTransfer!(transferId);
|
||||
} catch (err) {
|
||||
logger.warn("Failed to cancel transfer at backend:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up cancelled task ID after a delay to ensure all async ops see it
|
||||
setTimeout(() => {
|
||||
cancelledTasksRef.current.delete(transferId);
|
||||
}, 5000);
|
||||
},
|
||||
[stopProgressSimulation],
|
||||
);
|
||||
|
||||
const retryTransfer = useCallback(
|
||||
async (transferId: string) => {
|
||||
const task = transfers.find((t) => t.id === transferId);
|
||||
if (!task) return;
|
||||
|
||||
const sourceSide = task.sourceConnectionId.startsWith("left") ? "left" : "right";
|
||||
const targetSide = task.targetConnectionId.startsWith("left") ? "left" : "right";
|
||||
const sourcePane = getActivePane(sourceSide as "left" | "right");
|
||||
const targetPane = getActivePane(targetSide as "left" | "right");
|
||||
|
||||
if (sourcePane?.connection && targetPane?.connection) {
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === transferId
|
||||
? { ...t, status: "pending" as TransferStatus, error: undefined }
|
||||
: t,
|
||||
),
|
||||
);
|
||||
await processTransfer(task, sourcePane, targetPane, targetSide);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline
|
||||
[transfers, getActivePane],
|
||||
);
|
||||
|
||||
const clearCompletedTransfers = useCallback(() => {
|
||||
setTransfers((prev) =>
|
||||
prev.filter((t) => t.status !== "completed" && t.status !== "cancelled"),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const dismissTransfer = useCallback((transferId: string) => {
|
||||
setTransfers((prev) => prev.filter((t) => t.id !== transferId));
|
||||
}, []);
|
||||
|
||||
const addExternalUpload = useCallback((task: TransferTask) => {
|
||||
// Filter out any pending scanning tasks before adding the new task.
|
||||
// This ensures that even if dismissExternalUpload's state update hasn't been applied yet
|
||||
// (due to React state batching), the scanning placeholder will still be removed.
|
||||
setTransfers((prev) => [
|
||||
...prev.filter(t => !(t.status === "pending" && t.fileName === "Scanning files...")),
|
||||
task
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const updateExternalUpload = useCallback((taskId: string, updates: Partial<TransferTask>) => {
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => (t.id === taskId ? { ...t, ...updates } : t)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const resolveConflict = useCallback(
|
||||
async (conflictId: string, action: "replace" | "skip" | "duplicate") => {
|
||||
const conflict = conflicts.find((c) => c.transferId === conflictId);
|
||||
if (!conflict) return;
|
||||
|
||||
setConflicts((prev) => prev.filter((c) => c.transferId !== conflictId));
|
||||
|
||||
const task = transfers.find((t) => t.id === conflictId);
|
||||
if (!task) return;
|
||||
|
||||
if (action === "skip") {
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === conflictId
|
||||
? { ...t, status: "cancelled" as TransferStatus }
|
||||
: t,
|
||||
),
|
||||
);
|
||||
const completionHandler = completionHandlersRef.current.get(conflictId);
|
||||
if (completionHandler) {
|
||||
try {
|
||||
await completionHandler({
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
originalFileName: task.originalFileName ?? task.fileName,
|
||||
status: "cancelled",
|
||||
});
|
||||
} finally {
|
||||
completionHandlersRef.current.delete(conflictId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let updatedTask = { ...task };
|
||||
|
||||
if (action === "duplicate") {
|
||||
const ext = task.fileName.includes(".")
|
||||
? "." + task.fileName.split(".").pop()
|
||||
: "";
|
||||
const baseName = task.fileName.includes(".")
|
||||
? task.fileName.slice(0, task.fileName.lastIndexOf("."))
|
||||
: task.fileName;
|
||||
const newName = `${baseName} (copy)${ext}`;
|
||||
const newTargetPath = task.targetPath.replace(task.fileName, newName);
|
||||
updatedTask = {
|
||||
...task,
|
||||
fileName: newName,
|
||||
targetPath: newTargetPath,
|
||||
skipConflictCheck: true,
|
||||
};
|
||||
} else if (action === "replace") {
|
||||
updatedTask = {
|
||||
...task,
|
||||
skipConflictCheck: true,
|
||||
};
|
||||
}
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === conflictId
|
||||
? { ...updatedTask, status: "pending" as TransferStatus }
|
||||
: t,
|
||||
),
|
||||
);
|
||||
|
||||
const sourceSide = updatedTask.sourceConnectionId.startsWith("left") ? "left" : "right";
|
||||
const targetSide = updatedTask.targetConnectionId.startsWith("left") ? "left" : "right";
|
||||
const sourcePane = getActivePane(sourceSide as "left" | "right");
|
||||
const targetPane = getActivePane(targetSide as "left" | "right");
|
||||
|
||||
if (sourcePane?.connection && targetPane?.connection) {
|
||||
setTimeout(async () => {
|
||||
await processTransfer(updatedTask, sourcePane, targetPane, targetSide);
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline
|
||||
[conflicts, transfers, getActivePane],
|
||||
);
|
||||
|
||||
const activeTransfersCount = useMemo(() => transfers.filter(
|
||||
(t) => t.status === "pending" || t.status === "transferring",
|
||||
).length, [transfers]);
|
||||
|
||||
return {
|
||||
transfers,
|
||||
conflicts,
|
||||
activeTransfersCount,
|
||||
startTransfer,
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
cancelTransfer,
|
||||
retryTransfer,
|
||||
clearCompletedTransfers,
|
||||
dismissTransfer,
|
||||
resolveConflict,
|
||||
};
|
||||
};
|
||||
90
application/state/sftp/utils.ts
Normal file
90
application/state/sftp/utils.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { SftpFileEntry } from "../../../domain/models";
|
||||
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return "--";
|
||||
const units = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
const size = bytes / Math.pow(1024, i);
|
||||
return `${size.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
|
||||
};
|
||||
|
||||
export const formatDate = (timestamp: number): string => {
|
||||
if (!timestamp) return "--";
|
||||
const date = new Date(timestamp);
|
||||
if (isNaN(date.getTime())) return "--";
|
||||
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
};
|
||||
|
||||
export const getFileExtension = (name: string): string => {
|
||||
if (name === "..") return "folder";
|
||||
const ext = name.split(".").pop()?.toLowerCase();
|
||||
return ext || "file";
|
||||
};
|
||||
|
||||
// Check if an entry is navigable like a directory (directories or symlinks pointing to directories)
|
||||
export const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
|
||||
return entry.type === "directory" || (entry.type === "symlink" && entry.linkTarget === "directory");
|
||||
};
|
||||
|
||||
// Check if path is Windows-style
|
||||
export const isWindowsPath = (path: string): boolean => /^[A-Za-z]:/.test(path);
|
||||
|
||||
const normalizeWindowsRoot = (path: string): string => {
|
||||
const normalized = path.replace(/\//g, "\\");
|
||||
if (/^[A-Za-z]:\\$/.test(normalized)) return normalized;
|
||||
if (/^[A-Za-z]:$/.test(normalized)) return `${normalized}\\`;
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const isWindowsRoot = (path: string): boolean => {
|
||||
if (!isWindowsPath(path)) return false;
|
||||
return /^[A-Za-z]:\\?$/.test(path.replace(/\//g, "\\"));
|
||||
};
|
||||
|
||||
export const joinPath = (base: string, name: string): string => {
|
||||
if (isWindowsPath(base)) {
|
||||
const normalizedBase = normalizeWindowsRoot(base).replace(/[\\/]+$/, "");
|
||||
return `${normalizedBase}\\${name}`;
|
||||
}
|
||||
if (base === "/") return `/${name}`;
|
||||
return `${base}/${name}`;
|
||||
};
|
||||
|
||||
export const getParentPath = (path: string): string => {
|
||||
console.log("[SFTP getParentPath] input", { path, isWindows: isWindowsPath(path) });
|
||||
|
||||
if (isWindowsPath(path)) {
|
||||
const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, "");
|
||||
const drive = normalized.slice(0, 2);
|
||||
if (/^[A-Za-z]:$/.test(normalized) || /^[A-Za-z]:\\$/.test(normalized)) {
|
||||
console.log("[SFTP getParentPath] Windows root, returning", { result: `${drive}\\` });
|
||||
return `${drive}\\`;
|
||||
}
|
||||
const rest = normalized.slice(2).replace(/^[\\]+/, "");
|
||||
const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
|
||||
if (parts.length <= 1) {
|
||||
console.log("[SFTP getParentPath] Windows near root, returning", { result: `${drive}\\` });
|
||||
return `${drive}\\`;
|
||||
}
|
||||
parts.pop();
|
||||
const result = `${drive}\\${parts.join("\\")}`;
|
||||
console.log("[SFTP getParentPath] Windows result", { result });
|
||||
return result;
|
||||
}
|
||||
if (path === "/") {
|
||||
console.log("[SFTP getParentPath] Unix root, returning /");
|
||||
return "/";
|
||||
}
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
console.log("[SFTP getParentPath] Unix parts before pop", { parts: [...parts] });
|
||||
parts.pop();
|
||||
const result = parts.length ? `/${parts.join("/")}` : "/";
|
||||
console.log("[SFTP getParentPath] Unix result", { result, partsAfterPop: parts });
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getFileName = (path: string): string => {
|
||||
const parts = path.split(/[\\/]/).filter(Boolean);
|
||||
return parts[parts.length - 1] || "";
|
||||
};
|
||||
207
application/state/uiFontStore.ts
Normal file
207
application/state/uiFontStore.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { UI_FONTS, withUiCjkFallback, type UIFont } from '../../infrastructure/config/uiFonts';
|
||||
|
||||
/**
|
||||
* UI Font Store - singleton pattern using useSyncExternalStore
|
||||
* Fetches system fonts and combines with bundled fonts
|
||||
*/
|
||||
type Listener = () => void;
|
||||
|
||||
interface UIFontStoreState {
|
||||
availableFonts: UIFont[];
|
||||
isLoading: boolean;
|
||||
isLoaded: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type definition for Local Font Access API
|
||||
*/
|
||||
interface LocalFontData {
|
||||
family: string;
|
||||
}
|
||||
|
||||
class UIFontStore {
|
||||
private state: UIFontStoreState = {
|
||||
availableFonts: UI_FONTS,
|
||||
isLoading: false,
|
||||
isLoaded: false,
|
||||
error: null,
|
||||
};
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getAvailableFonts = (): UIFont[] => this.state.availableFonts;
|
||||
getIsLoading = (): boolean => this.state.isLoading;
|
||||
getIsLoaded = (): boolean => this.state.isLoaded;
|
||||
|
||||
private notify = () => {
|
||||
Promise.resolve().then(() => {
|
||||
this.listeners.forEach(listener => listener());
|
||||
});
|
||||
};
|
||||
|
||||
private setState = (partial: Partial<UIFontStoreState>) => {
|
||||
this.state = { ...this.state, ...partial };
|
||||
this.notify();
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
initialize = async (): Promise<void> => {
|
||||
if (this.state.isLoaded || this.state.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const localFonts = await this.getLocalFonts();
|
||||
|
||||
// Use a Map to deduplicate by normalized font name
|
||||
const fontMap = new Map<string, UIFont>();
|
||||
|
||||
// Add bundled fonts first (they have priority)
|
||||
UI_FONTS.forEach(font => fontMap.set(font.id, font));
|
||||
|
||||
// Add local fonts with a distinct ID namespace
|
||||
localFonts.forEach(font => {
|
||||
const localId = `local-${font.id}`;
|
||||
// Skip if a bundled font with similar name exists
|
||||
if (!fontMap.has(font.id)) {
|
||||
fontMap.set(localId, { ...font, id: localId });
|
||||
}
|
||||
});
|
||||
|
||||
this.setState({
|
||||
availableFonts: Array.from(fontMap.values()),
|
||||
isLoading: false,
|
||||
isLoaded: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to load local fonts';
|
||||
console.warn('Failed to fetch local UI fonts, using defaults:', error);
|
||||
this.setState({
|
||||
availableFonts: UI_FONTS,
|
||||
isLoading: false,
|
||||
isLoaded: true,
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private async getLocalFonts(): Promise<UIFont[]> {
|
||||
if (typeof window === 'undefined' || !('queryLocalFonts' in window)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const queryLocalFonts = (window as unknown as { queryLocalFonts: () => Promise<LocalFontData[]> }).queryLocalFonts;
|
||||
const fonts = await queryLocalFonts();
|
||||
|
||||
// Deduplicate by family name
|
||||
const uniqueFamilies = new Set<string>();
|
||||
const dedupedFonts = fonts.filter(f => {
|
||||
if (uniqueFamilies.has(f.family)) return false;
|
||||
uniqueFamilies.add(f.family);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Map to UIFont structure
|
||||
return dedupedFonts.map(f => ({
|
||||
id: f.family.toLowerCase().replace(/\s+/g, '-'),
|
||||
name: f.family,
|
||||
family: withUiCjkFallback(`"${f.family}", system-ui`),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn('Failed to query local fonts:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
getFontById = (fontId: string): UIFont => {
|
||||
const fonts = this.state.availableFonts;
|
||||
const found = fonts.find(f => f.id === fontId);
|
||||
if (found) return found;
|
||||
|
||||
// For local fonts that haven't been loaded yet, construct a fallback
|
||||
// This handles the case when main window receives a local font ID before fonts are loaded
|
||||
if (fontId.startsWith('local-')) {
|
||||
const fontName = fontId
|
||||
.replace(/^local-/, '')
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
return {
|
||||
id: fontId,
|
||||
name: fontName,
|
||||
family: withUiCjkFallback(`"${fontName}", system-ui`),
|
||||
};
|
||||
}
|
||||
|
||||
return fonts[0] || UI_FONTS[0];
|
||||
};
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const uiFontStore = new UIFontStore();
|
||||
|
||||
/**
|
||||
* Get available UI fonts - triggers initialization on first use
|
||||
*/
|
||||
export const useAvailableUIFonts = (): UIFont[] => {
|
||||
if (!uiFontStore.getIsLoaded() && !uiFontStore.getIsLoading()) {
|
||||
uiFontStore.initialize();
|
||||
}
|
||||
|
||||
return useSyncExternalStore(
|
||||
uiFontStore.subscribe,
|
||||
uiFontStore.getAvailableFonts
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get UI font loading state
|
||||
*/
|
||||
export const useUIFontsLoading = (): boolean => {
|
||||
return useSyncExternalStore(
|
||||
uiFontStore.subscribe,
|
||||
uiFontStore.getIsLoading
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get UI font loaded state
|
||||
*/
|
||||
export const useUIFontsLoaded = (): boolean => {
|
||||
return useSyncExternalStore(
|
||||
uiFontStore.subscribe,
|
||||
uiFontStore.getIsLoaded
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get UI font by ID with fallback
|
||||
*/
|
||||
export const useUIFontById = (fontId: string): UIFont => {
|
||||
const fonts = useAvailableUIFonts();
|
||||
return fonts.find(f => f.id === fontId) || fonts[0] || UI_FONTS[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a font ID is valid
|
||||
*/
|
||||
export const isValidUiFontId = (fontId: string): boolean => {
|
||||
// Local fonts are always considered valid (they start with 'local-')
|
||||
if (fontId.startsWith('local-')) return true;
|
||||
return uiFontStore.getAvailableFonts().some(f => f.id === fontId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize UI fonts eagerly
|
||||
*/
|
||||
export const initializeUIFonts = (): void => {
|
||||
uiFontStore.initialize();
|
||||
};
|
||||
@@ -7,6 +7,12 @@ export type ApplicationInfo = {
|
||||
platform: string;
|
||||
};
|
||||
|
||||
export type SshAgentStatus = {
|
||||
running: boolean;
|
||||
startupType: string | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export const useApplicationBackend = () => {
|
||||
const openExternal = useCallback(async (url: string) => {
|
||||
try {
|
||||
@@ -27,6 +33,12 @@ export const useApplicationBackend = () => {
|
||||
return info ?? null;
|
||||
}, []);
|
||||
|
||||
return { openExternal, getApplicationInfo };
|
||||
const checkSshAgent = useCallback(async (): Promise<SshAgentStatus | null> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
const status = await bridge?.checkSshAgent?.();
|
||||
return status ?? null;
|
||||
}, []);
|
||||
|
||||
return { openExternal, getApplicationInfo, checkSshAgent };
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ import { useCloudSync } from './useCloudSync';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import { getCloudSyncManager } from '../../infrastructure/services/CloudSyncManager';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import type { SyncPayload } from '../../domain/sync';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
|
||||
@@ -109,6 +112,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
|
||||
const payload = buildPayload();
|
||||
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
|
||||
if (encryptedCredentialPaths.length > 0) {
|
||||
console.warn('[AutoSync] Blocked: encrypted credential placeholders found at:', encryptedCredentialPaths.join(', '));
|
||||
throw new Error(t('sync.credentialsUnavailable'));
|
||||
}
|
||||
|
||||
const results = await sync.syncNow(payload);
|
||||
|
||||
for (const result of results.values()) {
|
||||
|
||||
14
application/state/useClipboardBackend.ts
Normal file
14
application/state/useClipboardBackend.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useCallback } from "react";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
|
||||
export const useClipboardBackend = () => {
|
||||
const readClipboardText = useCallback(async (): Promise<string> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.readClipboardText) throw new Error("clipboard bridge unavailable");
|
||||
|
||||
const text = await bridge.readClipboardText();
|
||||
return typeof text === "string" ? text : "";
|
||||
}, []);
|
||||
|
||||
return { readClipboardText };
|
||||
};
|
||||
@@ -286,7 +286,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
|
||||
// Open browser after starting server
|
||||
setTimeout(() => {
|
||||
window.open(data.url, '_blank', 'width=600,height=700');
|
||||
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
|
||||
}, 100);
|
||||
|
||||
// Wait for callback
|
||||
@@ -319,7 +319,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
|
||||
// Open browser after starting server
|
||||
setTimeout(() => {
|
||||
window.open(data.url, '_blank', 'width=600,height=700');
|
||||
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
|
||||
}, 100);
|
||||
|
||||
// Wait for callback
|
||||
@@ -358,8 +358,8 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
manager.setAutoSync(enabled, intervalMinutes);
|
||||
}, []);
|
||||
|
||||
const setDeviceName = useCallback((_name: string) => {
|
||||
// TODO: Add setDeviceName to CloudSyncManager if needed
|
||||
const setDeviceName = useCallback((name: string) => {
|
||||
manager.setDeviceName(name);
|
||||
}, []);
|
||||
|
||||
// ========== Utilities ==========
|
||||
|
||||
@@ -15,6 +15,8 @@ export const useKeychainBackend = () => {
|
||||
privateKey?: string;
|
||||
command: string;
|
||||
timeout?: number;
|
||||
enableKeyboardInteractive?: boolean;
|
||||
sessionId?: string;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.execCommand) throw new Error("execCommand unavailable");
|
||||
|
||||
383
application/state/useManagedSourceSync.ts
Normal file
383
application/state/useManagedSourceSync.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Host, ManagedSource } from "../../domain/models";
|
||||
import {
|
||||
serializeHostsToSshConfig,
|
||||
mergeWithExistingSshConfig,
|
||||
} from "../../domain/sshConfigSerializer";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
|
||||
const MANAGED_BLOCK_BEGIN = "# BEGIN NETCATTY MANAGED - DO NOT EDIT THIS BLOCK";
|
||||
const MANAGED_BLOCK_END = "# END NETCATTY MANAGED";
|
||||
|
||||
export interface UseManagedSourceSyncOptions {
|
||||
hosts: Host[];
|
||||
managedSources: ManagedSource[];
|
||||
onUpdateManagedSources: (sources: ManagedSource[]) => void;
|
||||
}
|
||||
|
||||
export const useManagedSourceSync = ({
|
||||
hosts,
|
||||
managedSources,
|
||||
onUpdateManagedSources,
|
||||
}: UseManagedSourceSyncOptions) => {
|
||||
const previousHostsRef = useRef<Host[]>([]);
|
||||
const syncInProgressRef = useRef(false);
|
||||
// Keep a ref to the latest managedSources to avoid stale closure issues
|
||||
const managedSourcesRef = useRef(managedSources);
|
||||
managedSourcesRef.current = managedSources;
|
||||
|
||||
const getManagedHostsForSource = useCallback(
|
||||
(sourceId: string) => {
|
||||
return hosts.filter((h) => h.managedSourceId === sourceId);
|
||||
},
|
||||
[hosts],
|
||||
);
|
||||
|
||||
const readExistingFileContent = useCallback(
|
||||
async (filePath: string): Promise<string | null> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.readLocalFile) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const buffer = await bridge.readLocalFile(filePath);
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(buffer);
|
||||
} catch {
|
||||
// File might not exist yet
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const mergeWithExistingContent = useCallback(
|
||||
(
|
||||
existingContent: string | null,
|
||||
managedHosts: Host[],
|
||||
allHosts: Host[],
|
||||
): string => {
|
||||
// Serialize the managed hosts
|
||||
const managedContent = serializeHostsToSshConfig(managedHosts, allHosts);
|
||||
|
||||
if (!existingContent) {
|
||||
// No existing file, just wrap the managed content
|
||||
return `${MANAGED_BLOCK_BEGIN}\n${managedContent}${MANAGED_BLOCK_END}\n`;
|
||||
}
|
||||
|
||||
const beginIndex = existingContent.indexOf(MANAGED_BLOCK_BEGIN);
|
||||
const endIndex = existingContent.indexOf(MANAGED_BLOCK_END);
|
||||
|
||||
if (beginIndex === -1 || endIndex === -1 || endIndex < beginIndex) {
|
||||
// No existing managed block - need to remove duplicate Host entries
|
||||
// Build a set of hostnames/aliases that will be managed
|
||||
const managedHostnameSet = new Set<string>();
|
||||
for (const host of managedHosts) {
|
||||
if (!host.protocol || host.protocol === "ssh") {
|
||||
// Add both hostname and sanitized label (alias) for matching
|
||||
managedHostnameSet.add(host.hostname.toLowerCase());
|
||||
if (host.label) {
|
||||
managedHostnameSet.add(host.label.replace(/\s/g, "").toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use mergeWithExistingSshConfig to filter out existing Host blocks
|
||||
// that match our managed hosts, keeping preserved content outside markers
|
||||
const mergedContent = mergeWithExistingSshConfig(
|
||||
existingContent,
|
||||
managedHosts,
|
||||
managedHostnameSet,
|
||||
allHosts,
|
||||
);
|
||||
return mergedContent;
|
||||
}
|
||||
|
||||
// Replace the existing managed block
|
||||
const before = existingContent.substring(0, beginIndex);
|
||||
const after = existingContent.substring(endIndex + MANAGED_BLOCK_END.length);
|
||||
return `${before}${MANAGED_BLOCK_BEGIN}\n${managedContent}${MANAGED_BLOCK_END}${after}`;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const writeSshConfigToFile = useCallback(
|
||||
async (source: ManagedSource, managedHosts: Host[]) => {
|
||||
console.log(`[ManagedSourceSync] writeSshConfigToFile called for ${source.groupName}, hosts:`, managedHosts.length);
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.writeLocalFile) {
|
||||
console.warn("[ManagedSourceSync] writeLocalFile not available");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Read existing file content to preserve non-managed parts
|
||||
const existingContent = await readExistingFileContent(source.filePath);
|
||||
|
||||
// Merge with existing content, preserving non-managed parts and removing duplicates
|
||||
const finalContent = mergeWithExistingContent(
|
||||
existingContent,
|
||||
managedHosts,
|
||||
hosts,
|
||||
);
|
||||
console.log(`[ManagedSourceSync] Final content (${finalContent.length} chars)`);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const buffer = encoder.encode(finalContent);
|
||||
console.log(`[ManagedSourceSync] Writing to ${source.filePath}`);
|
||||
|
||||
await bridge.writeLocalFile(source.filePath, buffer.buffer as ArrayBuffer);
|
||||
console.log(`[ManagedSourceSync] Write successful`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("[ManagedSourceSync] Failed to write SSH config:", err);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[readExistingFileContent, mergeWithExistingContent, hosts],
|
||||
);
|
||||
|
||||
const syncManagedSource = useCallback(
|
||||
async (source: ManagedSource): Promise<{ sourceId: string; success: boolean }> => {
|
||||
const managedHosts = getManagedHostsForSource(source.id);
|
||||
const success = await writeSshConfigToFile(source, managedHosts);
|
||||
return { sourceId: source.id, success };
|
||||
},
|
||||
[getManagedHostsForSource, writeSshConfigToFile],
|
||||
);
|
||||
|
||||
const unmanageSource = useCallback(
|
||||
(sourceId: string) => {
|
||||
const updatedSources = managedSourcesRef.current.filter((s) => s.id !== sourceId);
|
||||
onUpdateManagedSources(updatedSources);
|
||||
},
|
||||
[onUpdateManagedSources],
|
||||
);
|
||||
|
||||
// Clear the managed block in the SSH config file and then remove the source
|
||||
// This should be called before deleting a managed group to avoid stale entries
|
||||
const clearAndRemoveSource = useCallback(
|
||||
async (source: ManagedSource) => {
|
||||
console.log(`[ManagedSourceSync] Clearing managed block for ${source.groupName}`);
|
||||
// Write empty hosts list to clear the managed block
|
||||
const success = await writeSshConfigToFile(source, []);
|
||||
if (success) {
|
||||
console.log(`[ManagedSourceSync] Managed block cleared, removing source`);
|
||||
}
|
||||
// Remove the source regardless of write success
|
||||
const updatedSources = managedSourcesRef.current.filter((s) => s.id !== source.id);
|
||||
onUpdateManagedSources(updatedSources);
|
||||
return success;
|
||||
},
|
||||
[onUpdateManagedSources, writeSshConfigToFile],
|
||||
);
|
||||
|
||||
// Clear and remove multiple sources atomically to avoid race conditions
|
||||
// when multiple sources are removed concurrently
|
||||
const clearAndRemoveSources = useCallback(
|
||||
async (sources: ManagedSource[]) => {
|
||||
if (sources.length === 0) return;
|
||||
|
||||
console.log(`[ManagedSourceSync] Clearing ${sources.length} managed blocks`);
|
||||
|
||||
// Clear all files in parallel
|
||||
const results = await Promise.all(
|
||||
sources.map(async (source) => {
|
||||
const success = await writeSshConfigToFile(source, []);
|
||||
return { sourceId: source.id, success };
|
||||
})
|
||||
);
|
||||
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
console.log(`[ManagedSourceSync] Cleared ${successCount}/${sources.length} managed blocks`);
|
||||
|
||||
// Remove all sources atomically in a single update
|
||||
const sourceIdsToRemove = new Set(sources.map(s => s.id));
|
||||
const updatedSources = managedSourcesRef.current.filter(
|
||||
(s) => !sourceIdsToRemove.has(s.id)
|
||||
);
|
||||
onUpdateManagedSources(updatedSources);
|
||||
},
|
||||
[onUpdateManagedSources, writeSshConfigToFile],
|
||||
);
|
||||
|
||||
const pendingSyncRef = useRef(false);
|
||||
const checkAndSyncRef = useRef<() => void>(() => {});
|
||||
|
||||
const checkAndSync = useCallback(() => {
|
||||
if (managedSources.length === 0) {
|
||||
// Still update previousHostsRef so we have a baseline when sources are added
|
||||
previousHostsRef.current = hosts;
|
||||
return;
|
||||
}
|
||||
|
||||
const prevHosts = previousHostsRef.current;
|
||||
previousHostsRef.current = hosts;
|
||||
|
||||
// On initial sync (prevHosts empty), sync all sources that have managed hosts
|
||||
const isInitialSync = prevHosts.length === 0;
|
||||
|
||||
const changedSourceIds = new Set<string>();
|
||||
|
||||
if (isInitialSync) {
|
||||
// Initial sync: sync all sources that have hosts
|
||||
for (const source of managedSources) {
|
||||
const currManaged = hosts.filter((h) => h.managedSourceId === source.id);
|
||||
if (currManaged.length > 0) {
|
||||
changedSourceIds.add(source.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Build maps for all hosts (for jump host lookup)
|
||||
const prevHostMap = new Map<string, Host>(prevHosts.map((h) => [h.id, h]));
|
||||
const currHostMap = new Map<string, Host>(hosts.map((h) => [h.id, h]));
|
||||
|
||||
// Index hosts by managedSourceId to avoid O(N*M) lookups
|
||||
const prevHostsBySource = new Map<string, Host[]>();
|
||||
for (const h of prevHosts) {
|
||||
if (h.managedSourceId) {
|
||||
let list = prevHostsBySource.get(h.managedSourceId);
|
||||
if (!list) {
|
||||
list = [];
|
||||
prevHostsBySource.set(h.managedSourceId, list);
|
||||
}
|
||||
list.push(h);
|
||||
}
|
||||
}
|
||||
|
||||
const currHostsBySource = new Map<string, Host[]>();
|
||||
for (const h of hosts) {
|
||||
if (h.managedSourceId) {
|
||||
let list = currHostsBySource.get(h.managedSourceId);
|
||||
if (!list) {
|
||||
list = [];
|
||||
currHostsBySource.set(h.managedSourceId, list);
|
||||
}
|
||||
list.push(h);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to check if a host's SSH-relevant fields changed
|
||||
const hostChanged = (prevHost: Host | undefined, currHost: Host | undefined): boolean => {
|
||||
if (!prevHost || !currHost) return prevHost !== currHost;
|
||||
return (
|
||||
prevHost.hostname !== currHost.hostname ||
|
||||
prevHost.port !== currHost.port ||
|
||||
prevHost.username !== currHost.username ||
|
||||
prevHost.label !== currHost.label
|
||||
);
|
||||
};
|
||||
|
||||
for (const source of managedSources) {
|
||||
const prevManaged = prevHostsBySource.get(source.id) || [];
|
||||
const currManaged = currHostsBySource.get(source.id) || [];
|
||||
|
||||
console.log(`[ManagedSourceSync] Source ${source.groupName}: prev=${prevManaged.length}, curr=${currManaged.length}`);
|
||||
|
||||
if (prevManaged.length !== currManaged.length) {
|
||||
changedSourceIds.add(source.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const prevManagedMap = new Map<string, Host>(prevManaged.map((h) => [h.id, h]));
|
||||
let sourceChanged = false;
|
||||
|
||||
for (const curr of currManaged) {
|
||||
const prev = prevManagedMap.get(curr.id);
|
||||
if (!prev) {
|
||||
sourceChanged = true;
|
||||
break;
|
||||
}
|
||||
// Compare hostChain arrays for ProxyJump changes
|
||||
const prevChain = prev.hostChain?.hostIds || [];
|
||||
const currChain = curr.hostChain?.hostIds || [];
|
||||
const chainChanged =
|
||||
prevChain.length !== currChain.length ||
|
||||
prevChain.some((id, i) => id !== currChain[i]);
|
||||
|
||||
const hasChanged =
|
||||
prev.hostname !== curr.hostname ||
|
||||
prev.port !== curr.port ||
|
||||
prev.username !== curr.username ||
|
||||
prev.label !== curr.label ||
|
||||
prev.group !== curr.group ||
|
||||
prev.protocol !== curr.protocol ||
|
||||
chainChanged;
|
||||
if (hasChanged) {
|
||||
sourceChanged = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if any referenced jump hosts changed (even if outside this managed source)
|
||||
for (const jumpHostId of currChain) {
|
||||
const prevJumpHost = prevHostMap.get(jumpHostId);
|
||||
const currJumpHost = currHostMap.get(jumpHostId);
|
||||
if (hostChanged(prevJumpHost, currJumpHost)) {
|
||||
sourceChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (sourceChanged) break;
|
||||
}
|
||||
|
||||
if (sourceChanged) {
|
||||
changedSourceIds.add(source.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changedSourceIds.size > 0) {
|
||||
console.log(`[ManagedSourceSync] Syncing sources:`, Array.from(changedSourceIds));
|
||||
syncInProgressRef.current = true;
|
||||
|
||||
Promise.all(
|
||||
managedSources
|
||||
.filter((s) => changedSourceIds.has(s.id))
|
||||
.map(syncManagedSource),
|
||||
).then((results) => {
|
||||
// Batch update lastSyncedAt for all successful syncs to avoid race conditions
|
||||
const successfulSourceIds = new Set(
|
||||
results.filter(r => r.success).map(r => r.sourceId)
|
||||
);
|
||||
|
||||
if (successfulSourceIds.size > 0) {
|
||||
const currentSources = managedSourcesRef.current;
|
||||
const now = Date.now();
|
||||
const updatedSources = currentSources.map((s) =>
|
||||
successfulSourceIds.has(s.id) ? { ...s, lastSyncedAt: now } : s,
|
||||
);
|
||||
onUpdateManagedSources(updatedSources);
|
||||
}
|
||||
}).finally(() => {
|
||||
syncInProgressRef.current = false;
|
||||
// Check if there were changes during sync that need to be processed
|
||||
// Use ref to get the latest checkAndSync to avoid stale closure
|
||||
if (pendingSyncRef.current) {
|
||||
pendingSyncRef.current = false;
|
||||
checkAndSyncRef.current();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [hosts, managedSources, syncManagedSource, onUpdateManagedSources]);
|
||||
|
||||
// Keep ref updated with the latest checkAndSync
|
||||
checkAndSyncRef.current = checkAndSync;
|
||||
|
||||
useEffect(() => {
|
||||
if (syncInProgressRef.current) {
|
||||
// Mark that we need to re-sync after current sync completes
|
||||
pendingSyncRef.current = true;
|
||||
return;
|
||||
}
|
||||
checkAndSync();
|
||||
}, [hosts, managedSources, checkAndSync]);
|
||||
|
||||
return {
|
||||
syncManagedSource,
|
||||
unmanageSource,
|
||||
clearAndRemoveSource,
|
||||
clearAndRemoveSources,
|
||||
getManagedHostsForSource,
|
||||
};
|
||||
};
|
||||
139
application/state/usePortForwardingAutoStart.ts
Normal file
139
application/state/usePortForwardingAutoStart.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Hook for auto-starting port forwarding rules on app launch.
|
||||
* This should be used at the App level to ensure auto-start happens
|
||||
* when the application starts, not when the user navigates to the port forwarding page.
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Host, PortForwardingRule } from "../../domain/models";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
getActiveConnection,
|
||||
setReconnectCallback,
|
||||
startPortForward,
|
||||
syncWithBackend,
|
||||
} from "../../infrastructure/services/portForwardingService";
|
||||
import { logger } from "../../lib/logger";
|
||||
|
||||
export interface UsePortForwardingAutoStartOptions {
|
||||
hosts: Host[];
|
||||
keys: { id: string; privateKey: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-starts port forwarding rules that have autoStart enabled.
|
||||
* This hook should be called at the App level to run on app launch.
|
||||
*/
|
||||
export const usePortForwardingAutoStart = ({
|
||||
hosts,
|
||||
keys,
|
||||
}: UsePortForwardingAutoStartOptions): void => {
|
||||
const autoStartExecutedRef = useRef(false);
|
||||
const hostsRef = useRef<Host[]>(hosts);
|
||||
const keysRef = useRef<{ id: string; privateKey: string }[]>(keys);
|
||||
|
||||
// Keep refs in sync
|
||||
useEffect(() => {
|
||||
hostsRef.current = hosts;
|
||||
}, [hosts]);
|
||||
|
||||
useEffect(() => {
|
||||
keysRef.current = keys;
|
||||
}, [keys]);
|
||||
|
||||
// Set up the reconnect callback
|
||||
useEffect(() => {
|
||||
const handleReconnect = async (
|
||||
ruleId: string,
|
||||
onStatusChange: (status: PortForwardingRule["status"], error?: string) => void,
|
||||
) => {
|
||||
// Load the current rules from storage
|
||||
const rules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
) ?? [];
|
||||
|
||||
const rule = rules.find((r) => r.id === ruleId);
|
||||
if (!rule || !rule.hostId) {
|
||||
return { success: false, error: "Rule or host not found" };
|
||||
}
|
||||
|
||||
const host = hostsRef.current.find((h) => h.id === rule.hostId);
|
||||
if (!host) {
|
||||
return { success: false, error: "Host not found" };
|
||||
}
|
||||
|
||||
return startPortForward(rule, host, keysRef.current, onStatusChange, true);
|
||||
};
|
||||
|
||||
setReconnectCallback(handleReconnect);
|
||||
return () => {
|
||||
setReconnectCallback(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-start rules on app launch
|
||||
useEffect(() => {
|
||||
if (autoStartExecutedRef.current) return;
|
||||
if (hosts.length === 0) return;
|
||||
|
||||
// Mark as executed immediately to prevent duplicate runs
|
||||
// (React StrictMode or dependency changes could cause re-runs)
|
||||
autoStartExecutedRef.current = true;
|
||||
|
||||
const runAutoStart = async () => {
|
||||
// First sync with backend to get any active tunnels
|
||||
await syncWithBackend();
|
||||
|
||||
// Load rules from storage
|
||||
const rules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
) ?? [];
|
||||
|
||||
// Only start rules that are not already active
|
||||
const autoStartRules = rules.filter((r) => {
|
||||
if (!r.autoStart || !r.hostId) return false;
|
||||
// Check if there's an active connection for this rule
|
||||
const conn = getActiveConnection(r.id);
|
||||
// Only start if not already connecting or active
|
||||
return !conn || conn.status === 'inactive' || conn.status === 'error';
|
||||
});
|
||||
|
||||
if (autoStartRules.length === 0) return;
|
||||
logger.info(`[PortForwardingAutoStart] Starting ${autoStartRules.length} auto-start rules`);
|
||||
|
||||
// Start each auto-start rule
|
||||
for (const rule of autoStartRules) {
|
||||
const host = hosts.find((h) => h.id === rule.hostId);
|
||||
if (host) {
|
||||
void startPortForward(
|
||||
rule,
|
||||
host,
|
||||
keys,
|
||||
(status, error) => {
|
||||
// Update the rule status in storage
|
||||
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
) ?? [];
|
||||
|
||||
const updatedRules = currentRules.map((r) =>
|
||||
r.id === rule.id
|
||||
? {
|
||||
...r,
|
||||
status,
|
||||
error,
|
||||
lastUsedAt: status === "active" ? Date.now() : r.lastUsedAt,
|
||||
}
|
||||
: r,
|
||||
);
|
||||
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
|
||||
},
|
||||
true, // Enable reconnect for auto-start rules
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void runAutoStart();
|
||||
}, [hosts, keys]);
|
||||
};
|
||||
@@ -7,10 +7,11 @@ import {
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
clearReconnectTimer,
|
||||
getActiveConnection,
|
||||
getActiveRuleIds,
|
||||
startPortForward,
|
||||
stopPortForward,
|
||||
syncWithBackend,
|
||||
} from "../../infrastructure/services/portForwardingService";
|
||||
import { useStoredViewMode, ViewMode } from "./useStoredViewMode";
|
||||
|
||||
@@ -38,6 +39,7 @@ export interface UsePortForwardingStateResult {
|
||||
updateRule: (id: string, updates: Partial<PortForwardingRule>) => void;
|
||||
deleteRule: (id: string) => void;
|
||||
duplicateRule: (id: string) => void;
|
||||
importRules: (rules: PortForwardingRule[]) => void;
|
||||
|
||||
setRuleStatus: (
|
||||
id: string,
|
||||
@@ -50,6 +52,7 @@ export interface UsePortForwardingStateResult {
|
||||
host: Host,
|
||||
keys: { id: string; privateKey: string }[],
|
||||
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
|
||||
enableReconnect?: boolean,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
stopTunnel: (
|
||||
ruleId: string,
|
||||
@@ -60,8 +63,58 @@ export interface UsePortForwardingStateResult {
|
||||
selectedRule: PortForwardingRule | undefined;
|
||||
}
|
||||
|
||||
// Global Store State
|
||||
let globalRules: PortForwardingRule[] = [];
|
||||
let isInitialized = false;
|
||||
const listeners = new Set<(rules: PortForwardingRule[]) => void>();
|
||||
|
||||
// Store Actions
|
||||
const notifyListeners = () => {
|
||||
listeners.forEach((listener) => listener(globalRules));
|
||||
};
|
||||
|
||||
const setGlobalRules = (newRules: PortForwardingRule[]) => {
|
||||
globalRules = newRules;
|
||||
notifyListeners();
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, newRules);
|
||||
};
|
||||
|
||||
const normalizeRulesWithConnections = (rules: PortForwardingRule[]): PortForwardingRule[] => {
|
||||
return rules.map((rule): PortForwardingRule => {
|
||||
const connection = getActiveConnection(rule.id);
|
||||
if (connection) {
|
||||
return {
|
||||
...rule,
|
||||
status: connection.status,
|
||||
error: connection.error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...rule,
|
||||
status: "inactive" as const,
|
||||
error: undefined,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Initialization Logic
|
||||
const initializeStore = async () => {
|
||||
if (isInitialized) return;
|
||||
isInitialized = true;
|
||||
|
||||
await syncWithBackend();
|
||||
|
||||
const saved = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (saved && Array.isArray(saved)) {
|
||||
setGlobalRules(normalizeRulesWithConnections(saved));
|
||||
}
|
||||
};
|
||||
|
||||
export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
const [rules, setRules] = useState<PortForwardingRule[]>([]);
|
||||
const [rules, setRules] = useState<PortForwardingRule[]>(globalRules);
|
||||
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useStoredViewMode(
|
||||
STORAGE_KEY_PF_VIEW_MODE,
|
||||
@@ -78,30 +131,50 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE, prefer);
|
||||
}, []);
|
||||
|
||||
// Load rules from storage on mount
|
||||
// Initialize store on mount (only once globally)
|
||||
useEffect(() => {
|
||||
const saved = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (saved && Array.isArray(saved)) {
|
||||
// Sync status with active connections in the service layer
|
||||
const _activeRuleIds = getActiveRuleIds();
|
||||
const withSyncedStatus = saved.map((r) => {
|
||||
const conn = getActiveConnection(r.id);
|
||||
if (conn) {
|
||||
// This rule has an active connection, preserve its status
|
||||
return { ...r, status: conn.status, error: conn.error };
|
||||
}
|
||||
// No active connection, reset to inactive
|
||||
return { ...r, status: "inactive" as const, error: undefined };
|
||||
});
|
||||
setRules(withSyncedStatus);
|
||||
}
|
||||
void initializeStore();
|
||||
}, []);
|
||||
|
||||
// Persist rules to storage whenever they change
|
||||
const persistRules = useCallback((updatedRules: PortForwardingRule[]) => {
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
|
||||
// Subscribe to global store
|
||||
useEffect(() => {
|
||||
// If global state was updated before we subscribed (e.g. init finished), update local state
|
||||
if (rules !== globalRules) {
|
||||
setRules(globalRules);
|
||||
}
|
||||
|
||||
const listener = (newRules: PortForwardingRule[]) => {
|
||||
setRules(newRules);
|
||||
};
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}, [rules]);
|
||||
|
||||
// Listen for storage events for cross-window sync (main window <-> tray panel)
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
// Only handle changes from our specific key
|
||||
if (e.key !== STORAGE_KEY_PORT_FORWARDING) return;
|
||||
|
||||
// Parse the new value
|
||||
if (e.newValue) {
|
||||
try {
|
||||
const newRules = JSON.parse(e.newValue) as PortForwardingRule[];
|
||||
if (Array.isArray(newRules)) {
|
||||
// Update global state without triggering another localStorage write
|
||||
globalRules = normalizeRulesWithConnections(newRules);
|
||||
notifyListeners();
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
return () => window.removeEventListener("storage", handleStorageChange);
|
||||
}, []);
|
||||
|
||||
const addRule = useCallback(
|
||||
@@ -114,47 +187,38 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
createdAt: Date.now(),
|
||||
status: "inactive",
|
||||
};
|
||||
setRules((prev) => {
|
||||
const updated = [...prev, newRule];
|
||||
persistRules(updated);
|
||||
return updated;
|
||||
});
|
||||
const updated = [...globalRules, newRule];
|
||||
setGlobalRules(updated);
|
||||
setSelectedRuleId(newRule.id);
|
||||
return newRule;
|
||||
},
|
||||
[persistRules],
|
||||
[],
|
||||
);
|
||||
|
||||
const updateRule = useCallback(
|
||||
(id: string, updates: Partial<PortForwardingRule>) => {
|
||||
setRules((prev) => {
|
||||
const updated = prev.map((r) =>
|
||||
r.id === id ? { ...r, ...updates } : r,
|
||||
);
|
||||
persistRules(updated);
|
||||
return updated;
|
||||
});
|
||||
const updated = globalRules.map((r) =>
|
||||
r.id === id ? { ...r, ...updates } : r,
|
||||
);
|
||||
setGlobalRules(updated);
|
||||
},
|
||||
[persistRules],
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteRule = useCallback(
|
||||
(id: string) => {
|
||||
setRules((prev) => {
|
||||
const updated = prev.filter((r) => r.id !== id);
|
||||
persistRules(updated);
|
||||
return updated;
|
||||
});
|
||||
const updated = globalRules.filter((r) => r.id !== id);
|
||||
setGlobalRules(updated);
|
||||
if (selectedRuleId === id) {
|
||||
setSelectedRuleId(null);
|
||||
}
|
||||
},
|
||||
[selectedRuleId, persistRules],
|
||||
[selectedRuleId],
|
||||
);
|
||||
|
||||
const duplicateRule = useCallback(
|
||||
(id: string) => {
|
||||
const original = rules.find((r) => r.id === id);
|
||||
const original = globalRules.find((r) => r.id === id);
|
||||
if (!original) return;
|
||||
|
||||
const copy: PortForwardingRule = {
|
||||
@@ -166,33 +230,31 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
};
|
||||
setRules((prev) => {
|
||||
const updated = [...prev, copy];
|
||||
persistRules(updated);
|
||||
return updated;
|
||||
});
|
||||
const updated = [...globalRules, copy];
|
||||
setGlobalRules(updated);
|
||||
setSelectedRuleId(copy.id);
|
||||
},
|
||||
[rules, persistRules],
|
||||
[],
|
||||
);
|
||||
|
||||
const importRules = useCallback((newRules: PortForwardingRule[]) => {
|
||||
setGlobalRules(normalizeRulesWithConnections(newRules));
|
||||
}, []);
|
||||
|
||||
const setRuleStatus = useCallback(
|
||||
(id: string, status: PortForwardingRule["status"], error?: string) => {
|
||||
setRules((prev) => {
|
||||
const updated = prev.map((r) => {
|
||||
if (r.id !== id) return r;
|
||||
return {
|
||||
...r,
|
||||
status,
|
||||
error,
|
||||
lastUsedAt: status === "active" ? Date.now() : r.lastUsedAt,
|
||||
};
|
||||
});
|
||||
persistRules(updated);
|
||||
return updated;
|
||||
const updated = globalRules.map((r) => {
|
||||
if (r.id !== id) return r;
|
||||
return {
|
||||
...r,
|
||||
status,
|
||||
error,
|
||||
lastUsedAt: status === "active" ? Date.now() : r.lastUsedAt,
|
||||
};
|
||||
});
|
||||
setGlobalRules(updated);
|
||||
},
|
||||
[persistRules],
|
||||
[],
|
||||
);
|
||||
|
||||
const startTunnel = useCallback(
|
||||
@@ -204,11 +266,12 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
status: PortForwardingRule["status"],
|
||||
error?: string,
|
||||
) => void,
|
||||
enableReconnect = false,
|
||||
) => {
|
||||
return startPortForward(rule, host, keys, (status, error) => {
|
||||
setRuleStatus(rule.id, status, error);
|
||||
onStatusChange?.(status, error ?? undefined);
|
||||
});
|
||||
}, enableReconnect);
|
||||
},
|
||||
[setRuleStatus],
|
||||
);
|
||||
@@ -218,6 +281,8 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
ruleId: string,
|
||||
onStatusChange?: (status: PortForwardingRule["status"]) => void,
|
||||
) => {
|
||||
// Clear any pending reconnect timer when manually stopping
|
||||
clearReconnectTimer(ruleId);
|
||||
return stopPortForward(ruleId, (status) => {
|
||||
setRuleStatus(ruleId, status);
|
||||
onStatusChange?.(status);
|
||||
@@ -282,6 +347,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
updateRule,
|
||||
deleteRule,
|
||||
duplicateRule,
|
||||
importRules,
|
||||
|
||||
setRuleStatus,
|
||||
startTunnel,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MouseEvent,useCallback,useMemo,useState } from 'react';
|
||||
import { ConnectionLog,Host,Snippet,TerminalSession,Workspace,WorkspaceViewMode } from '../../domain/models';
|
||||
import { ConnectionLog,Host,SerialConfig,Snippet,TerminalSession,Workspace,WorkspaceViewMode } from '../../domain/models';
|
||||
import {
|
||||
collectSessionIds,
|
||||
createWorkspaceFromSessions as createWorkspaceEntity,
|
||||
@@ -53,7 +53,56 @@ export const useSessionState = () => {
|
||||
setActiveTabId(sessionId);
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const createSerialSession = useCallback((config: SerialConfig) => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
const serialHostId = `serial-${sessionId}`;
|
||||
const portName = config.path.split('/').pop() || config.path;
|
||||
const newSession: TerminalSession = {
|
||||
id: sessionId,
|
||||
hostId: serialHostId,
|
||||
hostLabel: `Serial: ${portName}`,
|
||||
hostname: config.path,
|
||||
username: '',
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig: config,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const connectToHost = useCallback((host: Host) => {
|
||||
// Handle serial hosts specially - use createSerialSession for them
|
||||
if (host.protocol === 'serial') {
|
||||
// Use stored serialConfig or construct from host data
|
||||
const serialConfig: SerialConfig = host.serialConfig || {
|
||||
path: host.hostname,
|
||||
baudRate: host.port || 115200,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: 'none',
|
||||
flowControl: 'none',
|
||||
localEcho: false,
|
||||
lineMode: false,
|
||||
};
|
||||
|
||||
const sessionId = crypto.randomUUID();
|
||||
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
|
||||
const newSession: TerminalSession = {
|
||||
id: sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: serialConfig.path,
|
||||
username: '',
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig: serialConfig,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const newSession: TerminalSession = {
|
||||
id: crypto.randomUUID(),
|
||||
hostId: host.id,
|
||||
@@ -237,6 +286,69 @@ export const useSessionState = () => {
|
||||
setWorkspaceRenameValue('');
|
||||
}, []);
|
||||
|
||||
const createWorkspaceWithHosts = useCallback((name: string, hosts: Host[]) => {
|
||||
if (hosts.length === 0) return;
|
||||
|
||||
// Create sessions for each host
|
||||
const newSessions: TerminalSession[] = hosts.map(host => {
|
||||
// Handle serial hosts specially
|
||||
if (host.protocol === 'serial') {
|
||||
const serialConfig: SerialConfig = host.serialConfig || {
|
||||
path: host.hostname,
|
||||
baudRate: host.port || 115200,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: 'none',
|
||||
flowControl: 'none',
|
||||
localEcho: false,
|
||||
lineMode: false,
|
||||
};
|
||||
|
||||
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: serialConfig.path,
|
||||
username: '',
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig: serialConfig,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: host.username,
|
||||
status: 'connecting',
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
};
|
||||
});
|
||||
|
||||
const sessionIds = newSessions.map(s => s.id);
|
||||
|
||||
// Create workspace
|
||||
const workspace = createWorkspaceFromSessionIds(sessionIds, {
|
||||
title: name,
|
||||
viewMode: 'split',
|
||||
});
|
||||
|
||||
// Assign workspaceId to sessions
|
||||
const sessionsWithWorkspace = newSessions.map(s => ({
|
||||
...s,
|
||||
workspaceId: workspace.id
|
||||
}));
|
||||
|
||||
setSessions(prev => [...prev, ...sessionsWithWorkspace]);
|
||||
setWorkspaces(prev => [...prev, workspace]);
|
||||
setActiveTabId(workspace.id);
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const createWorkspaceFromSessions = useCallback((
|
||||
baseSessionId: string,
|
||||
joiningSessionId: string,
|
||||
@@ -498,6 +610,31 @@ export const useSessionState = () => {
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Copy a session - creates a new session with the same host connection
|
||||
const copySession = useCallback((sessionId: string) => {
|
||||
setSessions(prevSessions => {
|
||||
const session = prevSessions.find(s => s.id === sessionId);
|
||||
if (!session) return prevSessions;
|
||||
|
||||
// Create a new session with the same connection info
|
||||
const newSession: TerminalSession = {
|
||||
id: crypto.randomUUID(),
|
||||
hostId: session.hostId,
|
||||
hostLabel: session.hostLabel,
|
||||
hostname: session.hostname,
|
||||
username: session.username,
|
||||
status: 'connecting',
|
||||
protocol: session.protocol,
|
||||
port: session.port,
|
||||
moshEnabled: session.moshEnabled,
|
||||
serialConfig: session.serialConfig,
|
||||
};
|
||||
|
||||
setActiveTabId(newSession.id);
|
||||
return [...prevSessions, newSession];
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Toggle broadcast mode for a workspace
|
||||
const toggleBroadcast = useCallback((workspaceId: string) => {
|
||||
setBroadcastWorkspaceIds(prev => {
|
||||
@@ -590,10 +727,12 @@ export const useSessionState = () => {
|
||||
submitWorkspaceRename,
|
||||
resetWorkspaceRename,
|
||||
createLocalTerminal,
|
||||
createSerialSession,
|
||||
connectToHost,
|
||||
closeSession,
|
||||
closeWorkspace,
|
||||
updateSessionStatus,
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
updateSplitSizes,
|
||||
@@ -612,5 +751,7 @@ export const useSessionState = () => {
|
||||
logViews,
|
||||
openLogView,
|
||||
closeLogView,
|
||||
// Copy session
|
||||
copySession,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,26 +1,40 @@
|
||||
import { useCallback,useEffect,useLayoutEffect,useMemo,useState } from 'react';
|
||||
import { SyncConfig, TerminalSettings, DEFAULT_TERMINAL_SETTINGS, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage } from '../../domain/models';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
||||
import { SyncConfig, TerminalSettings, DEFAULT_TERMINAL_SETTINGS, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat } from '../../domain/models';
|
||||
import {
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_SYNC,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
STORAGE_KEY_HOTKEY_SCHEME,
|
||||
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
STORAGE_KEY_HOTKEY_RECORDING,
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_UI_LANGUAGE,
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_SYNC,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
STORAGE_KEY_HOTKEY_SCHEME,
|
||||
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
STORAGE_KEY_HOTKEY_RECORDING,
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_UI_LANGUAGE,
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_UI_FONT_FAMILY,
|
||||
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_EDITOR_WORD_WRAP,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
import { TERMINAL_FONTS, DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
|
||||
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { UI_FONTS, DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
|
||||
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
|
||||
import { useAvailableFonts } from './fontStore';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
@@ -32,10 +46,21 @@ const DEFAULT_CUSTOM_ACCENT = '221.2 83.2% 53.3%';
|
||||
const DEFAULT_TERMINAL_THEME = 'netcatty-dark';
|
||||
const DEFAULT_FONT_FAMILY = 'menlo';
|
||||
// Auto-detect default hotkey scheme based on platform
|
||||
const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
|
||||
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
|
||||
? 'mac'
|
||||
const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
|
||||
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
|
||||
? 'mac'
|
||||
: 'pc';
|
||||
const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
|
||||
const DEFAULT_SFTP_AUTO_SYNC = false;
|
||||
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
|
||||
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
|
||||
|
||||
// Editor defaults
|
||||
const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
|
||||
// Session Logs defaults
|
||||
const DEFAULT_SESSION_LOGS_ENABLED = false;
|
||||
const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
|
||||
|
||||
const readStoredString = (key: string): string | null => {
|
||||
const raw = localStorageAdapter.readString(key);
|
||||
@@ -63,6 +88,20 @@ const isValidUiThemeId = (theme: 'light' | 'dark', value: string): boolean => {
|
||||
return list.some((preset) => preset.id === value);
|
||||
};
|
||||
|
||||
const isValidUiFontId = (value: string): boolean => {
|
||||
// Local fonts are always considered valid
|
||||
if (value.startsWith('local-')) return true;
|
||||
// Check bundled fonts first, then check dynamically loaded fonts
|
||||
return UI_FONTS.some((font) => font.id === value) ||
|
||||
uiFontStore.getAvailableFonts().some((font) => font.id === value);
|
||||
};
|
||||
|
||||
const serializeTerminalSettings = (settings: TerminalSettings): string =>
|
||||
JSON.stringify(settings);
|
||||
|
||||
const areTerminalSettingsEqual = (a: TerminalSettings, b: TerminalSettings): boolean =>
|
||||
serializeTerminalSettings(a) === serializeTerminalSettings(b);
|
||||
|
||||
const applyThemeTokens = (
|
||||
theme: 'light' | 'dark',
|
||||
tokens: UiThemeTokens,
|
||||
@@ -97,13 +136,15 @@ const applyThemeTokens = (
|
||||
root.style.setProperty('--border', tokens.border);
|
||||
root.style.setProperty('--input', tokens.input);
|
||||
root.style.setProperty('--ring', accentToken);
|
||||
|
||||
|
||||
// Sync with native window title bar (Electron)
|
||||
netcattyBridge.get()?.setTheme?.(theme);
|
||||
netcattyBridge.get()?.setBackgroundColor?.(tokens.background);
|
||||
};
|
||||
|
||||
export const useSettingsState = () => {
|
||||
const availableFonts = useAvailableFonts();
|
||||
const uiFontsLoaded = useUIFontsLoaded();
|
||||
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_THEME);
|
||||
return stored && isValidTheme(stored) ? stored : DEFAULT_THEME;
|
||||
@@ -126,6 +167,10 @@ export const useSettingsState = () => {
|
||||
const legacyColor = readStoredString(STORAGE_KEY_COLOR);
|
||||
return legacyColor && isValidHslToken(legacyColor) ? 'custom' : DEFAULT_ACCENT_MODE;
|
||||
});
|
||||
const [uiFontFamilyId, setUiFontFamilyId] = useState<string>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_UI_FONT_FAMILY);
|
||||
return stored && isValidUiFontId(stored) ? stored : DEFAULT_UI_FONT_ID;
|
||||
});
|
||||
const [syncConfig, setSyncConfig] = useState<SyncConfig | null>(() => localStorageAdapter.read<SyncConfig>(STORAGE_KEY_SYNC));
|
||||
const [terminalThemeId, setTerminalThemeId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME) || DEFAULT_TERMINAL_THEME);
|
||||
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY) || DEFAULT_FONT_FAMILY);
|
||||
@@ -134,7 +179,7 @@ export const useSettingsState = () => {
|
||||
const stored = readStoredString(STORAGE_KEY_UI_LANGUAGE);
|
||||
return resolveSupportedLocale(stored || DEFAULT_UI_LOCALE);
|
||||
});
|
||||
const [terminalSettings, setTerminalSettings] = useState<TerminalSettings>(() => {
|
||||
const [terminalSettings, setTerminalSettingsState] = useState<TerminalSettings>(() => {
|
||||
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
|
||||
return stored ? { ...DEFAULT_TERMINAL_SETTINGS, ...stored } : DEFAULT_TERMINAL_SETTINGS;
|
||||
});
|
||||
@@ -146,13 +191,96 @@ export const useSettingsState = () => {
|
||||
}
|
||||
return DEFAULT_HOTKEY_SCHEME;
|
||||
});
|
||||
const [customKeyBindings, setCustomKeyBindings] = useState<CustomKeyBindings>(() =>
|
||||
const [customKeyBindings, setCustomKeyBindings] = useState<CustomKeyBindings>(() =>
|
||||
localStorageAdapter.read<CustomKeyBindings>(STORAGE_KEY_CUSTOM_KEY_BINDINGS) || {}
|
||||
);
|
||||
const [isHotkeyRecording, setIsHotkeyRecordingState] = useState(false);
|
||||
const [customCSS, setCustomCSS] = useState<string>(() =>
|
||||
const [customCSS, setCustomCSS] = useState<string>(() =>
|
||||
localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS) || ''
|
||||
);
|
||||
const [sftpDoubleClickBehavior, setSftpDoubleClickBehavior] = useState<'open' | 'transfer'>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
|
||||
return (stored === 'open' || stored === 'transfer') ? stored : DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR;
|
||||
});
|
||||
const [sftpAutoSync, setSftpAutoSync] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_SYNC);
|
||||
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_SYNC;
|
||||
});
|
||||
const [sftpShowHiddenFiles, setSftpShowHiddenFiles] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
|
||||
return stored === 'true' ? true : DEFAULT_SFTP_SHOW_HIDDEN_FILES;
|
||||
});
|
||||
const [sftpUseCompressedUpload, setSftpUseCompressedUpload] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
|
||||
// 兼容旧的设置值
|
||||
if (stored === 'true' || stored === 'enabled' || stored === 'ask') return true;
|
||||
if (stored === 'false' || stored === 'disabled') return false;
|
||||
return DEFAULT_SFTP_USE_COMPRESSED_UPLOAD;
|
||||
});
|
||||
|
||||
// Editor Settings
|
||||
const [editorWordWrap, setEditorWordWrapState] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_EDITOR_WORD_WRAP);
|
||||
return stored === 'true' ? true : DEFAULT_EDITOR_WORD_WRAP;
|
||||
});
|
||||
|
||||
// Session Logs Settings
|
||||
const [sessionLogsEnabled, setSessionLogsEnabled] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SESSION_LOGS_ENABLED);
|
||||
return stored === 'true' ? true : DEFAULT_SESSION_LOGS_ENABLED;
|
||||
});
|
||||
const [sessionLogsDir, setSessionLogsDir] = useState<string>(() => {
|
||||
return readStoredString(STORAGE_KEY_SESSION_LOGS_DIR) || '';
|
||||
});
|
||||
const [sessionLogsFormat, setSessionLogsFormat] = useState<SessionLogFormat>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SESSION_LOGS_FORMAT);
|
||||
if (stored === 'txt' || stored === 'raw' || stored === 'html') return stored;
|
||||
return DEFAULT_SESSION_LOGS_FORMAT;
|
||||
});
|
||||
|
||||
// Global Toggle Window Settings (Quake Mode)
|
||||
const [toggleWindowHotkey, setToggleWindowHotkey] = useState<string>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY);
|
||||
if (stored !== null) return stored;
|
||||
// Default: Ctrl+` (Control+backtick) - similar to VS Code terminal toggle
|
||||
const isMac = typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform);
|
||||
return isMac ? '⌃ + `' : 'Ctrl + `';
|
||||
});
|
||||
const [closeToTray, setCloseToTray] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_CLOSE_TO_TRAY);
|
||||
// Default to true (enabled)
|
||||
if (stored === null) return true;
|
||||
return stored === 'true';
|
||||
});
|
||||
const [hotkeyRegistrationError, setHotkeyRegistrationError] = useState<string | null>(null);
|
||||
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
|
||||
const localTerminalSettingsVersionRef = useRef(0);
|
||||
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
|
||||
|
||||
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
|
||||
setTerminalSettingsState((prev) => {
|
||||
const next = typeof nextValue === 'function'
|
||||
? (nextValue as (prevState: TerminalSettings) => TerminalSettings)(prev)
|
||||
: nextValue;
|
||||
if (areTerminalSettingsEqual(prev, next)) {
|
||||
return prev;
|
||||
}
|
||||
localTerminalSettingsVersionRef.current += 1;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const mergeIncomingTerminalSettings = useCallback((incoming: Partial<TerminalSettings>) => {
|
||||
setTerminalSettingsState((prev) => {
|
||||
const next = { ...prev, ...incoming };
|
||||
if (areTerminalSettingsEqual(prev, next)) {
|
||||
return prev;
|
||||
}
|
||||
// Mark the exact incoming snapshot so only this state is skipped for IPC rebroadcast.
|
||||
incomingTerminalSettingsSignatureRef.current = serializeTerminalSettings(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Helper to notify other windows about settings changes via IPC
|
||||
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
|
||||
@@ -213,12 +341,21 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
|
||||
}, [uiLanguage, notifySettingsChanged]);
|
||||
|
||||
// Apply and persist UI font family
|
||||
// Re-run when fonts finish loading to get correct family for local fonts
|
||||
useLayoutEffect(() => {
|
||||
const font = uiFontStore.getFontById(uiFontFamilyId);
|
||||
document.documentElement.style.setProperty('--font-sans', font.family);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
|
||||
notifySettingsChanged(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
|
||||
}, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]);
|
||||
|
||||
// Listen for settings changes from other windows via IPC
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onSettingsChanged) return;
|
||||
const unsubscribe = bridge.onSettingsChanged((payload) => {
|
||||
const { key, value } = payload;
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onSettingsChanged) return;
|
||||
const unsubscribe = bridge.onSettingsChanged((payload) => {
|
||||
const { key, value } = payload;
|
||||
if (
|
||||
key === STORAGE_KEY_THEME ||
|
||||
key === STORAGE_KEY_UI_THEME_LIGHT ||
|
||||
@@ -232,11 +369,16 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_UI_LANGUAGE && typeof value === 'string') {
|
||||
const next = resolveSupportedLocale(value);
|
||||
setUiLanguage((prev) => (prev === next ? prev : next));
|
||||
document.documentElement.lang = next;
|
||||
}
|
||||
document.documentElement.lang = next;
|
||||
}
|
||||
if (key === STORAGE_KEY_CUSTOM_CSS && typeof value === 'string') {
|
||||
syncCustomCssFromStorage();
|
||||
}
|
||||
if (key === STORAGE_KEY_UI_FONT_FAMILY && typeof value === 'string') {
|
||||
if (isValidUiFontId(value)) {
|
||||
setUiFontFamilyId(value);
|
||||
}
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_THEME && typeof value === 'string') {
|
||||
setTerminalThemeId(value);
|
||||
}
|
||||
@@ -246,6 +388,21 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_TERM_FONT_SIZE && typeof value === 'number') {
|
||||
setTerminalFontSize(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_SETTINGS) {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<TerminalSettings>;
|
||||
mergeIncomingTerminalSettings(parsed);
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
} else if (value && typeof value === 'object') {
|
||||
mergeIncomingTerminalSettings(value as Partial<TerminalSettings>);
|
||||
}
|
||||
}
|
||||
if (key === STORAGE_KEY_EDITOR_WORD_WRAP && typeof value === 'boolean') {
|
||||
setEditorWordWrapState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
|
||||
setHotkeyScheme(value);
|
||||
}
|
||||
@@ -271,7 +428,7 @@ export const useSettingsState = () => {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
}, [syncAppearanceFromStorage, syncCustomCssFromStorage]);
|
||||
}, [mergeIncomingTerminalSettings, syncAppearanceFromStorage, syncCustomCssFromStorage]);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -323,6 +480,11 @@ export const useSettingsState = () => {
|
||||
setCustomCSS(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_FONT_FAMILY && e.newValue) {
|
||||
if (isValidUiFontId(e.newValue) && e.newValue !== uiFontFamilyId) {
|
||||
setUiFontFamilyId(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_HOTKEY_SCHEME && e.newValue) {
|
||||
const newScheme = e.newValue as HotkeyScheme;
|
||||
if (newScheme !== hotkeyScheme) {
|
||||
@@ -344,14 +506,14 @@ export const useSettingsState = () => {
|
||||
}
|
||||
}
|
||||
// Sync terminal settings from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
|
||||
try {
|
||||
const newSettings = JSON.parse(e.newValue) as TerminalSettings;
|
||||
setTerminalSettings(_prev => ({ ...DEFAULT_TERMINAL_SETTINGS, ...newSettings }));
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
|
||||
try {
|
||||
const newSettings = JSON.parse(e.newValue) as TerminalSettings;
|
||||
mergeIncomingTerminalSettings({ ...DEFAULT_TERMINAL_SETTINGS, ...newSettings });
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
// Sync terminal theme from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_THEME && e.newValue) {
|
||||
if (e.newValue !== terminalThemeId) {
|
||||
@@ -371,11 +533,44 @@ export const useSettingsState = () => {
|
||||
setTerminalFontSize(newSize);
|
||||
}
|
||||
}
|
||||
// Sync SFTP double-click behavior from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR && e.newValue) {
|
||||
if ((e.newValue === 'open' || e.newValue === 'transfer') && e.newValue !== sftpDoubleClickBehavior) {
|
||||
setSftpDoubleClickBehavior(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP auto-sync setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_AUTO_SYNC && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sftpAutoSync) {
|
||||
setSftpAutoSync(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP show hidden files setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sftpShowHiddenFiles) {
|
||||
setSftpShowHiddenFiles(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_EDITOR_WORD_WRAP && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== editorWordWrap) {
|
||||
setEditorWordWrapState(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP compressed upload setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
|
||||
if (newValue !== sftpUseCompressedUpload) {
|
||||
setSftpUseCompressedUpload(newValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize]);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, mergeIncomingTerminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
@@ -394,7 +589,17 @@ export const useSettingsState = () => {
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
|
||||
}, [terminalSettings]);
|
||||
const currentSignature = serializeTerminalSettings(terminalSettings);
|
||||
const hasPendingUnbroadcastLocalChanges =
|
||||
localTerminalSettingsVersionRef.current !== broadcastedLocalTerminalSettingsVersionRef.current;
|
||||
if (incomingTerminalSettingsSignatureRef.current === currentSignature && !hasPendingUnbroadcastLocalChanges) {
|
||||
incomingTerminalSettingsSignatureRef.current = null;
|
||||
return;
|
||||
}
|
||||
incomingTerminalSettingsSignatureRef.current = null;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
|
||||
broadcastedLocalTerminalSettingsVersionRef.current = localTerminalSettingsVersionRef.current;
|
||||
}, [terminalSettings, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
|
||||
@@ -415,7 +620,7 @@ export const useSettingsState = () => {
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
|
||||
notifySettingsChanged(STORAGE_KEY_CUSTOM_CSS, customCSS);
|
||||
|
||||
|
||||
// Apply custom CSS to document
|
||||
let styleEl = document.getElementById('netcatty-custom-css') as HTMLStyleElement | null;
|
||||
if (!styleEl) {
|
||||
@@ -426,6 +631,89 @@ export const useSettingsState = () => {
|
||||
styleEl.textContent = customCSS;
|
||||
}, [customCSS, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP double-click behavior
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
|
||||
}, [sftpDoubleClickBehavior, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP auto-sync setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync);
|
||||
}, [sftpAutoSync, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP show hidden files setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles);
|
||||
}, [sftpShowHiddenFiles, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP compressed upload setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload);
|
||||
}, [sftpUseCompressedUpload, notifySettingsChanged]);
|
||||
|
||||
// Persist Session Logs settings
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled);
|
||||
}, [sessionLogsEnabled, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
|
||||
}, [sessionLogsDir, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
|
||||
}, [sessionLogsFormat, notifySettingsChanged]);
|
||||
|
||||
// Persist and sync toggle window hotkey setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
// Register/unregister the global hotkey in main process
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.registerGlobalHotkey) {
|
||||
if (toggleWindowHotkey) {
|
||||
setHotkeyRegistrationError(null);
|
||||
bridge
|
||||
.registerGlobalHotkey(toggleWindowHotkey)
|
||||
.then((result) => {
|
||||
if (result?.success === false) {
|
||||
console.warn('[GlobalHotkey] Hotkey registration failed:', result.error);
|
||||
setHotkeyRegistrationError(result.error || 'Failed to register hotkey');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[GlobalHotkey] Failed to register hotkey:', err);
|
||||
setHotkeyRegistrationError(err?.message || 'Failed to register hotkey');
|
||||
});
|
||||
} else {
|
||||
setHotkeyRegistrationError(null);
|
||||
bridge.unregisterGlobalHotkey?.().catch((err) => {
|
||||
console.warn('[GlobalHotkey] Failed to unregister hotkey:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [toggleWindowHotkey, notifySettingsChanged]);
|
||||
|
||||
// Persist and sync close to tray setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
// Update main process tray behavior
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.setCloseToTray) {
|
||||
bridge.setCloseToTray(closeToTray).catch((err) => {
|
||||
console.warn('[SystemTray] Failed to set close-to-tray:', err);
|
||||
});
|
||||
}
|
||||
}, [closeToTray, notifySettingsChanged]);
|
||||
|
||||
// Get merged key bindings (defaults + custom overrides)
|
||||
const keyBindings = useMemo((): KeyBinding[] => {
|
||||
return DEFAULT_KEY_BINDINGS.map(binding => {
|
||||
@@ -484,8 +772,8 @@ export const useSettingsState = () => {
|
||||
);
|
||||
|
||||
const currentTerminalFont = useMemo(
|
||||
() => TERMINAL_FONTS.find(f => f.id === terminalFontFamilyId) || TERMINAL_FONTS[0],
|
||||
[terminalFontFamilyId]
|
||||
() => availableFonts.find(f => f.id === terminalFontFamilyId) || availableFonts[0],
|
||||
[terminalFontFamilyId, availableFonts]
|
||||
);
|
||||
|
||||
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
|
||||
@@ -493,7 +781,7 @@ export const useSettingsState = () => {
|
||||
value: TerminalSettings[K]
|
||||
) => {
|
||||
setTerminalSettings(prev => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
}, [setTerminalSettings]);
|
||||
|
||||
return {
|
||||
theme,
|
||||
@@ -506,6 +794,8 @@ export const useSettingsState = () => {
|
||||
setAccentMode,
|
||||
customAccent,
|
||||
setCustomAccent,
|
||||
uiFontFamilyId,
|
||||
setUiFontFamilyId,
|
||||
syncConfig,
|
||||
updateSyncConfig,
|
||||
uiLanguage,
|
||||
@@ -532,5 +822,34 @@ export const useSettingsState = () => {
|
||||
setIsHotkeyRecording,
|
||||
customCSS,
|
||||
setCustomCSS,
|
||||
sftpDoubleClickBehavior,
|
||||
setSftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
setSftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
setSftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
setSftpUseCompressedUpload,
|
||||
// Editor Settings
|
||||
editorWordWrap,
|
||||
setEditorWordWrap: useCallback((enabled: boolean) => {
|
||||
setEditorWordWrapState(enabled);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(enabled));
|
||||
notifySettingsChanged(STORAGE_KEY_EDITOR_WORD_WRAP, enabled);
|
||||
}, [notifySettingsChanged]),
|
||||
availableFonts,
|
||||
// Session Logs
|
||||
sessionLogsEnabled,
|
||||
setSessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
setSessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
setSessionLogsFormat,
|
||||
// Global Toggle Window (Quake Mode)
|
||||
toggleWindowHotkey,
|
||||
setToggleWindowHotkey,
|
||||
closeToTray,
|
||||
setCloseToTray,
|
||||
hotkeyRegistrationError,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import type { RemoteFile } from "../../types";
|
||||
import type { RemoteFile, SftpFilenameEncoding } from "../../types";
|
||||
|
||||
export const useSftpBackend = () => {
|
||||
const openSftp = useCallback(async (options: NetcattySSHOptions) => {
|
||||
@@ -15,34 +15,34 @@ export const useSftpBackend = () => {
|
||||
return bridge.closeSftp(sftpId);
|
||||
}, []);
|
||||
|
||||
const listSftp = useCallback(async (sftpId: string, path: string) => {
|
||||
const listSftp = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listSftp) throw new Error("SFTP bridge unavailable");
|
||||
return bridge.listSftp(sftpId, path);
|
||||
return bridge.listSftp(sftpId, path, encoding);
|
||||
}, []);
|
||||
|
||||
const readSftp = useCallback(async (sftpId: string, path: string) => {
|
||||
const readSftp = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.readSftp) throw new Error("SFTP bridge unavailable");
|
||||
return bridge.readSftp(sftpId, path);
|
||||
return bridge.readSftp(sftpId, path, encoding);
|
||||
}, []);
|
||||
|
||||
const readSftpBinary = useCallback(async (sftpId: string, path: string) => {
|
||||
const readSftpBinary = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.readSftpBinary) throw new Error("readSftpBinary unavailable");
|
||||
return bridge.readSftpBinary(sftpId, path);
|
||||
return bridge.readSftpBinary(sftpId, path, encoding);
|
||||
}, []);
|
||||
|
||||
const writeSftp = useCallback(async (sftpId: string, path: string, content: string) => {
|
||||
const writeSftp = useCallback(async (sftpId: string, path: string, content: string, encoding?: SftpFilenameEncoding) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.writeSftp) throw new Error("SFTP bridge unavailable");
|
||||
return bridge.writeSftp(sftpId, path, content);
|
||||
return bridge.writeSftp(sftpId, path, content, encoding);
|
||||
}, []);
|
||||
|
||||
const writeSftpBinary = useCallback(async (sftpId: string, path: string, content: ArrayBuffer) => {
|
||||
const writeSftpBinary = useCallback(async (sftpId: string, path: string, content: ArrayBuffer, encoding?: SftpFilenameEncoding) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.writeSftpBinary) throw new Error("writeSftpBinary unavailable");
|
||||
return bridge.writeSftpBinary(sftpId, path, content);
|
||||
return bridge.writeSftpBinary(sftpId, path, content, encoding);
|
||||
}, []);
|
||||
|
||||
const writeSftpBinaryWithProgress = useCallback(
|
||||
@@ -51,6 +51,7 @@ export const useSftpBackend = () => {
|
||||
path: string,
|
||||
content: ArrayBuffer,
|
||||
transferId: string,
|
||||
encoding?: SftpFilenameEncoding,
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void,
|
||||
@@ -62,6 +63,7 @@ export const useSftpBackend = () => {
|
||||
path,
|
||||
content,
|
||||
transferId,
|
||||
encoding,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onError,
|
||||
@@ -70,34 +72,34 @@ export const useSftpBackend = () => {
|
||||
[],
|
||||
);
|
||||
|
||||
const mkdirSftp = useCallback(async (sftpId: string, path: string) => {
|
||||
const mkdirSftp = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.mkdirSftp) throw new Error("mkdirSftp unavailable");
|
||||
return bridge.mkdirSftp(sftpId, path);
|
||||
return bridge.mkdirSftp(sftpId, path, encoding);
|
||||
}, []);
|
||||
|
||||
const deleteSftp = useCallback(async (sftpId: string, path: string) => {
|
||||
const deleteSftp = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.deleteSftp) throw new Error("deleteSftp unavailable");
|
||||
return bridge.deleteSftp(sftpId, path);
|
||||
return bridge.deleteSftp(sftpId, path, encoding);
|
||||
}, []);
|
||||
|
||||
const renameSftp = useCallback(async (sftpId: string, oldPath: string, newPath: string) => {
|
||||
const renameSftp = useCallback(async (sftpId: string, oldPath: string, newPath: string, encoding?: SftpFilenameEncoding) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.renameSftp) throw new Error("renameSftp unavailable");
|
||||
return bridge.renameSftp(sftpId, oldPath, newPath);
|
||||
return bridge.renameSftp(sftpId, oldPath, newPath, encoding);
|
||||
}, []);
|
||||
|
||||
const statSftp = useCallback(async (sftpId: string, path: string) => {
|
||||
const statSftp = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.statSftp) throw new Error("statSftp unavailable");
|
||||
return bridge.statSftp(sftpId, path);
|
||||
return bridge.statSftp(sftpId, path, encoding);
|
||||
}, []);
|
||||
|
||||
const chmodSftp = useCallback(async (sftpId: string, path: string, mode: string) => {
|
||||
const chmodSftp = useCallback(async (sftpId: string, path: string, mode: string, encoding?: SftpFilenameEncoding) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.chmodSftp) throw new Error("chmodSftp unavailable");
|
||||
return bridge.chmodSftp(sftpId, path, mode);
|
||||
return bridge.chmodSftp(sftpId, path, mode, encoding);
|
||||
}, []);
|
||||
|
||||
const listLocalDir = useCallback(async (path: string): Promise<RemoteFile[]> => {
|
||||
@@ -168,12 +170,84 @@ export const useSftpBackend = () => {
|
||||
return bridge.cancelTransfer(transferId);
|
||||
}, []);
|
||||
|
||||
const cancelSftpUpload = useCallback(async (transferId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.cancelSftpUpload) return undefined;
|
||||
return bridge.cancelSftpUpload(transferId);
|
||||
}, []);
|
||||
|
||||
const onTransferProgress = useCallback((transferId: string, cb: Parameters<NonNullable<NetcattyBridge["onTransferProgress"]>>[1]) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onTransferProgress) return undefined;
|
||||
return bridge.onTransferProgress(transferId, cb);
|
||||
}, []);
|
||||
|
||||
const selectApplication = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.selectApplication) return undefined;
|
||||
return bridge.selectApplication();
|
||||
}, []);
|
||||
|
||||
const showSaveDialog = useCallback(async (
|
||||
defaultPath: string,
|
||||
filters?: Array<{ name: string; extensions: string[] }>
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.showSaveDialog) return null;
|
||||
return bridge.showSaveDialog(defaultPath, filters);
|
||||
}, []);
|
||||
|
||||
const downloadSftpToTempAndOpen = useCallback(async (
|
||||
sftpId: string,
|
||||
remotePath: string,
|
||||
fileName: string,
|
||||
appPath: string,
|
||||
options?: { enableWatch?: boolean; encoding?: SftpFilenameEncoding }
|
||||
): Promise<{ localTempPath: string; watchId?: string }> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.downloadSftpToTemp || !bridge?.openWithApplication) {
|
||||
throw new Error("Download to temp / open with unavailable");
|
||||
}
|
||||
|
||||
// Download the file to temp
|
||||
console.log("[SFTPBackend] Downloading file to temp", { sftpId, remotePath, fileName });
|
||||
const tempPath = await bridge.downloadSftpToTemp(sftpId, remotePath, fileName, options?.encoding);
|
||||
console.log("[SFTPBackend] File downloaded to temp", { tempPath });
|
||||
|
||||
// Register temp file for cleanup when SFTP session closes (regardless of auto-sync setting)
|
||||
if (bridge.registerTempFile) {
|
||||
try {
|
||||
await bridge.registerTempFile(sftpId, tempPath);
|
||||
} catch (err) {
|
||||
console.warn("[SFTPBackend] Failed to register temp file for cleanup:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Open with the selected application
|
||||
console.log("[SFTPBackend] Opening with application", { tempPath, appPath });
|
||||
await bridge.openWithApplication(tempPath, appPath);
|
||||
console.log("[SFTPBackend] Application launched");
|
||||
|
||||
// Start file watching if enabled
|
||||
let watchId: string | undefined;
|
||||
console.log("[SFTPBackend] Auto-sync enabled check", { enableWatch: options?.enableWatch, hasStartFileWatch: !!bridge.startFileWatch });
|
||||
if (options?.enableWatch && bridge.startFileWatch) {
|
||||
try {
|
||||
console.log("[SFTPBackend] Starting file watch", { tempPath, remotePath, sftpId });
|
||||
const result = await bridge.startFileWatch(tempPath, remotePath, sftpId, options?.encoding);
|
||||
watchId = result.watchId;
|
||||
console.log("[SFTPBackend] File watch started successfully", { watchId, tempPath, remotePath });
|
||||
} catch (err) {
|
||||
console.warn("[SFTPBackend] Failed to start file watch:", err);
|
||||
// Don't fail the operation if watching fails
|
||||
}
|
||||
} else {
|
||||
console.log("[SFTPBackend] File watching not enabled or not available");
|
||||
}
|
||||
|
||||
return { localTempPath: tempPath, watchId };
|
||||
}, []);
|
||||
|
||||
return {
|
||||
openSftp,
|
||||
closeSftp,
|
||||
@@ -200,7 +274,10 @@ export const useSftpBackend = () => {
|
||||
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
cancelSftpUpload,
|
||||
onTransferProgress,
|
||||
selectApplication,
|
||||
showSaveDialog,
|
||||
downloadSftpToTempAndOpen,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
149
application/state/useSftpFileAssociations.ts
Normal file
149
application/state/useSftpFileAssociations.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* useSftpFileAssociations - Hook for managing SFTP file opener associations
|
||||
* Uses a shared state pattern to sync across components
|
||||
*/
|
||||
import { useCallback, useEffect, useSyncExternalStore } from 'react';
|
||||
import { STORAGE_KEY_SFTP_FILE_ASSOCIATIONS } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import type { FileAssociation, FileOpenerType, SystemAppInfo } from '../../lib/sftpFileUtils';
|
||||
import { getFileExtension } from '../../lib/sftpFileUtils';
|
||||
|
||||
export interface FileAssociationEntry {
|
||||
openerType: FileOpenerType;
|
||||
systemApp?: SystemAppInfo;
|
||||
}
|
||||
|
||||
export interface FileAssociationsMap {
|
||||
[extension: string]: FileAssociationEntry;
|
||||
}
|
||||
|
||||
// Shared state and subscribers for cross-component synchronization
|
||||
const subscribers = new Set<() => void>();
|
||||
|
||||
// Use a wrapper object so we can update the reference for useSyncExternalStore
|
||||
let snapshotRef: { associations: FileAssociationsMap } = { associations: {} };
|
||||
|
||||
function loadFromStorage(): FileAssociationsMap {
|
||||
const stored = localStorageAdapter.read<FileAssociationsMap>(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS);
|
||||
console.log('[SftpFileAssociations] Loading from storage:', stored);
|
||||
if (stored) {
|
||||
const migrated: FileAssociationsMap = {};
|
||||
for (const [ext, value] of Object.entries(stored)) {
|
||||
if (typeof value === 'string') {
|
||||
migrated[ext] = { openerType: value as FileOpenerType };
|
||||
} else {
|
||||
migrated[ext] = value as FileAssociationEntry;
|
||||
}
|
||||
}
|
||||
console.log('[SftpFileAssociations] Migrated associations:', migrated);
|
||||
return migrated;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Initialize from storage
|
||||
snapshotRef = { associations: loadFromStorage() };
|
||||
|
||||
function saveToStorage(associations: FileAssociationsMap) {
|
||||
console.log('[SftpFileAssociations] saveToStorage called with:', associations);
|
||||
localStorageAdapter.write(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS, associations);
|
||||
// Verify it was saved
|
||||
const verify = localStorageAdapter.read(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS);
|
||||
console.log('[SftpFileAssociations] Verification read from storage:', verify);
|
||||
}
|
||||
|
||||
function updateAssociations(newAssociations: FileAssociationsMap) {
|
||||
console.log('[SftpFileAssociations] Updating associations:', newAssociations);
|
||||
// Create new reference so useSyncExternalStore detects change
|
||||
snapshotRef = { associations: newAssociations };
|
||||
saveToStorage(newAssociations);
|
||||
console.log('[SftpFileAssociations] Notifying', subscribers.size, 'subscribers');
|
||||
subscribers.forEach(callback => callback());
|
||||
}
|
||||
|
||||
function subscribe(callback: () => void) {
|
||||
subscribers.add(callback);
|
||||
return () => subscribers.delete(callback);
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return snapshotRef;
|
||||
}
|
||||
|
||||
export function useSftpFileAssociations() {
|
||||
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
const associations = snapshot.associations;
|
||||
|
||||
// Listen for storage events from other tabs/windows
|
||||
useEffect(() => {
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (e.key === STORAGE_KEY_SFTP_FILE_ASSOCIATIONS) {
|
||||
updateAssociations(loadFromStorage());
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => window.removeEventListener('storage', handleStorage);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get the opener entry for a file based on its extension
|
||||
*/
|
||||
const getOpenerForFile = useCallback((fileName: string): FileAssociationEntry | null => {
|
||||
const ext = getFileExtension(fileName);
|
||||
return associations[ext] || null;
|
||||
}, [associations]);
|
||||
|
||||
/**
|
||||
* Set the opener type for a specific extension
|
||||
*/
|
||||
const setOpenerForExtension = useCallback((
|
||||
extension: string,
|
||||
openerType: FileOpenerType,
|
||||
systemApp?: SystemAppInfo
|
||||
) => {
|
||||
console.log('[SftpFileAssociations] setOpenerForExtension called with:', { extension, openerType, systemApp });
|
||||
console.log('[SftpFileAssociations] Current associations before update:', snapshotRef.associations);
|
||||
updateAssociations({
|
||||
...snapshotRef.associations,
|
||||
[extension.toLowerCase()]: { openerType, systemApp },
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Remove the association for a specific extension
|
||||
*/
|
||||
const removeAssociation = useCallback((extension: string) => {
|
||||
const next = { ...snapshotRef.associations };
|
||||
delete next[extension.toLowerCase()];
|
||||
updateAssociations(next);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get all associations as an array
|
||||
*/
|
||||
const getAllAssociations = useCallback((): FileAssociation[] => {
|
||||
const result = Object.entries(associations).map(([extension, entry]: [string, FileAssociationEntry]) => ({
|
||||
extension,
|
||||
openerType: entry.openerType,
|
||||
systemApp: entry.systemApp,
|
||||
}));
|
||||
console.log('[SftpFileAssociations] getAllAssociations called, returning', result.length, 'items:', result);
|
||||
return result;
|
||||
}, [associations]);
|
||||
|
||||
/**
|
||||
* Clear all associations
|
||||
*/
|
||||
const clearAllAssociations = useCallback(() => {
|
||||
updateAssociations({});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
associations,
|
||||
getOpenerForFile,
|
||||
setOpenerForExtension,
|
||||
removeAssociation,
|
||||
getAllAssociations,
|
||||
clearAllAssociations,
|
||||
};
|
||||
}
|
||||
184
application/state/useSftpFileOperations.ts
Normal file
184
application/state/useSftpFileOperations.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* useSftpFileOperations - Shared file operations for SFTP components
|
||||
*
|
||||
* This hook provides common file operations like open, edit, preview
|
||||
* that can be shared between SFTPModal and SftpView components.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { getFileExtension, isTextFile, FileOpenerType } from "../../lib/sftpFileUtils";
|
||||
import { toast } from "../../components/ui/toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useSftpFileAssociations } from "./useSftpFileAssociations";
|
||||
|
||||
export interface FileOperationsState {
|
||||
// Text editor state
|
||||
showTextEditor: boolean;
|
||||
textEditorTarget: { name: string; fullPath: string } | null;
|
||||
textEditorContent: string;
|
||||
loadingTextContent: boolean;
|
||||
|
||||
// File opener dialog state
|
||||
showFileOpenerDialog: boolean;
|
||||
fileOpenerTarget: { name: string; fullPath: string } | null;
|
||||
}
|
||||
|
||||
export interface FileOperationsActions {
|
||||
// Open file based on type/association
|
||||
openFile: (fileName: string, fullPath: string) => void;
|
||||
|
||||
// Edit text file
|
||||
editFile: (
|
||||
fileName: string,
|
||||
fullPath: string,
|
||||
readContent: () => Promise<string>
|
||||
) => Promise<void>;
|
||||
|
||||
// Save text file
|
||||
saveTextFile: (
|
||||
content: string,
|
||||
writeContent: (path: string, content: string) => Promise<void>
|
||||
) => Promise<void>;
|
||||
|
||||
// Handle file opener selection
|
||||
handleFileOpenerSelect: (
|
||||
openerType: FileOpenerType,
|
||||
setAsDefault: boolean,
|
||||
readTextContent: () => Promise<string>,
|
||||
readImageData: () => Promise<ArrayBuffer>
|
||||
) => Promise<void>;
|
||||
|
||||
// Close modals
|
||||
closeTextEditor: () => void;
|
||||
closeFileOpenerDialog: () => void;
|
||||
|
||||
// Check if file can be edited
|
||||
canEditFile: (fileName: string) => boolean;
|
||||
}
|
||||
|
||||
export interface UseSftpFileOperationsResult {
|
||||
state: FileOperationsState;
|
||||
actions: FileOperationsActions;
|
||||
}
|
||||
|
||||
export function useSftpFileOperations(): UseSftpFileOperationsResult {
|
||||
const { t } = useI18n();
|
||||
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
|
||||
|
||||
// Text editor state
|
||||
const [showTextEditor, setShowTextEditor] = useState(false);
|
||||
const [textEditorTarget, setTextEditorTarget] = useState<{ name: string; fullPath: string } | null>(null);
|
||||
const [textEditorContent, setTextEditorContent] = useState("");
|
||||
const [loadingTextContent, setLoadingTextContent] = useState(false);
|
||||
|
||||
// File opener dialog state
|
||||
const [showFileOpenerDialog, setShowFileOpenerDialog] = useState(false);
|
||||
const [fileOpenerTarget, setFileOpenerTarget] = useState<{ name: string; fullPath: string } | null>(null);
|
||||
|
||||
const canEditFile = useCallback((fileName: string) => {
|
||||
return isTextFile(fileName);
|
||||
}, []);
|
||||
|
||||
const closeTextEditor = useCallback(() => {
|
||||
setShowTextEditor(false);
|
||||
setTextEditorTarget(null);
|
||||
setTextEditorContent("");
|
||||
}, []);
|
||||
|
||||
const closeFileOpenerDialog = useCallback(() => {
|
||||
setShowFileOpenerDialog(false);
|
||||
setFileOpenerTarget(null);
|
||||
}, []);
|
||||
|
||||
const editFile = useCallback(async (
|
||||
fileName: string,
|
||||
fullPath: string,
|
||||
readContent: () => Promise<string>
|
||||
) => {
|
||||
try {
|
||||
setLoadingTextContent(true);
|
||||
setTextEditorTarget({ name: fileName, fullPath });
|
||||
const content = await readContent();
|
||||
setTextEditorContent(content);
|
||||
setShowTextEditor(true);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setLoadingTextContent(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const saveTextFile = useCallback(async (
|
||||
content: string,
|
||||
writeContent: (path: string, content: string) => Promise<void>
|
||||
) => {
|
||||
if (!textEditorTarget) return;
|
||||
await writeContent(textEditorTarget.fullPath, content);
|
||||
}, [textEditorTarget]);
|
||||
|
||||
const openFile = useCallback((fileName: string, fullPath: string) => {
|
||||
const savedOpener = getOpenerForFile(fileName);
|
||||
|
||||
if (savedOpener) {
|
||||
// User has saved an opener for this file type
|
||||
// We'll just set the target and let the caller handle it
|
||||
setFileOpenerTarget({ name: fileName, fullPath });
|
||||
|
||||
// Return the opener type so caller knows which operation to perform
|
||||
if (savedOpener === 'builtin-editor' && canEditFile(fileName)) {
|
||||
// Don't show dialog, caller should call editFile
|
||||
return 'edit' as const;
|
||||
}
|
||||
}
|
||||
|
||||
// No saved opener, show the dialog
|
||||
setFileOpenerTarget({ name: fileName, fullPath });
|
||||
setShowFileOpenerDialog(true);
|
||||
return 'dialog' as const;
|
||||
}, [getOpenerForFile, canEditFile]);
|
||||
|
||||
const handleFileOpenerSelect = useCallback(async (
|
||||
openerType: FileOpenerType,
|
||||
setAsDefault: boolean,
|
||||
readTextContent: () => Promise<string>,
|
||||
_readImageData: () => Promise<ArrayBuffer>
|
||||
) => {
|
||||
if (!fileOpenerTarget) return;
|
||||
|
||||
if (setAsDefault) {
|
||||
const ext = getFileExtension(fileOpenerTarget.name);
|
||||
if (ext !== 'file') {
|
||||
setOpenerForExtension(ext, openerType);
|
||||
}
|
||||
}
|
||||
|
||||
setShowFileOpenerDialog(false);
|
||||
|
||||
if (openerType === 'builtin-editor') {
|
||||
await editFile(fileOpenerTarget.name, fileOpenerTarget.fullPath, readTextContent);
|
||||
}
|
||||
}, [fileOpenerTarget, setOpenerForExtension, editFile]);
|
||||
|
||||
return {
|
||||
state: {
|
||||
showTextEditor,
|
||||
textEditorTarget,
|
||||
textEditorContent,
|
||||
loadingTextContent,
|
||||
showFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
},
|
||||
actions: {
|
||||
openFile,
|
||||
editFile,
|
||||
saveTextFile,
|
||||
handleFileOpenerSelect,
|
||||
closeTextEditor,
|
||||
closeFileOpenerDialog,
|
||||
canEditFile,
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
24
application/state/useStoredBoolean.ts
Normal file
24
application/state/useStoredBoolean.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
/**
|
||||
* Hook for persisting a boolean value to localStorage.
|
||||
* @param storageKey - The key to use for localStorage
|
||||
* @param fallback - The default value if no stored value exists (defaults to false)
|
||||
* @returns A tuple of [value, setValue] similar to useState
|
||||
*/
|
||||
export const useStoredBoolean = (
|
||||
storageKey: string,
|
||||
fallback: boolean = false,
|
||||
) => {
|
||||
const [value, setValue] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(storageKey);
|
||||
return stored ?? fallback;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeBoolean(storageKey, value);
|
||||
}, [storageKey, value]);
|
||||
|
||||
return [value, setValue] as const;
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
export type ViewMode = "grid" | "list";
|
||||
export type ViewMode = "grid" | "list" | "tree";
|
||||
|
||||
const isViewMode = (value: string | null): value is ViewMode =>
|
||||
value === "grid" || value === "list";
|
||||
value === "grid" || value === "list" || value === "tree";
|
||||
|
||||
export const useStoredViewMode = (
|
||||
storageKey: string,
|
||||
|
||||
@@ -17,6 +17,11 @@ export const useTerminalBackend = () => {
|
||||
return !!bridge?.startLocalSession;
|
||||
}, []);
|
||||
|
||||
const serialAvailable = useCallback(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return !!bridge?.startSerialSession;
|
||||
}, []);
|
||||
|
||||
const execAvailable = useCallback(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return !!bridge?.execCommand;
|
||||
@@ -46,6 +51,12 @@ export const useTerminalBackend = () => {
|
||||
return bridge.startLocalSession(options);
|
||||
}, []);
|
||||
|
||||
const startSerialSession = useCallback(async (options: Parameters<NonNullable<NetcattyBridge["startSerialSession"]>>[0]) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.startSerialSession) throw new Error("startSerialSession unavailable");
|
||||
return bridge.startSerialSession(options);
|
||||
}, []);
|
||||
|
||||
const execCommand = useCallback(async (options: Parameters<NetcattyBridge["execCommand"]>[0]) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.execCommand) throw new Error("execCommand unavailable");
|
||||
@@ -99,18 +110,41 @@ export const useTerminalBackend = () => {
|
||||
return !!bridge?.startSSHSession;
|
||||
}, []);
|
||||
|
||||
const listSerialPorts = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listSerialPorts) return [];
|
||||
return bridge.listSerialPorts();
|
||||
}, []);
|
||||
|
||||
const getSessionPwd = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
|
||||
return bridge.getSessionPwd(sessionId);
|
||||
}, []);
|
||||
|
||||
const getServerStats = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getServerStats) return { success: false, error: 'getServerStats unavailable' };
|
||||
return bridge.getServerStats(sessionId);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
backendAvailable,
|
||||
telnetAvailable,
|
||||
moshAvailable,
|
||||
localAvailable,
|
||||
serialAvailable,
|
||||
execAvailable,
|
||||
openExternalAvailable,
|
||||
startSSHSession,
|
||||
startTelnetSession,
|
||||
startMoshSession,
|
||||
startLocalSession,
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
getServerStats,
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
closeSession,
|
||||
|
||||
72
application/state/useTrayPanelBackend.ts
Normal file
72
application/state/useTrayPanelBackend.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useCallback } from "react";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
|
||||
export const useTrayPanelBackend = () => {
|
||||
const hideTrayPanel = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.hideTrayPanel?.();
|
||||
}, []);
|
||||
|
||||
const openMainWindow = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.openMainWindow?.();
|
||||
}, []);
|
||||
|
||||
const quitApp = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.quitApp?.();
|
||||
}, []);
|
||||
|
||||
const jumpToSession = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.jumpToSessionFromTrayPanel?.(sessionId);
|
||||
}, []);
|
||||
|
||||
const connectToHostFromTrayPanel = useCallback(async (hostId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.connectToHostFromTrayPanel?.(hostId);
|
||||
}, []);
|
||||
|
||||
const onTrayPanelCloseRequest = useCallback((callback: () => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onTrayPanelCloseRequest?.(callback);
|
||||
}, []);
|
||||
|
||||
const onTrayPanelRefresh = useCallback((callback: () => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onTrayPanelRefresh?.(callback);
|
||||
}, []);
|
||||
|
||||
const onTrayPanelMenuData = useCallback(
|
||||
(
|
||||
callback: (data: {
|
||||
sessions?: Array<{ id: string; label: string; hostLabel: string; status: "connecting" | "connected" | "disconnected"; workspaceId?: string; workspaceTitle?: string }>;
|
||||
portForwardRules?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
type: "local" | "remote" | "dynamic";
|
||||
localPort: number;
|
||||
remoteHost?: string;
|
||||
remotePort?: number;
|
||||
status: "inactive" | "connecting" | "active" | "error";
|
||||
hostId?: string;
|
||||
}>;
|
||||
}) => void,
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onTrayPanelMenuData?.(callback);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
hideTrayPanel,
|
||||
openMainWindow,
|
||||
quitApp,
|
||||
jumpToSession,
|
||||
connectToHostFromTrayPanel,
|
||||
onTrayPanelCloseRequest,
|
||||
onTrayPanelRefresh,
|
||||
onTrayPanelMenuData,
|
||||
};
|
||||
};
|
||||
47
application/state/useTreeExpandedState.ts
Normal file
47
application/state/useTreeExpandedState.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
export const useTreeExpandedState = (storageKey: string) => {
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => {
|
||||
const stored = localStorageAdapter.readString(storageKey);
|
||||
if (stored) {
|
||||
try {
|
||||
const paths = JSON.parse(stored) as string[];
|
||||
return new Set(paths);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
return new Set();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const pathsArray = Array.from(expandedPaths);
|
||||
localStorageAdapter.writeString(storageKey, JSON.stringify(pathsArray));
|
||||
}, [storageKey, expandedPaths]);
|
||||
|
||||
const togglePath = (path: string) => {
|
||||
const newExpanded = new Set(expandedPaths);
|
||||
if (newExpanded.has(path)) {
|
||||
newExpanded.delete(path);
|
||||
} else {
|
||||
newExpanded.add(path);
|
||||
}
|
||||
setExpandedPaths(newExpanded);
|
||||
};
|
||||
|
||||
const expandAll = (allPaths: string[]) => {
|
||||
setExpandedPaths(new Set(allPaths));
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
setExpandedPaths(new Set());
|
||||
};
|
||||
|
||||
return {
|
||||
expandedPaths,
|
||||
togglePath,
|
||||
expandAll,
|
||||
collapseAll,
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
|
||||
import {
|
||||
ConnectionLog,
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Identity,
|
||||
KeyCategory,
|
||||
KnownHost,
|
||||
ManagedSource,
|
||||
ShellHistoryEntry,
|
||||
Snippet,
|
||||
SSHKey,
|
||||
@@ -22,11 +23,20 @@ import {
|
||||
STORAGE_KEY_KEYS,
|
||||
STORAGE_KEY_KNOWN_HOSTS,
|
||||
STORAGE_KEY_LEGACY_KEYS,
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
decryptHosts,
|
||||
decryptIdentities,
|
||||
decryptKeys,
|
||||
encryptHosts,
|
||||
encryptIdentities,
|
||||
encryptKeys,
|
||||
} from "../../infrastructure/persistence/secureFieldAdapter";
|
||||
|
||||
type ExportableVaultData = {
|
||||
hosts: Host[];
|
||||
@@ -95,21 +105,49 @@ export const useVaultState = () => {
|
||||
const [knownHosts, setKnownHosts] = useState<KnownHost[]>([]);
|
||||
const [shellHistory, setShellHistory] = useState<ShellHistoryEntry[]>([]);
|
||||
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
|
||||
const [managedSources, setManagedSources] = useState<ManagedSource[]>([]);
|
||||
|
||||
// Write-version counters prevent out-of-order async writes from overwriting
|
||||
// newer data. Each update bumps the counter; the .then() callback only
|
||||
// persists if its version still matches the latest.
|
||||
const hostsWriteVersion = useRef(0);
|
||||
const keysWriteVersion = useRef(0);
|
||||
const identitiesWriteVersion = useRef(0);
|
||||
|
||||
// Read-sequence counters for cross-window storage events. Each incoming
|
||||
// event bumps the counter; the async decrypt callback only applies state if
|
||||
// its sequence still matches, preventing stale decrypts from overwriting
|
||||
// newer data when multiple events arrive in quick succession.
|
||||
const hostsReadSeq = useRef(0);
|
||||
const keysReadSeq = useRef(0);
|
||||
const identitiesReadSeq = useRef(0);
|
||||
|
||||
const updateHosts = useCallback((data: Host[]) => {
|
||||
const cleaned = data.map(sanitizeHost);
|
||||
setHosts(cleaned);
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, cleaned);
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
encryptHosts(cleaned).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateKeys = useCallback((data: SSHKey[]) => {
|
||||
setKeys(data);
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, data);
|
||||
const ver = ++keysWriteVersion.current;
|
||||
encryptKeys(data).then((enc) => {
|
||||
if (ver === keysWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateIdentities = useCallback((data: Identity[]) => {
|
||||
setIdentities(data);
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, data);
|
||||
const ver = ++identitiesWriteVersion.current;
|
||||
encryptIdentities(data).then((enc) => {
|
||||
if (ver === identitiesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateSnippets = useCallback((data: Snippet[]) => {
|
||||
@@ -132,6 +170,11 @@ export const useVaultState = () => {
|
||||
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, data);
|
||||
}, []);
|
||||
|
||||
const updateManagedSources = useCallback((data: ManagedSource[]) => {
|
||||
setManagedSources(data);
|
||||
localStorageAdapter.write(STORAGE_KEY_MANAGED_SOURCES, data);
|
||||
}, []);
|
||||
|
||||
const clearVaultData = useCallback(() => {
|
||||
updateHosts([]);
|
||||
updateKeys([]);
|
||||
@@ -140,6 +183,7 @@ export const useVaultState = () => {
|
||||
updateSnippetPackages([]);
|
||||
updateCustomGroups([]);
|
||||
updateKnownHosts([]);
|
||||
updateManagedSources([]);
|
||||
localStorageAdapter.remove(STORAGE_KEY_LEGACY_KEYS);
|
||||
}, [
|
||||
updateHosts,
|
||||
@@ -149,6 +193,7 @@ export const useVaultState = () => {
|
||||
updateSnippetPackages,
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
]);
|
||||
|
||||
const addShellHistoryEntry = useCallback(
|
||||
@@ -261,7 +306,11 @@ export const useVaultState = () => {
|
||||
// Add to hosts using functional update
|
||||
setHosts((prevHosts) => {
|
||||
const updated = [...prevHosts, sanitizeHost(newHost)];
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, updated);
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
encryptHosts(updated).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
|
||||
@@ -269,76 +318,120 @@ export const useVaultState = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
|
||||
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
|
||||
const savedIdentities =
|
||||
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
|
||||
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
|
||||
const savedSnippets =
|
||||
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
|
||||
const savedSnippetPackages = localStorageAdapter.read<string[]>(
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
);
|
||||
const init = async () => {
|
||||
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
|
||||
|
||||
if (savedHosts) {
|
||||
const sanitized = savedHosts.map(sanitizeHost);
|
||||
setHosts(sanitized);
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, sanitized);
|
||||
} else {
|
||||
updateHosts(INITIAL_HOSTS);
|
||||
}
|
||||
if (savedHosts) {
|
||||
// Capture version before the async gap so that any write occurring
|
||||
// during decryption (storage event, user edit) advances the counter
|
||||
// and causes this stale result to be discarded.
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
const decrypted = await decryptHosts(savedHosts);
|
||||
if (ver === hostsWriteVersion.current) {
|
||||
const sanitized = decrypted.map(sanitizeHost);
|
||||
setHosts(sanitized);
|
||||
encryptHosts(sanitized).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
updateHosts(INITIAL_HOSTS);
|
||||
}
|
||||
|
||||
// Migrate old keys to new format with source/category fields
|
||||
if (savedKeysRaw?.length) {
|
||||
const migratedKeys: SSHKey[] = [];
|
||||
const legacyKeys: LegacyKeyRecord[] = [];
|
||||
// Read keys fresh here (not before the hosts await) so we don't apply
|
||||
// a stale snapshot if keys were updated during host decryption.
|
||||
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
|
||||
|
||||
for (const entry of savedKeysRaw) {
|
||||
const record =
|
||||
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
|
||||
if (!record) continue;
|
||||
// Migrate old keys to new format with source/category fields
|
||||
if (savedKeysRaw?.length) {
|
||||
const migratedKeys: SSHKey[] = [];
|
||||
const legacyKeys: LegacyKeyRecord[] = [];
|
||||
|
||||
if (isLegacyUnsupportedKey(record)) {
|
||||
legacyKeys.push(record);
|
||||
continue;
|
||||
for (const entry of savedKeysRaw) {
|
||||
const record =
|
||||
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
|
||||
if (!record) continue;
|
||||
|
||||
if (isLegacyUnsupportedKey(record)) {
|
||||
legacyKeys.push(record);
|
||||
continue;
|
||||
}
|
||||
|
||||
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
|
||||
}
|
||||
|
||||
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
|
||||
// Decrypt sensitive fields (passphrase, privateKey)
|
||||
const keyVer = ++keysWriteVersion.current;
|
||||
const decryptedKeys = await decryptKeys(migratedKeys);
|
||||
if (keyVer === keysWriteVersion.current) {
|
||||
setKeys(decryptedKeys);
|
||||
encryptKeys(decryptedKeys).then((enc) => {
|
||||
if (keyVer === keysWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||||
});
|
||||
}
|
||||
if (legacyKeys.length) {
|
||||
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
|
||||
}
|
||||
}
|
||||
|
||||
setKeys(migratedKeys);
|
||||
// Persist migrated keys
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, migratedKeys);
|
||||
if (legacyKeys.length) {
|
||||
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
|
||||
// Read identities fresh here (not before the hosts/keys awaits) so we
|
||||
// don't apply a stale snapshot if identities were updated during prior decryption.
|
||||
const savedIdentities =
|
||||
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
|
||||
if (savedIdentities) {
|
||||
const idVer = ++identitiesWriteVersion.current;
|
||||
const decryptedIds = await decryptIdentities(savedIdentities);
|
||||
if (idVer === identitiesWriteVersion.current) {
|
||||
setIdentities(decryptedIds);
|
||||
encryptIdentities(decryptedIds).then((enc) => {
|
||||
if (idVer === identitiesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (savedIdentities) setIdentities(savedIdentities);
|
||||
// Read remaining non-encrypted data fresh after all async gaps above
|
||||
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
|
||||
const savedSnippets =
|
||||
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
|
||||
const savedSnippetPackages = localStorageAdapter.read<string[]>(
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
);
|
||||
|
||||
if (savedSnippets) setSnippets(savedSnippets);
|
||||
else updateSnippets(INITIAL_SNIPPETS);
|
||||
if (savedSnippets) setSnippets(savedSnippets);
|
||||
else updateSnippets(INITIAL_SNIPPETS);
|
||||
|
||||
if (savedGroups) setCustomGroups(savedGroups);
|
||||
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
|
||||
if (savedGroups) setCustomGroups(savedGroups);
|
||||
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
|
||||
|
||||
// Load known hosts
|
||||
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
|
||||
STORAGE_KEY_KNOWN_HOSTS,
|
||||
);
|
||||
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
|
||||
// Load known hosts
|
||||
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
|
||||
STORAGE_KEY_KNOWN_HOSTS,
|
||||
);
|
||||
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
|
||||
|
||||
// Load shell history
|
||||
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
);
|
||||
if (savedShellHistory) setShellHistory(savedShellHistory);
|
||||
// Load shell history
|
||||
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
);
|
||||
if (savedShellHistory) setShellHistory(savedShellHistory);
|
||||
|
||||
// Load connection logs
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
|
||||
// Load connection logs
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
|
||||
|
||||
// Load managed sources
|
||||
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
);
|
||||
if (savedManagedSources) setManagedSources(savedManagedSources);
|
||||
};
|
||||
|
||||
init();
|
||||
}, [updateHosts, updateSnippets]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -351,7 +444,17 @@ export const useVaultState = () => {
|
||||
|
||||
if (key === STORAGE_KEY_HOSTS) {
|
||||
const next = safeParse<Host[]>(event.newValue) ?? [];
|
||||
setHosts(next.map(sanitizeHost));
|
||||
// Bump write version to invalidate any in-flight encrypt from this
|
||||
// window — the cross-window data is newer and must not be overwritten.
|
||||
++hostsWriteVersion.current;
|
||||
const seq = ++hostsReadSeq.current;
|
||||
const writeAtStart = hostsWriteVersion.current;
|
||||
decryptHosts(next).then((dec) => {
|
||||
// Discard if a newer storage event arrived OR a local write occurred
|
||||
// during the decrypt (writeVersion would have advanced).
|
||||
if (seq === hostsReadSeq.current && writeAtStart === hostsWriteVersion.current)
|
||||
setHosts(dec.map(sanitizeHost));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -364,13 +467,25 @@ export const useVaultState = () => {
|
||||
if (!record || isLegacyUnsupportedKey(record)) continue;
|
||||
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
|
||||
}
|
||||
setKeys(migratedKeys);
|
||||
++keysWriteVersion.current;
|
||||
const seq = ++keysReadSeq.current;
|
||||
const writeAtStart = keysWriteVersion.current;
|
||||
decryptKeys(migratedKeys).then((dec) => {
|
||||
if (seq === keysReadSeq.current && writeAtStart === keysWriteVersion.current)
|
||||
setKeys(dec);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_IDENTITIES) {
|
||||
const next = safeParse<Identity[]>(event.newValue) ?? [];
|
||||
setIdentities(next);
|
||||
++identitiesWriteVersion.current;
|
||||
const seq = ++identitiesReadSeq.current;
|
||||
const writeAtStart = identitiesWriteVersion.current;
|
||||
decryptIdentities(next).then((dec) => {
|
||||
if (seq === identitiesReadSeq.current && writeAtStart === identitiesWriteVersion.current)
|
||||
setIdentities(dec);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -407,6 +522,12 @@ export const useVaultState = () => {
|
||||
if (key === STORAGE_KEY_CONNECTION_LOGS) {
|
||||
const next = safeParse<ConnectionLog[]>(event.newValue) ?? [];
|
||||
setConnectionLogs(next);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_MANAGED_SOURCES) {
|
||||
const next = safeParse<ManagedSource[]>(event.newValue) ?? [];
|
||||
setManagedSources(next);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -420,7 +541,11 @@ export const useVaultState = () => {
|
||||
const next = prev.map((h) =>
|
||||
h.id === hostId ? { ...h, distro: normalized } : h,
|
||||
);
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, next);
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
encryptHosts(next).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
@@ -474,6 +599,7 @@ export const useVaultState = () => {
|
||||
knownHosts,
|
||||
shellHistory,
|
||||
connectionLogs,
|
||||
managedSources,
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
updateIdentities,
|
||||
@@ -481,6 +607,7 @@ export const useVaultState = () => {
|
||||
updateSnippetPackages,
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
addShellHistoryEntry,
|
||||
clearShellHistory,
|
||||
addConnectionLog,
|
||||
|
||||
@@ -32,6 +32,9 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useCloudSync } from '../application/state/useCloudSync';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../domain/credentials';
|
||||
import type { CloudProvider, ConflictInfo, SyncPayload, WebDAVAuthType, WebDAVConfig, S3Config } from '../domain/sync';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
@@ -482,7 +485,7 @@ const GitHubDeviceFlowModal: React.FC<GitHubDeviceFlowModalProps> = ({
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => window.open(verificationUri, '_blank')}
|
||||
onClick={() => window.open(verificationUri, "_blank", "noopener,noreferrer")}
|
||||
className="w-full gap-2 mb-4"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
@@ -751,6 +754,17 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// Clear local data dialog
|
||||
const [showClearLocalDialog, setShowClearLocalDialog] = useState(false);
|
||||
|
||||
const ensureSyncablePayload = useCallback(
|
||||
(payload: SyncPayload): boolean => {
|
||||
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
|
||||
if (encryptedCredentialPaths.length === 0) return true;
|
||||
|
||||
toast.error(t('sync.credentialsUnavailable'), t('sync.toast.errorTitle'));
|
||||
return false;
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// Handle conflict detection
|
||||
useEffect(() => {
|
||||
if (sync.currentConflict) {
|
||||
@@ -958,6 +972,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
const handleSync = async (provider: CloudProvider) => {
|
||||
try {
|
||||
const payload = onBuildPayload();
|
||||
if (!ensureSyncablePayload(payload)) return;
|
||||
const result = await sync.syncToProvider(provider, payload);
|
||||
|
||||
if (result.success) {
|
||||
@@ -982,6 +997,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
} else if (resolution === 'USE_LOCAL') {
|
||||
// Re-sync with local data
|
||||
const localPayload = onBuildPayload();
|
||||
if (!ensureSyncablePayload(localPayload)) return;
|
||||
await sync.syncNow(localPayload);
|
||||
toast.success(t('cloudSync.resolve.uploaded'));
|
||||
}
|
||||
@@ -1556,6 +1572,16 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
let payloadForReencrypt: SyncPayload | null = null;
|
||||
if (sync.hasAnyConnectedProvider) {
|
||||
const payload = onBuildPayload();
|
||||
if (!ensureSyncablePayload(payload)) {
|
||||
setChangeKeyError(t('sync.credentialsUnavailable'));
|
||||
return;
|
||||
}
|
||||
payloadForReencrypt = payload;
|
||||
}
|
||||
|
||||
setIsChangingKey(true);
|
||||
try {
|
||||
const ok = await sync.changeMasterKey(currentMasterKey, newMasterKey);
|
||||
@@ -1564,9 +1590,8 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (sync.hasAnyConnectedProvider) {
|
||||
const payload = onBuildPayload();
|
||||
await sync.syncNow(payload);
|
||||
if (payloadForReencrypt) {
|
||||
await sync.syncNow(payloadForReencrypt);
|
||||
}
|
||||
|
||||
toast.success(t('cloudSync.changeKey.updatedToast'));
|
||||
|
||||
@@ -4,9 +4,10 @@ import {
|
||||
Server,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Usb,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import React, { memo, useCallback, useMemo } from "react";
|
||||
import React, { memo, useCallback, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { cn } from "../lib/utils";
|
||||
import { ConnectionLog, Host } from "../types";
|
||||
@@ -63,6 +64,7 @@ interface LogItemProps {
|
||||
const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) => {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const isLocal = log.protocol === "local" || log.hostname === "localhost";
|
||||
const isSerial = log.protocol === "serial";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -92,14 +94,14 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className={cn(
|
||||
"h-8 w-8 rounded-lg flex items-center justify-center shrink-0",
|
||||
isLocal ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
|
||||
isSerial ? "bg-amber-500/10 text-amber-500" : isLocal ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
|
||||
)}>
|
||||
{isLocal ? <Terminal size={14} /> : <Server size={14} />}
|
||||
{isSerial ? <Usb size={14} /> : isLocal ? <Terminal size={14} /> : <Server size={14} />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{isLocal ? t("logs.localTerminal") : log.hostLabel}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{isLocal ? "local" : `${log.protocol}, ${log.username}`}
|
||||
{isLocal ? "local" : isSerial ? `serial, ${log.hostname}` : `${log.protocol}, ${log.username}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,7 +149,11 @@ const ConnectionLogsManager: React.FC<ConnectionLogsManagerProps> = ({
|
||||
onOpenLogView,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const RENDER_LIMIT = 100;
|
||||
const INITIAL_RENDER_LIMIT = 30;
|
||||
const LOAD_MORE_COUNT = 30;
|
||||
|
||||
// Track how many items to show
|
||||
const [renderLimit, setRenderLimit] = useState(INITIAL_RENDER_LIMIT);
|
||||
|
||||
// Sort logs by newest first
|
||||
const filteredLogs = useMemo(() => {
|
||||
@@ -155,10 +161,14 @@ const ConnectionLogsManager: React.FC<ConnectionLogsManagerProps> = ({
|
||||
}, [logs]);
|
||||
|
||||
const displayedLogs = useMemo(() => {
|
||||
return filteredLogs.slice(0, RENDER_LIMIT);
|
||||
}, [filteredLogs]);
|
||||
return filteredLogs.slice(0, renderLimit);
|
||||
}, [filteredLogs, renderLimit]);
|
||||
|
||||
const hasMore = filteredLogs.length > RENDER_LIMIT;
|
||||
const hasMore = filteredLogs.length > renderLimit;
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
setRenderLimit(prev => prev + LOAD_MORE_COUNT);
|
||||
}, []);
|
||||
|
||||
const handleToggleSaved = useCallback(
|
||||
(id: string) => onToggleSaved(id),
|
||||
@@ -220,9 +230,12 @@ const ConnectionLogsManager: React.FC<ConnectionLogsManagerProps> = ({
|
||||
<>
|
||||
{renderedItems}
|
||||
{hasMore && (
|
||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||
{t("logs.showing", { limit: RENDER_LIMIT, total: filteredLogs.length })}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
className="w-full py-3 text-sm text-primary hover:bg-secondary/50 transition-colors"
|
||||
>
|
||||
{t("logs.loadMore", { count: Math.min(LOAD_MORE_COUNT, filteredLogs.length - renderLimit) })}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
143
components/CreateWorkspaceDialog.tsx
Normal file
143
components/CreateWorkspaceDialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { Search } from 'lucide-react';
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { Host } from '../types';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
|
||||
interface CreateWorkspaceDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
hosts: Host[];
|
||||
onCreate: (name: string, selectedHosts: Host[]) => void;
|
||||
}
|
||||
|
||||
export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
hosts,
|
||||
onCreate,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [name, setName] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedHostIds, setSelectedHostIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const filteredHosts = useMemo(() => {
|
||||
if (!search.trim()) return hosts;
|
||||
const term = search.toLowerCase();
|
||||
return hosts.filter(h =>
|
||||
h.label.toLowerCase().includes(term) ||
|
||||
h.hostname.toLowerCase().includes(term) ||
|
||||
(h.group || '').toLowerCase().includes(term)
|
||||
);
|
||||
}, [hosts, search]);
|
||||
|
||||
const toggleHost = (hostId: string) => {
|
||||
setSelectedHostIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(hostId)) {
|
||||
next.delete(hostId);
|
||||
} else {
|
||||
next.add(hostId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
const selected = hosts.filter(h => selectedHostIds.has(h.id));
|
||||
onCreate(name, selected);
|
||||
onClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setName('');
|
||||
setSearch('');
|
||||
setSelectedHostIds(new Set());
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-md flex flex-col max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('dialog.createWorkspace.title', 'Create Workspace')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2 flex-1 flex flex-col min-h-0">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workspace-name">{t('field.name', 'Name')}</Label>
|
||||
<Input
|
||||
id="workspace-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('placeholder.workspaceName', 'Workspace Name')}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 flex-1 flex flex-col min-h-0">
|
||||
<Label>{t('field.selectHosts', 'Select Hosts')}</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t('placeholder.searchHosts', 'Search hosts...')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md flex-1 min-h-[200px]">
|
||||
<ScrollArea className="h-full max-h-[300px]">
|
||||
<div className="p-2 space-y-1">
|
||||
{filteredHosts.length === 0 ? (
|
||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||
{t('common.noResults', 'No hosts found')}
|
||||
</div>
|
||||
) : (
|
||||
filteredHosts.map(host => {
|
||||
const isSelected = selectedHostIds.has(host.id);
|
||||
return (
|
||||
<div
|
||||
key={host.id}
|
||||
className={`flex items-center gap-3 p-2 rounded-md cursor-pointer hover:bg-muted/50 ${isSelected ? 'bg-primary/10' : ''}`}
|
||||
onClick={() => toggleHost(host.id)}
|
||||
>
|
||||
<div className={`h-4 w-4 border rounded flex items-center justify-center ${isSelected ? 'bg-primary border-primary' : 'border-muted-foreground'}`}>
|
||||
{isSelected && <div className="h-2 w-2 bg-primary-foreground rounded-sm" />}
|
||||
</div>
|
||||
<DistroAvatar host={host} size="sm" fallback={host.label.slice(0, 2).toUpperCase()} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{host.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{host.hostname}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground text-right">
|
||||
{selectedHostIds.size} {t('common.selected', 'selected')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
|
||||
<Button onClick={handleCreate} disabled={!name.trim() || selectedHostIds.size === 0}>
|
||||
{t('common.create', 'Create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Server } from "lucide-react";
|
||||
import { Server, Usb } from "lucide-react";
|
||||
import React, { memo } from "react";
|
||||
import { normalizeDistroId } from "../domain/host";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -69,6 +69,21 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
const containerClass = sizeClasses[size];
|
||||
const iconSize = iconSizes[size];
|
||||
|
||||
// Show USB icon for serial hosts
|
||||
if (host.protocol === 'serial') {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
containerClass,
|
||||
"flex items-center justify-center bg-amber-500/15 text-amber-500",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Usb className={iconSize} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (logo && !errored) {
|
||||
return (
|
||||
<div
|
||||
|
||||
132
components/FileOpenerDialog.tsx
Normal file
132
components/FileOpenerDialog.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* FileOpenerDialog - Dialog for choosing how to open a file
|
||||
*/
|
||||
import { Edit2, FolderOpen } from 'lucide-react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import type { FileOpenerType, SystemAppInfo } from '../lib/sftpFileUtils';
|
||||
import { getFileExtension, isKnownBinaryFile } from '../lib/sftpFileUtils';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
|
||||
interface FileOpenerDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
fileName: string;
|
||||
onSelect: (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => void;
|
||||
onSelectSystemApp: () => Promise<SystemAppInfo | null>;
|
||||
}
|
||||
|
||||
export const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
fileName,
|
||||
onSelect,
|
||||
onSelectSystemApp,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isSelectingApp, setIsSelectingApp] = useState(false);
|
||||
const [rememberChoice, setRememberChoice] = useState(true);
|
||||
|
||||
const extension = getFileExtension(fileName);
|
||||
// Show edit option for files that are not known binary formats
|
||||
const canEdit = !isKnownBinaryFile(fileName);
|
||||
// For files without extension, we use 'file' as virtual extension
|
||||
// So we always allow setting default (hasExtension is always true)
|
||||
const displayExtension = extension === 'file' ? t('sftp.opener.noExtension') : `.${extension}`;
|
||||
|
||||
const handleSelectBuiltIn = useCallback((openerType: FileOpenerType) => {
|
||||
onSelect(openerType, rememberChoice);
|
||||
onClose();
|
||||
}, [rememberChoice, onSelect, onClose]);
|
||||
|
||||
const handleSelectSystemApp = useCallback(async () => {
|
||||
setIsSelectingApp(true);
|
||||
try {
|
||||
const result = await onSelectSystemApp();
|
||||
if (result) {
|
||||
console.log('[FileOpenerDialog] Calling onSelect with rememberChoice:', rememberChoice, 'result:', result);
|
||||
onSelect('system-app', rememberChoice, result);
|
||||
onClose();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to select application:', e);
|
||||
} finally {
|
||||
setIsSelectingApp(false);
|
||||
}
|
||||
}, [onSelectSystemApp, rememberChoice, onSelect, onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => {
|
||||
// Don't close while selecting system app
|
||||
if (!isOpen && !isSelectingApp) {
|
||||
onClose();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader className="min-w-0">
|
||||
<DialogTitle>{t('sftp.opener.title')}</DialogTitle>
|
||||
<DialogDescription className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{fileName}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-2">
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-12"
|
||||
onClick={() => handleSelectBuiltIn('builtin-editor')}
|
||||
>
|
||||
<Edit2 size={18} className="text-primary" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-sm">{t('sftp.opener.builtInEditor')}</div>
|
||||
<div className="text-xs text-muted-foreground">{t('sftp.opener.editDescription')}</div>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* System application option */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3 h-12"
|
||||
onClick={handleSelectSystemApp}
|
||||
disabled={isSelectingApp}
|
||||
>
|
||||
<FolderOpen size={18} className="text-primary" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-sm">{t('sftp.opener.systemApp')}</div>
|
||||
<div className="text-xs text-muted-foreground">{t('sftp.opener.systemAppDescription')}</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Remember choice checkbox - always show, use 'file' for no extension */}
|
||||
<div className="flex items-center gap-2 pb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remember-choice"
|
||||
checked={rememberChoice}
|
||||
onChange={(e) => setRememberChoice(e.target.checked)}
|
||||
className="rounded border-border h-4 w-4 accent-primary"
|
||||
/>
|
||||
<label
|
||||
htmlFor="remember-choice"
|
||||
className="text-sm text-muted-foreground cursor-pointer select-none"
|
||||
>
|
||||
{t('sftp.opener.setDefault', { ext: displayExtension })}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileOpenerDialog;
|
||||
@@ -16,6 +16,7 @@ interface GroupTreeItemProps {
|
||||
onEditGroup: (path: string) => void;
|
||||
onNewHost: (path: string) => void;
|
||||
onNewSubfolder: (path: string) => void;
|
||||
isManagedGroup?: (path: string) => boolean;
|
||||
}
|
||||
|
||||
export const GroupTreeItem: React.FC<GroupTreeItemProps> = ({
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FolderLock,
|
||||
FolderPlus,
|
||||
Forward,
|
||||
Globe,
|
||||
Key,
|
||||
KeyRound,
|
||||
Link2,
|
||||
MapPin,
|
||||
Palette,
|
||||
Plus,
|
||||
Settings2,
|
||||
Shield,
|
||||
Tag,
|
||||
TerminalSquare,
|
||||
User,
|
||||
Variable,
|
||||
Wifi,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useApplicationBackend } from "../application/state/useApplicationBackend";
|
||||
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
|
||||
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
|
||||
import { cn } from "../lib/utils";
|
||||
import { EnvVar, Host, Identity, ProxyConfig, SSHKey } from "../types";
|
||||
import { EnvVar, Host, Identity, ManagedSource, ProxyConfig, SSHKey } from "../types";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import ThemeSelectPanel from "./ThemeSelectPanel";
|
||||
import {
|
||||
@@ -28,11 +39,14 @@ import {
|
||||
} from "./ui/aside-panel";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { Card } from "./ui/card";
|
||||
import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
|
||||
import { Input } from "./ui/input";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
|
||||
// Import host-details sub-panels
|
||||
import {
|
||||
@@ -57,8 +71,10 @@ interface HostDetailsPanelProps {
|
||||
availableKeys: SSHKey[];
|
||||
identities: Identity[];
|
||||
groups: string[];
|
||||
managedSources?: ManagedSource[];
|
||||
allTags?: string[]; // All available tags for autocomplete
|
||||
allHosts?: Host[]; // All hosts for chain selection
|
||||
defaultGroup?: string | null; // Default group for new hosts (from current navigation)
|
||||
onSave: (host: Host) => void;
|
||||
onCancel: () => void;
|
||||
onCreateGroup?: (groupPath: string) => void; // Callback to create a new group
|
||||
@@ -70,14 +86,17 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
availableKeys,
|
||||
identities,
|
||||
groups,
|
||||
managedSources = [],
|
||||
allTags = [],
|
||||
allHosts = [],
|
||||
defaultGroup,
|
||||
onSave,
|
||||
onCancel,
|
||||
onCreateGroup,
|
||||
onCreateTag,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
const [form, setForm] = useState<Host>(
|
||||
() =>
|
||||
initialData ||
|
||||
@@ -90,11 +109,11 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
protocol: "ssh",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
agentForwarding: false,
|
||||
authMethod: "password",
|
||||
charset: "UTF-8",
|
||||
theme: "Flexoki Dark",
|
||||
createdAt: Date.now(),
|
||||
group: defaultGroup || undefined, // Pre-fill with current navigation group
|
||||
} as Host),
|
||||
);
|
||||
|
||||
@@ -109,10 +128,29 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
// Identity suggestion dropdown state (popover anchored to username input)
|
||||
const [identitySuggestionsOpen, setIdentitySuggestionsOpen] = useState(false);
|
||||
|
||||
// Password visibility state
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// New group creation state
|
||||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [newGroupParent, setNewGroupParent] = useState("");
|
||||
|
||||
// SSH Agent status for Windows (only checked when agentForwarding is enabled)
|
||||
const [sshAgentStatus, setSshAgentStatus] = useState<{
|
||||
running: boolean;
|
||||
startupType: string | null;
|
||||
error: string | null;
|
||||
} | null>(null);
|
||||
|
||||
// Check SSH Agent status when agentForwarding is toggled on (Windows only)
|
||||
useEffect(() => {
|
||||
if (form.agentForwarding) {
|
||||
checkSshAgent().then(setSshAgentStatus);
|
||||
} else {
|
||||
setSshAgentStatus(null);
|
||||
}
|
||||
}, [form.agentForwarding, checkSshAgent]);
|
||||
|
||||
// Group input state for inline creation suggestion
|
||||
const [groupInputValue, setGroupInputValue] = useState(form.group || "");
|
||||
|
||||
@@ -134,6 +172,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
}
|
||||
setForm(updatedData);
|
||||
setGroupInputValue(initialData.group || "");
|
||||
// Reset password visibility when host changes for privacy
|
||||
setShowPassword(false);
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
@@ -214,12 +254,51 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!form.hostname || !form.label) return;
|
||||
if (!form.hostname) return;
|
||||
// If label is empty, use hostname as label
|
||||
let finalLabel = form.label?.trim() || form.hostname;
|
||||
const finalGroup = groupInputValue.trim() || form.group || "";
|
||||
|
||||
// Find the most specific (deepest) managed source that matches the group path
|
||||
// This handles nested managed groups correctly by preferring exact matches
|
||||
// and longer paths over shorter prefix matches
|
||||
const targetManagedSource = managedSources
|
||||
.filter(s => finalGroup === s.groupName || finalGroup.startsWith(s.groupName + "/"))
|
||||
.sort((a, b) => b.groupName.length - a.groupName.length)[0];
|
||||
|
||||
// Only SSH hosts can be managed (SSH config only supports SSH protocol)
|
||||
const canBeManaged = !form.protocol || form.protocol === "ssh";
|
||||
|
||||
// Strip spaces from label only if host can be managed and is in a managed group
|
||||
// (SSH config requires no spaces in Host alias)
|
||||
if (targetManagedSource && canBeManaged) {
|
||||
finalLabel = finalLabel.replace(/\s/g, '');
|
||||
}
|
||||
|
||||
// Determine managedSourceId:
|
||||
// - Only SSH hosts can be managed (SSH config only supports SSH protocol)
|
||||
// - If we found a matching managed source, use its id
|
||||
// - If managedSources was not provided (empty array) and host already has managedSourceId, preserve it
|
||||
// - Otherwise, clear it (host is not in a managed group)
|
||||
let finalManagedSourceId: string | undefined;
|
||||
if (targetManagedSource && canBeManaged) {
|
||||
finalManagedSourceId = targetManagedSource.id;
|
||||
} else if (managedSources.length === 0 && form.managedSourceId && canBeManaged) {
|
||||
// managedSources not provided, preserve existing value
|
||||
finalManagedSourceId = form.managedSourceId;
|
||||
} else {
|
||||
finalManagedSourceId = undefined;
|
||||
}
|
||||
|
||||
const cleaned: Host = {
|
||||
...form,
|
||||
group: groupInputValue.trim() || form.group,
|
||||
label: finalLabel,
|
||||
group: finalGroup,
|
||||
tags: form.tags || [],
|
||||
port: form.port || 22,
|
||||
// Clear password if savePassword is explicitly set to false
|
||||
password: form.savePassword === false ? undefined : form.password,
|
||||
managedSourceId: finalManagedSourceId,
|
||||
};
|
||||
onSave(cleaned);
|
||||
};
|
||||
@@ -286,10 +365,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
const base = identities;
|
||||
const filtered = q
|
||||
? base.filter(
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
: base;
|
||||
return filtered.slice(0, 6);
|
||||
}, [form.username, identities, selectedIdentity]);
|
||||
@@ -469,7 +548,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleSubmit}
|
||||
disabled={!form.hostname || !form.label}
|
||||
disabled={!form.hostname}
|
||||
aria-label={t("hostDetails.saveAria")}
|
||||
>
|
||||
<Check size={16} />
|
||||
@@ -477,37 +556,31 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
}
|
||||
>
|
||||
<AsidePanelContent>
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.address")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<DistroAvatar
|
||||
host={form as Host}
|
||||
fallback={
|
||||
form.label?.slice(0, 2).toUpperCase() ||
|
||||
form.hostname?.slice(0, 2).toUpperCase() ||
|
||||
"H"
|
||||
}
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("hostDetails.hostname.placeholder")}
|
||||
value={form.hostname}
|
||||
onChange={(e) => update("hostname", e.target.value)}
|
||||
className="h-10 flex-1"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.general")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.general")}
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
placeholder={t("hostDetails.label.placeholder")}
|
||||
value={form.label}
|
||||
onChange={(e) => update("label", e.target.value)}
|
||||
onChange={(e) => {
|
||||
let value = e.target.value;
|
||||
// Only strip spaces if the TARGET group belongs to a managed source
|
||||
// (don't use form.managedSourceId as it reflects old state before group change)
|
||||
const targetGroup = groupInputValue.trim() || form.group || "";
|
||||
const willBeManaged = managedSources.some(s =>
|
||||
targetGroup === s.groupName || targetGroup.startsWith(s.groupName + "/")
|
||||
);
|
||||
// Also check protocol - only SSH hosts can be managed
|
||||
const canBeManaged = !form.protocol || form.protocol === "ssh";
|
||||
if (willBeManaged && canBeManaged) {
|
||||
value = value.replace(/\s/g, '');
|
||||
}
|
||||
update("label", value);
|
||||
}}
|
||||
className="h-10"
|
||||
/>
|
||||
|
||||
@@ -519,12 +592,16 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<Combobox
|
||||
options={groupOptions}
|
||||
value={form.group || ""}
|
||||
onValueChange={(val) => update("group", val)}
|
||||
onValueChange={(val) => {
|
||||
update("group", val);
|
||||
setGroupInputValue(val);
|
||||
}}
|
||||
placeholder={t("hostDetails.group.placeholder")}
|
||||
allowCreate={true}
|
||||
onCreateNew={(val) => {
|
||||
onCreateGroup?.(val);
|
||||
update("group", val);
|
||||
setGroupInputValue(val);
|
||||
}}
|
||||
createText="Create Group"
|
||||
triggerClassName="flex-1 h-10"
|
||||
@@ -549,10 +626,39 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.address")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DistroAvatar
|
||||
host={form as Host}
|
||||
fallback={
|
||||
form.label?.slice(0, 2).toUpperCase() ||
|
||||
form.hostname?.slice(0, 2).toUpperCase() ||
|
||||
"H"
|
||||
}
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("hostDetails.hostname.placeholder")}
|
||||
value={form.hostname}
|
||||
onChange={(e) => update("hostname", e.target.value)}
|
||||
className="h-10 flex-1"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.portCredentials")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.portCredentials")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0 h-10 flex items-center gap-2 bg-secondary/70 border border-border/70 rounded-md px-3">
|
||||
<span className="text-xs text-muted-foreground">SSH on</span>
|
||||
@@ -639,10 +745,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
const q = next.toLowerCase().trim();
|
||||
const matches = q
|
||||
? identities.filter(
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
: identities;
|
||||
setIdentitySuggestionsOpen(matches.length > 0);
|
||||
}}
|
||||
@@ -650,10 +756,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
const q = (form.username || "").toLowerCase().trim();
|
||||
const matches = q
|
||||
? identities.filter(
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
: identities;
|
||||
setIdentitySuggestionsOpen(matches.length > 0);
|
||||
}}
|
||||
@@ -670,10 +776,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
.trim();
|
||||
const matches = q
|
||||
? identities.filter(
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
: identities;
|
||||
return matches.length > 0;
|
||||
});
|
||||
@@ -702,8 +808,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
{filteredIdentitySuggestions.map((identity) => {
|
||||
const keyLabel = identity.keyId
|
||||
? availableKeys.find(
|
||||
(k) => k.id === identity.keyId,
|
||||
)?.label
|
||||
(k) => k.id === identity.keyId,
|
||||
)?.label
|
||||
: undefined;
|
||||
const methodLabel =
|
||||
identity.authMethod === "certificate"
|
||||
@@ -755,13 +861,36 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
)}
|
||||
|
||||
{!selectedIdentity && !form.identityId && (
|
||||
<Input
|
||||
placeholder={t("hostDetails.password.placeholder")}
|
||||
type="password"
|
||||
value={form.password || ""}
|
||||
onChange={(e) => update("password", e.target.value)}
|
||||
className="h-10"
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t("hostDetails.password.placeholder")}
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={form.password || ""}
|
||||
onChange={(e) => update("password", e.target.value)}
|
||||
className="h-10 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={showPassword ? t("hostDetails.password.hide") : t("hostDetails.password.show")}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save Password toggle - shown when password is entered */}
|
||||
{!selectedIdentity && !form.identityId && form.password && (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.password.save")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={form.savePassword ?? true}
|
||||
onCheckedChange={(val) => update("savePassword" as keyof Host, val)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected credential display */}
|
||||
@@ -850,42 +979,42 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
{!selectedIdentity &&
|
||||
selectedCredentialType === "key" &&
|
||||
!form.identityFileId && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Combobox
|
||||
options={keysByCategory.key.map((k) => ({
|
||||
value: k.id,
|
||||
label: k.label,
|
||||
sublabel: `${k.type}${k.keySize ? ` ${k.keySize}` : ""}`,
|
||||
icon: <Key size={14} className="text-muted-foreground" />,
|
||||
}))}
|
||||
value={form.identityFileId}
|
||||
onValueChange={(val) => {
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "key");
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.keys.search")}
|
||||
emptyText={t("hostDetails.keys.empty")}
|
||||
icon={<Key size={14} className="text-muted-foreground" />}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<Combobox
|
||||
options={keysByCategory.key.map((k) => ({
|
||||
value: k.id,
|
||||
label: k.label,
|
||||
sublabel: `${k.type}${k.keySize ? ` ${k.keySize}` : ""}`,
|
||||
icon: <Key size={14} className="text-muted-foreground" />,
|
||||
}))}
|
||||
value={form.identityFileId}
|
||||
onValueChange={(val) => {
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "key");
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.keys.search")}
|
||||
emptyText={t("hostDetails.keys.empty")}
|
||||
icon={<Key size={14} className="text-muted-foreground" />}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Certificate selection combobox - appears after selecting "Certificate" type */}
|
||||
{!selectedIdentity &&
|
||||
selectedCredentialType === "certificate" &&
|
||||
!form.identityFileId && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Combobox
|
||||
{!selectedIdentity &&
|
||||
selectedCredentialType === "certificate" &&
|
||||
!form.identityFileId && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Combobox
|
||||
options={keysByCategory.certificate.map((k) => ({
|
||||
value: k.id,
|
||||
label: k.label,
|
||||
@@ -913,16 +1042,68 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.appearance")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderLock size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.sftp")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium">
|
||||
{t("hostDetails.sftp.sudo")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.sftp.sudo.desc")}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.sftpSudo || false}
|
||||
onCheckedChange={(val) => update("sftpSudo", val)}
|
||||
/>
|
||||
</div>
|
||||
{form.sftpSudo && !form.password && !selectedIdentity?.password && (
|
||||
<p className="text-xs text-amber-500">
|
||||
{t("hostDetails.sftp.sudo.passwordWarning")}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">
|
||||
{t("hostDetails.sftp.encoding")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.sftp.encoding.desc")}
|
||||
</div>
|
||||
<Select
|
||||
value={form.sftpEncoding || "auto"}
|
||||
onValueChange={(val) => update("sftpEncoding", val as Host["sftpEncoding"])}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder={t("sftp.encoding.label")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("sftp.encoding.auto")}</SelectItem>
|
||||
<SelectItem value="utf-8">{t("sftp.encoding.utf8")}</SelectItem>
|
||||
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.appearance")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* SSH Theme Selection */}
|
||||
<button
|
||||
@@ -1009,7 +1190,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.mosh")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Wifi size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.mosh")}</p>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label="Mosh"
|
||||
enabled={!!form.moshEnabled}
|
||||
@@ -1017,75 +1201,109 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Agent Forwarding */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Forward size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.agentForwarding")}</p>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.agentForwarding")}
|
||||
enabled={!!form.agentForwarding}
|
||||
onToggle={() => update("agentForwarding", !form.agentForwarding)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Host Chain Configuration - Only show when Agent Forwarding is enabled */}
|
||||
{form.agentForwarding && (
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.jumpHosts")}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.agentForwarding.desc")}
|
||||
</p>
|
||||
{form.agentForwarding && sshAgentStatus && !sshAgentStatus.running && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 font-medium">
|
||||
{t("hostDetails.agentForwarding.agentNotRunning")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.agentForwarding.agentNotRunningHint")}
|
||||
</p>
|
||||
</div>
|
||||
{chainedHosts.length > 0 ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{t("hostDetails.jumpHosts.direct")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{chainedHosts.length > 0 && (
|
||||
<button
|
||||
className="w-full flex items-center gap-1 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer"
|
||||
onClick={() => setActiveSubPanel("chain")}
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.jumpHosts")}
|
||||
</p>
|
||||
</div>
|
||||
{chainedHosts.length > 0 ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<Link2
|
||||
size={14}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<span className="text-sm truncate">
|
||||
{chainedHosts
|
||||
.slice(0, 3)
|
||||
.map((h) => h.hostname || h.label)
|
||||
.join(" -> ")}
|
||||
{chainedHosts.length > 3 && "..."}
|
||||
</span>
|
||||
{t("hostDetails.jumpHosts.direct")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{chainedHosts.length > 0 && (
|
||||
<button
|
||||
className="w-full flex flex-col items-start gap-1 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer"
|
||||
onClick={() => setActiveSubPanel("chain")}
|
||||
>
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 min-w-0 flex-1">
|
||||
<Link2
|
||||
size={14}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
size={14}
|
||||
className="text-muted-foreground hover:text-destructive flex-shrink-0 ml-auto"
|
||||
className="text-muted-foreground hover:text-destructive flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearHostChain();
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{chainedHosts.length === 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full h-9 justify-start gap-2 text-sm"
|
||||
onClick={() => setActiveSubPanel("chain")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("hostDetails.jumpHosts.configure")}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full space-y-1 pl-5">
|
||||
{chainedHosts.slice(0, 5).map((h, idx) => (
|
||||
<div key={h.id} className="flex items-center gap-1 text-sm">
|
||||
<span className="text-muted-foreground">{idx + 1}.</span>
|
||||
<span className="truncate">
|
||||
{h.label !== h.hostname ? `${h.hostname} (${h.label})` : h.hostname}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{chainedHosts.length > 5 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
+{chainedHosts.length - 5} more...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{chainedHosts.length === 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full h-9 justify-start gap-2 text-sm"
|
||||
onClick={() => setActiveSubPanel("chain")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("hostDetails.jumpHosts.configure")}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Proxy Configuration */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
@@ -1167,11 +1385,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<TerminalSquare size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.startupCommand")}</p>
|
||||
</div>
|
||||
<Input
|
||||
<Textarea
|
||||
placeholder={t("hostDetails.startupCommand.placeholder")}
|
||||
value={form.startupCommand || ""}
|
||||
onChange={(e) => update("startupCommand", e.target.value)}
|
||||
className="h-9"
|
||||
className="min-h-[80px] font-mono text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.startupCommand.help")}
|
||||
@@ -1313,7 +1532,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<Button
|
||||
className="w-full h-10"
|
||||
onClick={handleSubmit}
|
||||
disabled={!form.hostname || !form.label}
|
||||
disabled={!form.hostname}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Switch } from "./ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -257,6 +258,52 @@ const HostForm: React.FC<HostFormProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between space-x-2 border rounded-md p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="sftp-sudo" className="text-base">
|
||||
{t("hostDetails.sftp.sudo")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.sftp.sudo.desc")}
|
||||
</p>
|
||||
{formData.sftpSudo && authType === "key" && (
|
||||
<p className="text-xs text-amber-500 mt-1">
|
||||
{t("hostDetails.sftp.sudo.passwordWarning")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
id="sftp-sudo"
|
||||
checked={formData.sftpSudo || false}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, sftpSudo: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="sftp-encoding">
|
||||
{t("hostDetails.sftp.encoding")}
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.sftpEncoding || "auto"}
|
||||
onValueChange={(val) =>
|
||||
setFormData({ ...formData, sftpEncoding: val as Host["sftpEncoding"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="sftp-encoding">
|
||||
<SelectValue placeholder={t("sftp.encoding.label")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("sftp.encoding.auto")}</SelectItem>
|
||||
<SelectItem value="utf-8">{t("sftp.encoding.utf8")}</SelectItem>
|
||||
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.sftp.encoding.desc")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Label>{t("hostForm.auth.method")}</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
|
||||
553
components/HostTreeView.tsx
Normal file
553
components/HostTreeView.tsx
Normal file
@@ -0,0 +1,553 @@
|
||||
import { CheckSquare, ChevronRight, FileSymlink, Folder, FolderOpen, Monitor, Server, Square, Expand, Minimize2 } from 'lucide-react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
|
||||
import { sanitizeHost } from '../domain/host';
|
||||
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
|
||||
import { cn } from '../lib/utils';
|
||||
import { GroupNode, Host } from '../types';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface HostTreeViewProps {
|
||||
groupTree: GroupNode[];
|
||||
hosts: Host[];
|
||||
sortMode?: 'az' | 'za' | 'newest' | 'oldest' | 'group';
|
||||
expandedPaths?: Set<string>;
|
||||
onTogglePath?: (path: string) => void;
|
||||
onExpandAll?: (paths: string[]) => void;
|
||||
onCollapseAll?: () => void;
|
||||
onConnect: (host: Host) => void;
|
||||
onEditHost: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
onNewHost: (groupPath?: string) => void;
|
||||
onNewGroup: (parentPath?: string) => void;
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
selectedHostIds?: Set<string>;
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: GroupNode;
|
||||
depth: number;
|
||||
sortMode: 'az' | 'za' | 'newest' | 'oldest' | 'group';
|
||||
expandedPaths: Set<string>;
|
||||
onToggle: (path: string) => void;
|
||||
onConnect: (host: Host) => void;
|
||||
onEditHost: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
onNewHost: (groupPath?: string) => void;
|
||||
onNewGroup: (parentPath?: string) => void;
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
selectedHostIds?: Set<string>;
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
}
|
||||
|
||||
const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
node,
|
||||
depth,
|
||||
sortMode,
|
||||
expandedPaths,
|
||||
onToggle,
|
||||
onConnect,
|
||||
onEditHost,
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
onNewHost,
|
||||
onNewGroup,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
toggleHostSelection,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
const hasChildren = node.children && Object.keys(node.children).length > 0;
|
||||
const paddingLeft = `${depth * 20 + 12}px`;
|
||||
const isManaged = managedGroupPaths?.has(node.path) ?? false;
|
||||
|
||||
const childNodes = useMemo(() => {
|
||||
if (!node.children) return [];
|
||||
const nodes = Object.values(node.children) as unknown as GroupNode[];
|
||||
return nodes.sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'za':
|
||||
return b.name.localeCompare(a.name);
|
||||
case 'newest':
|
||||
case 'oldest':
|
||||
// For groups, fall back to name sorting since groups don't have creation dates
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'az':
|
||||
default:
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
});
|
||||
}, [node.children, sortMode]);
|
||||
|
||||
const sortedHosts = useMemo(() => {
|
||||
return [...node.hosts].sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'az':
|
||||
return a.label.localeCompare(b.label);
|
||||
case 'za':
|
||||
return b.label.localeCompare(a.label);
|
||||
case 'newest':
|
||||
return (b.createdAt || 0) - (a.createdAt || 0);
|
||||
case 'oldest':
|
||||
return (a.createdAt || 0) - (b.createdAt || 0);
|
||||
default:
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
});
|
||||
}, [node.hosts, sortMode]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Group Node */}
|
||||
<Collapsible open={isExpanded} onOpenChange={() => onToggle(node.path)}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
draggable
|
||||
onDragStart={(e) => e.dataTransfer.setData("group-path", node.path)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, node.path);
|
||||
if (groupPath) moveGroup(groupPath, node.path);
|
||||
}}
|
||||
>
|
||||
<div className="mr-2 flex-shrink-0 w-4 h-4 flex items-center justify-center">
|
||||
{(hasChildren || node.hosts.length > 0) && (
|
||||
<div className={cn("transition-transform duration-200", isExpanded ? "rotate-90" : "")}>
|
||||
<ChevronRight size={14} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mr-3 text-primary/80 group-hover:text-primary transition-colors">
|
||||
{isExpanded ? <FolderOpen size={18} /> : <Folder size={18} />}
|
||||
</div>
|
||||
<span className="truncate flex-1 font-semibold">{node.name}</span>
|
||||
{isManaged && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/15 text-primary shrink-0 mr-1.5">
|
||||
<FileSymlink size={10} />
|
||||
Managed
|
||||
</span>
|
||||
)}
|
||||
{(node.hosts.length > 0 || hasChildren) && (
|
||||
<span className="text-xs opacity-70 bg-background/50 px-2 py-0.5 rounded-full border border-border">
|
||||
{node.hosts.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => onNewHost(node.path)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.newHost")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onNewGroup(node.path)}>
|
||||
<Folder className="mr-2 h-4 w-4" /> {t("vault.hosts.newGroup")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onEditGroup(node.path)}>
|
||||
<FolderOpen className="mr-2 h-4 w-4" /> {t("vault.groups.rename")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onDeleteGroup(node.path)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<FolderOpen className="mr-2 h-4 w-4" /> {t("vault.groups.delete")}
|
||||
</ContextMenuItem>
|
||||
{isManaged && onUnmanageGroup && (
|
||||
<ContextMenuItem onClick={() => onUnmanageGroup(node.path)}>
|
||||
<FileSymlink className="mr-2 h-4 w-4" /> {t("vault.managedSource.unmanage")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<CollapsibleContent>
|
||||
{/* Child Groups */}
|
||||
{childNodes.map((child) => (
|
||||
<TreeNode
|
||||
key={child.path}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
sortMode={sortMode}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggle={onToggle}
|
||||
onConnect={onConnect}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onNewHost={onNewHost}
|
||||
onNewGroup={onNewGroup}
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Hosts in this group */}
|
||||
{sortedHosts.map((host) => (
|
||||
<HostTreeItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
depth={depth + 1}
|
||||
onConnect={onConnect}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface HostTreeItemProps {
|
||||
host: Host;
|
||||
depth: number;
|
||||
onConnect: (host: Host) => void;
|
||||
onEditHost: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
selectedHostIds?: Set<string>;
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
}
|
||||
|
||||
const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
host,
|
||||
depth,
|
||||
onConnect,
|
||||
onEditHost,
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
moveHostToGroup: _moveHostToGroup,
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
toggleHostSelection,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const paddingLeft = `${depth * 20 + 12}px`;
|
||||
const safeHost = sanitizeHost(host);
|
||||
const tags = host.tags || [];
|
||||
const isTelnet = host.protocol === 'telnet';
|
||||
const displayUsername = isTelnet
|
||||
? (host.telnetUsername?.trim() || host.username?.trim() || '')
|
||||
: (host.username?.trim() || '');
|
||||
const displayPort = isTelnet
|
||||
? (host.telnetPort ?? host.port ?? 23)
|
||||
: (host.port ?? 22);
|
||||
const isSelected = isMultiSelectMode && selectedHostIds?.has(host.id);
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg",
|
||||
isSelected ? "bg-primary/10" : "",
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
draggable={!isMultiSelectMode}
|
||||
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
|
||||
onClick={() => {
|
||||
if (isMultiSelectMode && toggleHostSelection) {
|
||||
toggleHostSelection(host.id);
|
||||
} else {
|
||||
onConnect(safeHost);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isMultiSelectMode && (
|
||||
<div className="mr-2 flex-shrink-0" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleHostSelection?.(host.id);
|
||||
}}>
|
||||
{isSelected ? (
|
||||
<CheckSquare size={18} className="text-primary" />
|
||||
) : (
|
||||
<Square size={18} className="text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isMultiSelectMode && <div className="mr-2 flex-shrink-0 w-4 h-4" />}
|
||||
<div className="mr-3 flex-shrink-0">
|
||||
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="sm" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{host.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{displayUsername}@{host.hostname}:{displayPort}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{host.protocol && host.protocol !== 'ssh' && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">
|
||||
{host.protocol.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
{tags.length > 0 && (
|
||||
<span className="text-xs opacity-60">
|
||||
{tags.slice(0, 2).join(', ')}
|
||||
{tags.length > 2 && '...'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => onConnect(safeHost)}>
|
||||
<Monitor className="mr-2 h-4 w-4" /> {t("vault.hosts.connect")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onEditHost(host)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("action.edit")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onDuplicateHost(host)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("action.duplicate")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onCopyCredentials(host)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.copyCredentials")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onDeleteHost(host)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("action.delete")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
groupTree,
|
||||
hosts,
|
||||
sortMode = 'az',
|
||||
expandedPaths: externalExpandedPaths,
|
||||
onTogglePath: externalOnTogglePath,
|
||||
onExpandAll: externalOnExpandAll,
|
||||
onCollapseAll: externalOnCollapseAll,
|
||||
onConnect,
|
||||
onEditHost,
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
onNewHost,
|
||||
onNewGroup,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
toggleHostSelection,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
// Use external state if provided, otherwise use local persistent state
|
||||
const localTreeState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
|
||||
|
||||
const expandedPaths = externalExpandedPaths || localTreeState.expandedPaths;
|
||||
const togglePath = externalOnTogglePath || localTreeState.togglePath;
|
||||
const expandAll = externalOnExpandAll || localTreeState.expandAll;
|
||||
const collapseAll = externalOnCollapseAll || localTreeState.collapseAll;
|
||||
|
||||
// Get all possible group paths for expand/collapse all functionality
|
||||
const getAllGroupPaths = (nodes: GroupNode[]): string[] => {
|
||||
const paths: string[] = [];
|
||||
const traverse = (nodeList: GroupNode[]) => {
|
||||
nodeList.forEach(node => {
|
||||
paths.push(node.path);
|
||||
if (node.children) {
|
||||
traverse(Object.values(node.children) as GroupNode[]);
|
||||
}
|
||||
});
|
||||
};
|
||||
traverse(nodes);
|
||||
return paths;
|
||||
};
|
||||
|
||||
const allGroupPaths = useMemo(() => getAllGroupPaths(groupTree), [groupTree]);
|
||||
|
||||
const handleExpandAll = () => {
|
||||
expandAll(allGroupPaths);
|
||||
};
|
||||
|
||||
const handleCollapseAll = () => {
|
||||
collapseAll();
|
||||
};
|
||||
|
||||
// Get ungrouped hosts (hosts without a group or with empty group) and sort them
|
||||
const ungroupedHosts = useMemo(() => {
|
||||
const hosts_without_group = hosts.filter(host => !host.group || host.group === '');
|
||||
return hosts_without_group.sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'az':
|
||||
return a.label.localeCompare(b.label);
|
||||
case 'za':
|
||||
return b.label.localeCompare(a.label);
|
||||
case 'newest':
|
||||
return (b.createdAt || 0) - (a.createdAt || 0);
|
||||
case 'oldest':
|
||||
return (a.createdAt || 0) - (b.createdAt || 0);
|
||||
default:
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
});
|
||||
}, [hosts, sortMode]);
|
||||
|
||||
// Sort group tree based on sort mode
|
||||
const sortedGroupTree = useMemo(() => {
|
||||
return [...groupTree].sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'za':
|
||||
return b.name.localeCompare(a.name);
|
||||
case 'newest':
|
||||
case 'oldest':
|
||||
// For groups, fall back to name sorting since groups don't have creation dates
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'az':
|
||||
default:
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
});
|
||||
}, [groupTree, sortMode]);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{/* Expand/Collapse controls */}
|
||||
{groupTree.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border/30">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleExpandAll}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Expand size={12} className="mr-1" />
|
||||
{t("vault.tree.expandAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCollapseAll}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Minimize2 size={12} className="mr-1" />
|
||||
{t("vault.tree.collapseAll")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Group tree */}
|
||||
{sortedGroupTree.map((node) => (
|
||||
<TreeNode
|
||||
key={node.path}
|
||||
node={node}
|
||||
depth={0}
|
||||
sortMode={sortMode}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggle={togglePath}
|
||||
onConnect={onConnect}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onNewHost={onNewHost}
|
||||
onNewGroup={onNewGroup}
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Ungrouped hosts at root level */}
|
||||
{ungroupedHosts.map((host) => (
|
||||
<HostTreeItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
depth={0}
|
||||
onConnect={onConnect}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Empty state */}
|
||||
{ungroupedHosts.length === 0 && groupTree.length === 0 && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Server size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">{t("vault.hosts.empty")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
200
components/KeyboardInteractiveModal.tsx
Normal file
200
components/KeyboardInteractiveModal.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Keyboard Interactive Authentication Modal
|
||||
* Global modal for handling SSH keyboard-interactive authentication (2FA/MFA)
|
||||
* This modal displays prompts from the SSH server and collects user responses.
|
||||
*/
|
||||
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
export interface KeyboardInteractivePrompt {
|
||||
prompt: string;
|
||||
echo: boolean;
|
||||
}
|
||||
|
||||
export interface KeyboardInteractiveRequest {
|
||||
requestId: string;
|
||||
name: string;
|
||||
instructions: string;
|
||||
prompts: KeyboardInteractivePrompt[];
|
||||
hostname?: string;
|
||||
savedPassword?: string | null;
|
||||
}
|
||||
|
||||
interface KeyboardInteractiveModalProps {
|
||||
request: KeyboardInteractiveRequest | null;
|
||||
onSubmit: (requestId: string, responses: string[]) => void;
|
||||
onCancel: (requestId: string) => void;
|
||||
}
|
||||
|
||||
export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> = ({
|
||||
request,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [responses, setResponses] = useState<string[]>([]);
|
||||
const [showPasswords, setShowPasswords] = useState<boolean[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Reset state when request changes
|
||||
useEffect(() => {
|
||||
if (request) {
|
||||
setResponses(request.prompts.map(() => ""));
|
||||
setShowPasswords(request.prompts.map(() => false));
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
const handleResponseChange = useCallback((index: number, value: string) => {
|
||||
setResponses((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[index] = value;
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleShowPassword = useCallback((index: number) => {
|
||||
setShowPasswords((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[index] = !updated[index];
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!request || isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
onSubmit(request.requestId, responses);
|
||||
}, [request, responses, onSubmit, isSubmitting]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (!request) return;
|
||||
onCancel(request.requestId);
|
||||
}, [request, onCancel]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !isSubmitting) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit, isSubmitting]
|
||||
);
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
const title = request.name?.trim() || t("keyboard.interactive.title");
|
||||
const description =
|
||||
request.instructions?.trim() ||
|
||||
(request.hostname
|
||||
? t("keyboard.interactive.descWithHost", { hostname: request.hostname })
|
||||
: t("keyboard.interactive.desc"));
|
||||
|
||||
return (
|
||||
<Dialog open={!!request} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="sm:max-w-[425px]" hideCloseButton>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<KeyRound className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{request.prompts.map((prompt, index) => {
|
||||
const isPassword = !prompt.echo;
|
||||
const showPassword = showPasswords[index];
|
||||
// Clean up prompt text (remove trailing colon and whitespace)
|
||||
const promptLabel = prompt.prompt.replace(/:\s*$/, "").trim();
|
||||
|
||||
return (
|
||||
<div key={index} className="space-y-2">
|
||||
<Label htmlFor={`ki-prompt-${index}`}>
|
||||
{promptLabel || t("keyboard.interactive.response")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={`ki-prompt-${index}`}
|
||||
type={isPassword && !showPassword ? "password" : "text"}
|
||||
value={responses[index] || ""}
|
||||
onChange={(e) => handleResponseChange(index, e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder=""
|
||||
className={isPassword ? "pr-10" : undefined}
|
||||
autoFocus={index === 0}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{isPassword && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50 p-1"
|
||||
onClick={() => toggleShowPassword(index)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Use saved password button - shown below input, right-aligned */}
|
||||
{isPassword && request.savedPassword && !responses[index] && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 disabled:opacity-50"
|
||||
onClick={() => handleResponseChange(index, request.savedPassword!)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<KeyRound size={12} />
|
||||
<span>{t("keyboard.interactive.useSavedPassword")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("keyboard.interactive.verifying")}
|
||||
</>
|
||||
) : (
|
||||
t("keyboard.interactive.submit")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyboardInteractiveModal;
|
||||
@@ -23,6 +23,7 @@ import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/stora
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, Identity, KeyType, SSHKey } from "../types";
|
||||
import { ManagedSource } from "../domain/models";
|
||||
import { useKeychainBackend } from "../application/state/useKeychainBackend";
|
||||
import SelectHostPanel from "./SelectHostPanel";
|
||||
import {
|
||||
@@ -68,6 +69,7 @@ interface KeychainManagerProps {
|
||||
identities?: Identity[];
|
||||
hosts?: Host[];
|
||||
customGroups?: string[];
|
||||
managedSources?: ManagedSource[];
|
||||
onSave: (key: SSHKey) => void;
|
||||
onUpdate: (key: SSHKey) => void;
|
||||
onDelete: (id: string) => void;
|
||||
@@ -83,6 +85,7 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
|
||||
identities = [],
|
||||
hosts = [],
|
||||
customGroups = [],
|
||||
managedSources = [],
|
||||
onSave,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
@@ -913,6 +916,8 @@ echo $3 >> "$FILE"`);
|
||||
<ImportKeyPanel
|
||||
draftKey={draftKey}
|
||||
setDraftKey={setDraftKey}
|
||||
showPassphrase={showPassphrase}
|
||||
setShowPassphrase={setShowPassphrase}
|
||||
onImport={handleImport}
|
||||
/>
|
||||
)}
|
||||
@@ -1111,6 +1116,8 @@ echo $3 >> "$FILE"`);
|
||||
privateKey: hostPrivateKey,
|
||||
command,
|
||||
timeout: 30000,
|
||||
enableKeyboardInteractive: true,
|
||||
sessionId: `export-key:${exportHost.id}:${panel.key.id}`,
|
||||
});
|
||||
|
||||
// Check result - code 0, null, or undefined with no stderr is success
|
||||
@@ -1282,6 +1289,7 @@ echo $3 >> "$FILE"`);
|
||||
onBack={() => setShowHostSelector(false)}
|
||||
onContinue={() => setShowHostSelector(false)}
|
||||
availableKeys={keys}
|
||||
managedSources={managedSources}
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={onCreateGroup}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { FileText, Palette, X } from "lucide-react";
|
||||
import { FileText, Download, Palette, X } from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -34,6 +34,7 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
// Use log's saved theme/fontSize or fall back to defaults
|
||||
const currentTheme = useMemo(() => {
|
||||
@@ -67,6 +68,30 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
onUpdateLog(log.id, { fontSize });
|
||||
}, [log.id, onUpdateLog]);
|
||||
|
||||
// Handle export
|
||||
const handleExport = useCallback(async () => {
|
||||
if (!log.terminalData || isExporting) return;
|
||||
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const { netcattyBridge } = await import("../infrastructure/services/netcattyBridge");
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.exportSessionLog) {
|
||||
await bridge.exportSessionLog({
|
||||
terminalData: log.terminalData,
|
||||
hostLabel: log.hostLabel,
|
||||
hostname: log.hostname,
|
||||
startTime: log.startTime,
|
||||
format: 'txt',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to export session log:', err);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, [log.terminalData, log.hostLabel, log.hostname, log.startTime, isExporting]);
|
||||
|
||||
// Initialize terminal
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !isVisible) return;
|
||||
@@ -216,6 +241,21 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Export button */}
|
||||
{log.terminalData && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 h-8 px-2"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
title={t("logView.export")}
|
||||
>
|
||||
<Download size={14} />
|
||||
<span className="text-xs">{t("logView.export")}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Theme & font customization button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
169
components/PassphraseModal.tsx
Normal file
169
components/PassphraseModal.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Passphrase Modal
|
||||
* Modal for requesting passphrase for encrypted SSH keys
|
||||
*/
|
||||
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
export interface PassphraseRequest {
|
||||
requestId: string;
|
||||
keyPath: string;
|
||||
keyName: string;
|
||||
hostname?: string;
|
||||
}
|
||||
|
||||
interface PassphraseModalProps {
|
||||
request: PassphraseRequest | null;
|
||||
onSubmit: (requestId: string, passphrase: string) => void;
|
||||
onCancel: (requestId: string) => void;
|
||||
onSkip?: (requestId: string) => void;
|
||||
}
|
||||
|
||||
export const PassphraseModal: React.FC<PassphraseModalProps> = ({
|
||||
request,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onSkip,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [showPassphrase, setShowPassphrase] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Reset state when request changes
|
||||
useEffect(() => {
|
||||
if (request) {
|
||||
setPassphrase("");
|
||||
setShowPassphrase(false);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!request || isSubmitting || !passphrase) return;
|
||||
setIsSubmitting(true);
|
||||
onSubmit(request.requestId, passphrase);
|
||||
}, [request, passphrase, onSubmit, isSubmitting]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (!request) return;
|
||||
onCancel(request.requestId);
|
||||
}, [request, onCancel]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
if (!request || !onSkip) return;
|
||||
onSkip(request.requestId);
|
||||
}, [request, onSkip]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !isSubmitting && passphrase) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit, isSubmitting, passphrase]
|
||||
);
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
const keyDisplayName = request.keyName || request.keyPath.split("/").pop() || "SSH Key";
|
||||
|
||||
return (
|
||||
<Dialog open={!!request} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="sm:max-w-[425px]" hideCloseButton>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<KeyRound className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>{t("passphrase.title")}</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
{request.hostname
|
||||
? t("passphrase.descWithHost", { keyName: keyDisplayName, hostname: request.hostname })
|
||||
: t("passphrase.desc", { keyName: keyDisplayName })}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="passphrase-input">
|
||||
{t("passphrase.label")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="passphrase-input"
|
||||
type={showPassphrase ? "text" : "password"}
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder=""
|
||||
className="pr-10"
|
||||
autoFocus
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50 p-1"
|
||||
onClick={() => setShowPassphrase(!showPassphrase)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{showPassphrase ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("passphrase.keyPath")}: <code className="text-xs">{request.keyPath}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
{onSkip && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleSkip}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("passphrase.skip")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting || !passphrase}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("passphrase.unlocking")}
|
||||
</>
|
||||
) : (
|
||||
t("passphrase.unlock")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default PassphraseModal;
|
||||
@@ -15,6 +15,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import {
|
||||
Host,
|
||||
ManagedSource,
|
||||
PortForwardingRule,
|
||||
PortForwardingType,
|
||||
SSHKey,
|
||||
@@ -64,6 +65,7 @@ interface PortForwardingProps {
|
||||
keys: SSHKey[];
|
||||
identities?: import('../domain/models').Identity[];
|
||||
customGroups: string[];
|
||||
managedSources?: ManagedSource[];
|
||||
onNewHost?: () => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
onCreateGroup?: (groupPath: string) => void;
|
||||
@@ -74,6 +76,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
keys,
|
||||
identities = [],
|
||||
customGroups: _customGroups,
|
||||
managedSources = [],
|
||||
onNewHost: _onNewHost,
|
||||
onSaveHost,
|
||||
onCreateGroup: _onCreateGroup,
|
||||
@@ -138,6 +141,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
);
|
||||
}
|
||||
},
|
||||
rule.autoStart, // Enable reconnect for auto-start rules
|
||||
);
|
||||
// Show error from result only if not already shown
|
||||
if (!result.success && result.error && !errorShown) {
|
||||
@@ -697,6 +701,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
<RuleCard
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
host={hosts.find((h) => h.id === rule.hostId)}
|
||||
viewMode={viewMode}
|
||||
isSelected={selectedRuleId === rule.id}
|
||||
isPending={pendingOperations.has(rule.id)}
|
||||
@@ -843,6 +848,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
onContinue={() => setShowHostSelector(false)}
|
||||
availableKeys={keys}
|
||||
identities={identities}
|
||||
managedSources={managedSources}
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={_onCreateGroup}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Terminal,
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
import React, { memo, useEffect, useRef, useState } from "react";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Host, TerminalSession, Workspace } from "../types";
|
||||
import { KeyBinding } from "../domain/models";
|
||||
@@ -17,10 +17,45 @@ type QuickSwitcherItem = {
|
||||
data?: Host | TerminalSession | Workspace;
|
||||
};
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
|
||||
// Compute once at module level
|
||||
const IS_MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
// Memoized host item component to prevent unnecessary re-renders
|
||||
const HostItem = memo(({
|
||||
host,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onMouseEnter,
|
||||
}: {
|
||||
host: Host;
|
||||
isSelected: boolean;
|
||||
onSelect: (host: Host) => void;
|
||||
onMouseEnter: () => void;
|
||||
}) => (
|
||||
<div
|
||||
className={`flex items-center justify-between px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => onSelect(host)}
|
||||
onMouseEnter={onMouseEnter}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.label.slice(0, 2).toUpperCase()}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="text-sm font-medium truncate">{host.label}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{host.group ? `Personal / ${host.group}` : "Personal"}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
HostItem.displayName = "HostItem";
|
||||
|
||||
interface QuickSwitcherProps {
|
||||
isOpen: boolean;
|
||||
query: string;
|
||||
@@ -32,7 +67,7 @@ interface QuickSwitcherProps {
|
||||
onSelectTab: (tabId: string) => void;
|
||||
onClose: () => void;
|
||||
onCreateLocalTerminal?: () => void;
|
||||
onCreateWorkspace?: () => void;
|
||||
// onCreateWorkspace removed - feature not currently used
|
||||
keyBindings?: KeyBinding[];
|
||||
}
|
||||
|
||||
@@ -47,19 +82,16 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
onSelectTab,
|
||||
onClose,
|
||||
onCreateLocalTerminal,
|
||||
onCreateWorkspace,
|
||||
keyBindings,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
// Get hotkey display strings
|
||||
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
const getHotkeyLabel = (actionId: string) => {
|
||||
const getHotkeyLabel = useCallback((actionId: string) => {
|
||||
const binding = keyBindings?.find(k => k.id === actionId);
|
||||
if (!binding) return '';
|
||||
return isMac ? binding.mac : binding.pc;
|
||||
};
|
||||
return IS_MAC ? binding.mac : binding.pc;
|
||||
}, [keyBindings]);
|
||||
const quickSwitchKey = getHotkeyLabel('quick-switch');
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -67,7 +99,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
// Reset state when opening
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setIsFocused(false);
|
||||
setSelectedIndex(0);
|
||||
// Auto focus the input after a short delay
|
||||
setTimeout(() => {
|
||||
@@ -93,15 +124,17 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
// Memoize orphan sessions
|
||||
const orphanSessions = useMemo(
|
||||
() => sessions.filter((s) => !s.workspaceId),
|
||||
[sessions]
|
||||
);
|
||||
|
||||
const showCategorized = isFocused || query.trim().length > 0;
|
||||
// Always show categorized view (Hosts/Tabs/Quick connect)
|
||||
const showCategorized = true;
|
||||
|
||||
// Get orphan sessions (sessions without workspace)
|
||||
const orphanSessions = sessions.filter((s) => !s.workspaceId);
|
||||
|
||||
// Build categorized items for navigation
|
||||
const buildFlatItems = () => {
|
||||
// Memoize flat items list and index map
|
||||
const { flatItems, itemIndexMap } = useMemo(() => {
|
||||
const items: QuickSwitcherItem[] = [];
|
||||
|
||||
if (showCategorized) {
|
||||
@@ -127,10 +160,21 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
// Build index map for O(1) lookup
|
||||
const indexMap = new Map<string, number>();
|
||||
items.forEach((item, idx) => {
|
||||
indexMap.set(`${item.type}:${item.id}`, idx);
|
||||
});
|
||||
|
||||
const flatItems = buildFlatItems();
|
||||
return { flatItems: items, itemIndexMap: indexMap };
|
||||
}, [showCategorized, results, orphanSessions, workspaces]);
|
||||
|
||||
// O(1) index lookup
|
||||
const getItemIndex = useCallback((type: string, id: string) => {
|
||||
return itemIndexMap.get(`${type}:${id}`) ?? -1;
|
||||
}, [itemIndexMap]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
@@ -165,40 +209,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to get item index in flat list
|
||||
const getItemIndex = (type: string, id: string) => {
|
||||
return flatItems.findIndex((item) => item.type === type && item.id === id);
|
||||
};
|
||||
|
||||
const renderHostItem = (host: Host) => {
|
||||
const idx = getItemIndex("host", host.id);
|
||||
const isSelected = idx === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={host.id}
|
||||
className={`flex items-center justify-between px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelect(host);
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.label.slice(0, 2).toUpperCase()}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="text-sm font-medium truncate">{host.label}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{host.group ? `Personal / ${host.group}` : "Personal"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-x-0 top-12 z-50 flex justify-center pt-2"
|
||||
@@ -219,7 +229,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
onQueryChange(e.target.value);
|
||||
setSelectedIndex(0);
|
||||
}}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t("qs.search.placeholder")}
|
||||
className="flex-1 h-8 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 px-0 text-sm"
|
||||
@@ -232,193 +241,163 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 h-full">
|
||||
{!showCategorized ? (
|
||||
/* Default view: Recent connections with header */
|
||||
<div>
|
||||
<div className="px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("qs.recentConnections")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 px-2 text-[11px]"
|
||||
onClick={() => onCreateWorkspace?.()}
|
||||
>
|
||||
{t("qs.createWorkspace")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 px-2 text-[11px]"
|
||||
disabled
|
||||
>
|
||||
{t("qs.restore")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{results.length > 0 ? (
|
||||
results.map(renderHostItem)
|
||||
) : (
|
||||
<div className="px-4 py-6 text-sm text-muted-foreground text-center">
|
||||
No recent connections
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Categorized view: Hosts/Tabs/Quick connect */}
|
||||
<div>
|
||||
{/* Jump To hint */}
|
||||
<div className="px-4 py-2 flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{t("qs.jumpTo")}</span>
|
||||
{quickSwitchKey && (
|
||||
<kbd className="text-[10px] text-muted-foreground bg-muted px-1 py-0.5 rounded">
|
||||
{quickSwitchKey.replace(/ \+ /g, '+')}
|
||||
</kbd>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Focused/searching view: Categorized items */
|
||||
|
||||
{/* Hosts section */}
|
||||
{results.length > 0 && (
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Hosts
|
||||
</span>
|
||||
</div>
|
||||
{results.map((host) => (
|
||||
<HostItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
isSelected={getItemIndex("host", host.id) === selectedIndex}
|
||||
onSelect={onSelect}
|
||||
onMouseEnter={() => setSelectedIndex(getItemIndex("host", host.id))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs section */}
|
||||
<div>
|
||||
{/* Jump To hint */}
|
||||
<div className="px-4 py-2 flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{t("qs.jumpTo")}</span>
|
||||
{quickSwitchKey && (
|
||||
<kbd className="text-[10px] text-muted-foreground bg-muted px-1 py-0.5 rounded">
|
||||
{quickSwitchKey.replace(/ \+ /g, '+')}
|
||||
</kbd>
|
||||
)}
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Tabs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Hosts section */}
|
||||
{results.length > 0 && (
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Hosts
|
||||
{/* Built-in tabs */}
|
||||
{["vault", "sftp"].map((tabId) => {
|
||||
const idx = getItemIndex("tab", tabId);
|
||||
const isSelected = idx === selectedIndex;
|
||||
const icon =
|
||||
tabId === "vault" ? (
|
||||
<Shield size={16} />
|
||||
) : (
|
||||
<Folder size={16} />
|
||||
);
|
||||
const label = tabId === "vault" ? "Vaults" : "SFTP";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tabId}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectTab(tabId);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
{icon}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Workspaces */}
|
||||
{workspaces.map((workspace) => {
|
||||
const idx = getItemIndex("workspace", workspace.id);
|
||||
const isSelected = idx === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workspace.id}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectTab(workspace.id);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
<LayoutGrid size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{workspace.title}
|
||||
</span>
|
||||
</div>
|
||||
{results.map(renderHostItem)}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Orphan sessions */}
|
||||
{orphanSessions.map((session) => {
|
||||
const idx = getItemIndex("tab", session.id);
|
||||
const isSelected = idx === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectTab(session.id);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
<TerminalSquare size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{session.hostLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Quick connect section */}
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Quick connect
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Local Terminal */}
|
||||
{onCreateLocalTerminal && (
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${getItemIndex("action", "local-terminal") === selectedIndex
|
||||
? "bg-primary/15"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onCreateLocalTerminal();
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setSelectedIndex(getItemIndex("action", "local-terminal"))
|
||||
}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
<Terminal size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">{t("qs.localTerminal")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs section */}
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Tabs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Built-in tabs */}
|
||||
{["vault", "sftp"].map((tabId) => {
|
||||
const idx = getItemIndex("tab", tabId);
|
||||
const isSelected = idx === selectedIndex;
|
||||
const icon =
|
||||
tabId === "vault" ? (
|
||||
<Shield size={16} />
|
||||
) : (
|
||||
<Folder size={16} />
|
||||
);
|
||||
const label = tabId === "vault" ? "Vaults" : "SFTP";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tabId}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectTab(tabId);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
{icon}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Workspaces */}
|
||||
{workspaces.map((workspace) => {
|
||||
const idx = getItemIndex("workspace", workspace.id);
|
||||
const isSelected = idx === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workspace.id}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectTab(workspace.id);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
<LayoutGrid size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{workspace.title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Orphan sessions */}
|
||||
{orphanSessions.map((session) => {
|
||||
const idx = getItemIndex("tab", session.id);
|
||||
const isSelected = idx === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectTab(session.id);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
<TerminalSquare size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{session.hostLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Quick connect section */}
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Quick connect
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Local Terminal */}
|
||||
{onCreateLocalTerminal && (
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${getItemIndex("action", "local-terminal") === selectedIndex
|
||||
? "bg-primary/15"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onCreateLocalTerminal();
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setSelectedIndex(getItemIndex("action", "local-terminal"))
|
||||
}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
<Terminal size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">{t("qs.localTerminal")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Serial removed (not supported) */}
|
||||
</div>
|
||||
{/* Serial removed (not supported) */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ import React, { useMemo, useState } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Host, SSHKey } from "../types";
|
||||
import { ManagedSource } from "../domain/models";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import HostDetailsPanel from "./HostDetailsPanel";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -31,6 +32,7 @@ interface SelectHostPanelProps {
|
||||
// Props for inline host creation
|
||||
availableKeys?: SSHKey[];
|
||||
identities?: import('../domain/models').Identity[];
|
||||
managedSources?: ManagedSource[];
|
||||
onSaveHost?: (host: Host) => void;
|
||||
onCreateGroup?: (groupPath: string) => void;
|
||||
title?: string;
|
||||
@@ -49,6 +51,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
onNewHost,
|
||||
availableKeys = [],
|
||||
identities = [],
|
||||
managedSources = [],
|
||||
onSaveHost,
|
||||
onCreateGroup,
|
||||
title,
|
||||
@@ -63,21 +66,26 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [showNewHostPanel, setShowNewHostPanel] = useState(false);
|
||||
|
||||
const selectableHosts = useMemo(
|
||||
() => hosts.filter((host) => host.protocol !== "serial"),
|
||||
[hosts]
|
||||
);
|
||||
|
||||
// Get all unique tags from hosts
|
||||
const allTags = useMemo(() => {
|
||||
const tagSet = new Set<string>();
|
||||
hosts.forEach((h) => {
|
||||
selectableHosts.forEach((h) => {
|
||||
if (h.tags) {
|
||||
h.tags.forEach((tag) => tagSet.add(tag));
|
||||
}
|
||||
});
|
||||
return Array.from(tagSet).sort();
|
||||
}, [hosts]);
|
||||
}, [selectableHosts]);
|
||||
|
||||
// Get unique group paths from both hosts and customGroups
|
||||
const allGroupPaths = useMemo(() => {
|
||||
const pathSet = new Set<string>();
|
||||
hosts.forEach((h) => {
|
||||
selectableHosts.forEach((h) => {
|
||||
if (h.group) {
|
||||
// Add all parent paths as well
|
||||
const parts = h.group.split("/");
|
||||
@@ -88,7 +96,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
});
|
||||
customGroups.forEach((g) => pathSet.add(g));
|
||||
return Array.from(pathSet).sort();
|
||||
}, [hosts, customGroups]);
|
||||
}, [selectableHosts, customGroups]);
|
||||
|
||||
// Get groups at current level
|
||||
const groupsWithCounts = useMemo(() => {
|
||||
@@ -102,7 +110,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
const topLevel = path.split("/")[0];
|
||||
if (!seen.has(topLevel)) {
|
||||
seen.add(topLevel);
|
||||
const count = hosts.filter(
|
||||
const count = selectableHosts.filter(
|
||||
(h) =>
|
||||
h.group &&
|
||||
(h.group === topLevel || h.group.startsWith(`${topLevel}/`)),
|
||||
@@ -116,7 +124,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
const fullPath = `${prefix}${nextLevel}`;
|
||||
if (!seen.has(fullPath)) {
|
||||
seen.add(fullPath);
|
||||
const count = hosts.filter(
|
||||
const count = selectableHosts.filter(
|
||||
(h) =>
|
||||
h.group &&
|
||||
(h.group === fullPath || h.group.startsWith(`${fullPath}/`)),
|
||||
@@ -127,11 +135,11 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [allGroupPaths, currentPath, hosts]);
|
||||
}, [allGroupPaths, currentPath, selectableHosts]);
|
||||
|
||||
// Get hosts at current level with filtering and sorting
|
||||
const filteredHosts = useMemo(() => {
|
||||
let result = hosts;
|
||||
let result = selectableHosts;
|
||||
|
||||
// Filter by current path
|
||||
if (currentPath) {
|
||||
@@ -177,7 +185,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [hosts, currentPath, searchQuery, selectedTags, sortMode]);
|
||||
}, [selectableHosts, currentPath, searchQuery, selectedTags, sortMode]);
|
||||
|
||||
// Build breadcrumb from current path
|
||||
const breadcrumbs = useMemo(() => {
|
||||
@@ -356,7 +364,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{host.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{host.protocol || "ssh"}, {host.username}
|
||||
{host.username}@{host.hostname}:{host.port || 22}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
@@ -387,7 +395,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
if (onContinue) {
|
||||
onContinue();
|
||||
} else {
|
||||
const host = hosts.find((h) => selectedHostIds.includes(h.id));
|
||||
const host = selectableHosts.find((h) => selectedHostIds.includes(h.id));
|
||||
if (host) {
|
||||
onSelect(host);
|
||||
}
|
||||
@@ -407,6 +415,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
availableKeys={availableKeys}
|
||||
identities={identities}
|
||||
groups={customGroups}
|
||||
managedSources={managedSources}
|
||||
allHosts={hosts}
|
||||
onSave={(host) => {
|
||||
onSaveHost(host);
|
||||
|
||||
428
components/SerialConnectModal.tsx
Normal file
428
components/SerialConnectModal.tsx
Normal file
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* Serial Port Connect Modal
|
||||
* Allows users to configure and connect to a serial port
|
||||
*/
|
||||
import { ChevronDown, ChevronUp, Cpu, RefreshCw, Save, Usb } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useTerminalBackend } from '../application/state/useTerminalBackend';
|
||||
import type { Host, SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
import { Combobox, type ComboboxOption } from './ui/combobox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
|
||||
interface SerialPort {
|
||||
path: string;
|
||||
manufacturer: string;
|
||||
serialNumber: string;
|
||||
vendorId: string;
|
||||
productId: string;
|
||||
pnpId: string;
|
||||
type?: 'hardware' | 'pseudo' | 'custom';
|
||||
}
|
||||
|
||||
interface SerialConnectModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConnect: (config: SerialConfig) => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
}
|
||||
|
||||
const BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600];
|
||||
const DATA_BITS: Array<5 | 6 | 7 | 8> = [5, 6, 7, 8];
|
||||
const STOP_BITS: Array<1 | 1.5 | 2> = [1, 1.5, 2];
|
||||
const PARITY_OPTIONS: SerialParity[] = ['none', 'even', 'odd', 'mark', 'space'];
|
||||
const FLOW_CONTROL_OPTIONS: SerialFlowControl[] = ['none', 'xon/xoff', 'rts/cts'];
|
||||
|
||||
export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onConnect,
|
||||
onSaveHost,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [ports, setPorts] = useState<SerialPort[]>([]);
|
||||
const [isLoadingPorts, setIsLoadingPorts] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [selectedPort, setSelectedPort] = useState('');
|
||||
const [baudRate, setBaudRate] = useState(115200);
|
||||
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(8);
|
||||
const [stopBits, setStopBits] = useState<1 | 1.5 | 2>(1);
|
||||
const [parity, setParity] = useState<SerialParity>('none');
|
||||
const [flowControl, setFlowControl] = useState<SerialFlowControl>('none');
|
||||
const [localEcho, setLocalEcho] = useState(false);
|
||||
const [lineMode, setLineMode] = useState(false);
|
||||
|
||||
// Save configuration state
|
||||
const [saveConfig, setSaveConfig] = useState(false);
|
||||
const [configLabel, setConfigLabel] = useState('');
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
|
||||
const loadPorts = useCallback(async () => {
|
||||
setIsLoadingPorts(true);
|
||||
try {
|
||||
const result = await terminalBackend.listSerialPorts();
|
||||
setPorts(result);
|
||||
// Auto-select first port if available and no port is selected
|
||||
if (result.length > 0) {
|
||||
setSelectedPort((prev) => prev || result[0].path);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Serial] Failed to list ports:', err);
|
||||
} finally {
|
||||
setIsLoadingPorts(false);
|
||||
}
|
||||
}, [terminalBackend]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadPorts();
|
||||
}
|
||||
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Generate a default label when port is selected
|
||||
useEffect(() => {
|
||||
if (selectedPort && !configLabel) {
|
||||
const portName = selectedPort.split('/').pop() || selectedPort;
|
||||
setConfigLabel(`Serial: ${portName}`);
|
||||
}
|
||||
}, [selectedPort, configLabel]);
|
||||
|
||||
const handleConnect = () => {
|
||||
if (!selectedPort) return;
|
||||
|
||||
const config: SerialConfig = {
|
||||
path: selectedPort,
|
||||
baudRate,
|
||||
dataBits,
|
||||
stopBits,
|
||||
parity,
|
||||
flowControl,
|
||||
localEcho,
|
||||
lineMode,
|
||||
};
|
||||
|
||||
// Save as host if checkbox is checked and onSaveHost is provided
|
||||
if (saveConfig && onSaveHost) {
|
||||
const portName = selectedPort.split('/').pop() || selectedPort;
|
||||
const host: Host = {
|
||||
id: `serial-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
|
||||
label: configLabel.trim() || `Serial: ${portName}`,
|
||||
hostname: selectedPort,
|
||||
// For serial hosts, port field stores baud rate as a numeric identifier.
|
||||
// The full configuration is stored in serialConfig for actual connection.
|
||||
port: baudRate,
|
||||
username: '',
|
||||
os: 'linux',
|
||||
tags: ['serial'],
|
||||
protocol: 'serial',
|
||||
createdAt: Date.now(),
|
||||
serialConfig: config, // Store full serial configuration for connection
|
||||
};
|
||||
onSaveHost(host);
|
||||
}
|
||||
|
||||
onConnect(config);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Convert ports to Combobox options
|
||||
const portOptions: ComboboxOption[] = useMemo(() => {
|
||||
return ports.map((port) => ({
|
||||
value: port.path,
|
||||
label: port.path,
|
||||
sublabel: port.manufacturer || undefined,
|
||||
}));
|
||||
}, [ports]);
|
||||
|
||||
// Validate: port path must start with /dev/ (Unix/macOS) or COM/\\.\COM (Windows)
|
||||
const trimmedPort = selectedPort.trim();
|
||||
const isPortValid =
|
||||
trimmedPort.startsWith('/dev/') ||
|
||||
/^COM\d+$/i.test(trimmedPort) ||
|
||||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
|
||||
// Allow custom baud rates as long as they are positive integers
|
||||
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
|
||||
|
||||
// Check if using 1.5 stop bits (limited Windows support)
|
||||
const isStopBits15 = stopBits === 1.5;
|
||||
const isValid = isPortValid && isBaudRateValid;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Usb size={18} />
|
||||
{t('serial.modal.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('serial.modal.desc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Serial Port Selection */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="serial-port">{t('serial.field.port')}</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadPorts}
|
||||
disabled={isLoadingPorts}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
<RefreshCw size={12} className={cn("mr-1", isLoadingPorts && "animate-spin")} />
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Combobox for port selection with manual input support */}
|
||||
<Combobox
|
||||
options={portOptions}
|
||||
value={selectedPort}
|
||||
onValueChange={setSelectedPort}
|
||||
placeholder={t('serial.field.selectPort')}
|
||||
emptyText={t('serial.noPorts')}
|
||||
allowCreate
|
||||
createText={t('common.use')}
|
||||
icon={<Usb size={14} className="text-muted-foreground" />}
|
||||
/>
|
||||
|
||||
{!isPortValid && selectedPort && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t('serial.field.customPortPlaceholder')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Baud Rate */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
|
||||
<Combobox
|
||||
options={BAUD_RATES.map((rate) => ({
|
||||
value: String(rate),
|
||||
label: String(rate),
|
||||
}))}
|
||||
value={String(baudRate)}
|
||||
onValueChange={(val) => {
|
||||
const parsed = parseInt(val, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
setBaudRate(parsed);
|
||||
}
|
||||
}}
|
||||
placeholder={t('serial.field.baudRatePlaceholder')}
|
||||
emptyText={t('serial.field.baudRateEmpty')}
|
||||
allowCreate
|
||||
createText={t('common.use')}
|
||||
/>
|
||||
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.customBaudRate')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced Options */}
|
||||
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between h-9 px-0 hover:bg-transparent"
|
||||
>
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t('common.advanced')}
|
||||
</span>
|
||||
{showAdvanced ? (
|
||||
<ChevronUp size={14} className="text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown size={14} className="text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
{/* Data Bits */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
|
||||
<select
|
||||
id="data-bits"
|
||||
value={dataBits}
|
||||
onChange={(e) => setDataBits(parseInt(e.target.value, 10) as 5 | 6 | 7 | 8)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{DATA_BITS.map((bits) => (
|
||||
<option key={bits} value={bits}>
|
||||
{bits}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Stop Bits */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
|
||||
<select
|
||||
id="stop-bits"
|
||||
value={stopBits}
|
||||
onChange={(e) => setStopBits(parseFloat(e.target.value) as 1 | 1.5 | 2)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{STOP_BITS.map((bits) => (
|
||||
<option key={bits} value={bits}>
|
||||
{bits}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isStopBits15 && (
|
||||
<p className="text-xs text-yellow-500">
|
||||
{t('serial.field.stopBits15Warning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parity */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
|
||||
<select
|
||||
id="parity"
|
||||
value={parity}
|
||||
onChange={(e) => setParity(e.target.value as SerialParity)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{PARITY_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{t(`serial.parity.${option}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Flow Control */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
|
||||
<select
|
||||
id="flow-control"
|
||||
value={flowControl}
|
||||
onChange={(e) => setFlowControl(e.target.value as SerialFlowControl)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{FLOW_CONTROL_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{t(`serial.flowControl.${option}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Terminal Options */}
|
||||
<div className="space-y-3 pt-2 border-t border-border/60">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="local-echo" className="text-sm font-medium cursor-pointer">
|
||||
{t('serial.field.localEcho')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.localEchoDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="local-echo"
|
||||
checked={localEcho}
|
||||
onChange={(e) => setLocalEcho(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="line-mode" className="text-sm font-medium cursor-pointer">
|
||||
{t('serial.field.lineMode')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.lineModeDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="line-mode"
|
||||
checked={lineMode}
|
||||
onChange={(e) => setLineMode(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Save Configuration */}
|
||||
{onSaveHost && (
|
||||
<div className="space-y-3 pt-2 border-t border-border/60">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="save-config" className="text-sm font-medium cursor-pointer">
|
||||
{t('serial.field.saveConfig')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.saveConfigDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="save-config"
|
||||
checked={saveConfig}
|
||||
onChange={(e) => setSaveConfig(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
{saveConfig && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="config-label">{t('serial.field.configLabel')}</Label>
|
||||
<Input
|
||||
id="config-label"
|
||||
value={configLabel}
|
||||
onChange={(e) => setConfigLabel(e.target.value)}
|
||||
placeholder={t('serial.field.configLabelPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleConnect} disabled={!isValid}>
|
||||
{saveConfig ? (
|
||||
<Save size={14} className="mr-2" />
|
||||
) : (
|
||||
<Cpu size={14} className="mr-2" />
|
||||
)}
|
||||
{saveConfig ? t('serial.connectAndSave') : t('common.connect')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default SerialConnectModal;
|
||||
415
components/SerialHostDetailsPanel.tsx
Normal file
415
components/SerialHostDetailsPanel.tsx
Normal file
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Serial Host Details Panel
|
||||
* A dedicated editor for serial port hosts (distinct from SSH HostDetailsPanel)
|
||||
*/
|
||||
import { ChevronDown, ChevronUp, Save, Tag, Usb } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useTerminalBackend } from '../application/state/useTerminalBackend';
|
||||
import type { Host, SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
|
||||
|
||||
import { Button } from './ui/button';
|
||||
import { Combobox, ComboboxOption, MultiCombobox } from './ui/combobox';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
AsidePanelFooter,
|
||||
} from './ui/aside-panel';
|
||||
|
||||
interface SerialPort {
|
||||
path: string;
|
||||
manufacturer: string;
|
||||
serialNumber: string;
|
||||
vendorId: string;
|
||||
productId: string;
|
||||
pnpId: string;
|
||||
type?: 'hardware' | 'pseudo' | 'custom';
|
||||
}
|
||||
|
||||
interface SerialHostDetailsPanelProps {
|
||||
initialData: Host;
|
||||
allTags?: string[];
|
||||
groups?: string[];
|
||||
onSave: (host: Host) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600];
|
||||
const DATA_BITS: Array<5 | 6 | 7 | 8> = [5, 6, 7, 8];
|
||||
const STOP_BITS: Array<1 | 1.5 | 2> = [1, 1.5, 2];
|
||||
const PARITY_OPTIONS: SerialParity[] = ['none', 'even', 'odd', 'mark', 'space'];
|
||||
const FLOW_CONTROL_OPTIONS: SerialFlowControl[] = ['none', 'xon/xoff', 'rts/cts'];
|
||||
|
||||
export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
|
||||
initialData,
|
||||
allTags = [],
|
||||
groups = [],
|
||||
onSave,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const [ports, setPorts] = useState<SerialPort[]>([]);
|
||||
const [isLoadingPorts, setIsLoadingPorts] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [label, setLabel] = useState(initialData.label);
|
||||
const [selectedPort, setSelectedPort] = useState(initialData.hostname || initialData.serialConfig?.path || '');
|
||||
const [baudRate, setBaudRate] = useState(initialData.serialConfig?.baudRate || initialData.port || 115200);
|
||||
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(initialData.serialConfig?.dataBits || 8);
|
||||
const [stopBits, setStopBits] = useState<1 | 1.5 | 2>(initialData.serialConfig?.stopBits || 1);
|
||||
const [parity, setParity] = useState<SerialParity>(initialData.serialConfig?.parity || 'none');
|
||||
const [flowControl, setFlowControl] = useState<SerialFlowControl>(initialData.serialConfig?.flowControl || 'none');
|
||||
const [localEcho, setLocalEcho] = useState(initialData.serialConfig?.localEcho || false);
|
||||
const [lineMode, setLineMode] = useState(initialData.serialConfig?.lineMode || false);
|
||||
const [tags, setTags] = useState<string[]>(initialData.tags || []);
|
||||
const [group, setGroup] = useState(initialData.group || '');
|
||||
|
||||
const loadPorts = useCallback(async () => {
|
||||
setIsLoadingPorts(true);
|
||||
try {
|
||||
const result = await terminalBackend.listSerialPorts();
|
||||
setPorts(result);
|
||||
} catch (err) {
|
||||
console.error('[Serial] Failed to list ports:', err);
|
||||
} finally {
|
||||
setIsLoadingPorts(false);
|
||||
}
|
||||
}, [terminalBackend]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPorts();
|
||||
}, [loadPorts]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!selectedPort) return;
|
||||
|
||||
const config: SerialConfig = {
|
||||
path: selectedPort,
|
||||
baudRate,
|
||||
dataBits,
|
||||
stopBits,
|
||||
parity,
|
||||
flowControl,
|
||||
localEcho,
|
||||
lineMode,
|
||||
};
|
||||
|
||||
const portName = selectedPort.split('/').pop() || selectedPort;
|
||||
const updatedHost: Host = {
|
||||
...initialData,
|
||||
label: label.trim() || `Serial: ${portName}`,
|
||||
hostname: selectedPort,
|
||||
port: baudRate,
|
||||
tags,
|
||||
group,
|
||||
serialConfig: config,
|
||||
};
|
||||
|
||||
onSave(updatedHost);
|
||||
};
|
||||
|
||||
// Convert ports to Combobox options
|
||||
const portOptions: ComboboxOption[] = useMemo(() => {
|
||||
return ports.map((port) => ({
|
||||
value: port.path,
|
||||
label: port.path,
|
||||
sublabel: port.manufacturer || undefined,
|
||||
}));
|
||||
}, [ports]);
|
||||
|
||||
// Tag options for MultiCombobox
|
||||
const tagOptions: ComboboxOption[] = useMemo(() => {
|
||||
const allUniqueTags = new Set([...allTags, ...tags]);
|
||||
return Array.from(allUniqueTags).map((tag) => ({
|
||||
value: tag,
|
||||
label: tag,
|
||||
}));
|
||||
}, [allTags, tags]);
|
||||
|
||||
// Group options for Combobox
|
||||
const groupOptions: ComboboxOption[] = useMemo(() => {
|
||||
const allGroups = new Set(groups);
|
||||
if (group && !allGroups.has(group)) {
|
||||
allGroups.add(group);
|
||||
}
|
||||
return Array.from(allGroups).map((g) => ({
|
||||
value: g,
|
||||
label: g,
|
||||
}));
|
||||
}, [groups, group]);
|
||||
|
||||
// Validation
|
||||
const trimmedPort = selectedPort.trim();
|
||||
const isPortValid =
|
||||
trimmedPort.startsWith('/dev/') ||
|
||||
/^COM\d+$/i.test(trimmedPort) ||
|
||||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
|
||||
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
|
||||
const isValid = isPortValid && isBaudRateValid;
|
||||
|
||||
// Check if using 1.5 stop bits (limited Windows support)
|
||||
const isStopBits15 = stopBits === 1.5;
|
||||
|
||||
return (
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={onCancel}
|
||||
title={t('serial.edit.title')}
|
||||
subtitle={initialData.label}
|
||||
className="z-40"
|
||||
>
|
||||
<AsidePanelContent>
|
||||
{/* Label */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="serial-label">{t('serial.field.configLabel')}</Label>
|
||||
<Input
|
||||
id="serial-label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t('serial.field.configLabelPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Serial Port */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="serial-port">{t('serial.field.port')}</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadPorts}
|
||||
disabled={isLoadingPorts}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
<Combobox
|
||||
options={portOptions}
|
||||
value={selectedPort}
|
||||
onValueChange={setSelectedPort}
|
||||
placeholder={t('serial.field.selectPort')}
|
||||
emptyText={t('serial.noPorts')}
|
||||
allowCreate
|
||||
createText={t('common.use')}
|
||||
icon={<Usb size={14} className="text-muted-foreground" />}
|
||||
/>
|
||||
{!isPortValid && selectedPort && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t('serial.field.customPortPlaceholder')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Baud Rate */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
|
||||
<Combobox
|
||||
options={BAUD_RATES.map((rate) => ({
|
||||
value: String(rate),
|
||||
label: String(rate),
|
||||
}))}
|
||||
value={String(baudRate)}
|
||||
onValueChange={(val) => {
|
||||
const parsed = parseInt(val, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
setBaudRate(parsed);
|
||||
}
|
||||
}}
|
||||
placeholder={t('serial.field.baudRatePlaceholder')}
|
||||
emptyText={t('serial.field.baudRateEmpty')}
|
||||
allowCreate
|
||||
createText={t('common.use')}
|
||||
/>
|
||||
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.customBaudRate')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Tag size={14} />
|
||||
{t('hostDetails.tags')}
|
||||
</Label>
|
||||
<MultiCombobox
|
||||
options={tagOptions}
|
||||
values={tags}
|
||||
onValuesChange={setTags}
|
||||
placeholder={t('hostDetails.addTag')}
|
||||
allowCreate
|
||||
createText={t('hostDetails.createTag')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Group */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('hostDetails.group')}</Label>
|
||||
<Combobox
|
||||
options={groupOptions}
|
||||
value={group}
|
||||
onValueChange={setGroup}
|
||||
placeholder={t('hostDetails.selectGroup')}
|
||||
allowCreate
|
||||
createText={t('hostDetails.createGroup')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced Options */}
|
||||
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between h-9 px-0 hover:bg-transparent"
|
||||
>
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t('common.advanced')}
|
||||
</span>
|
||||
{showAdvanced ? (
|
||||
<ChevronUp size={14} className="text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown size={14} className="text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
{/* Data Bits */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
|
||||
<select
|
||||
id="data-bits"
|
||||
value={dataBits}
|
||||
onChange={(e) => setDataBits(parseInt(e.target.value, 10) as 5 | 6 | 7 | 8)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{DATA_BITS.map((bits) => (
|
||||
<option key={bits} value={bits}>
|
||||
{bits}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Stop Bits */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
|
||||
<select
|
||||
id="stop-bits"
|
||||
value={stopBits}
|
||||
onChange={(e) => setStopBits(parseFloat(e.target.value) as 1 | 1.5 | 2)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{STOP_BITS.map((bits) => (
|
||||
<option key={bits} value={bits}>
|
||||
{bits}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isStopBits15 && (
|
||||
<p className="text-xs text-yellow-500">
|
||||
{t('serial.field.stopBits15Warning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parity */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
|
||||
<select
|
||||
id="parity"
|
||||
value={parity}
|
||||
onChange={(e) => setParity(e.target.value as SerialParity)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{PARITY_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{t(`serial.parity.${option}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Flow Control */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
|
||||
<select
|
||||
id="flow-control"
|
||||
value={flowControl}
|
||||
onChange={(e) => setFlowControl(e.target.value as SerialFlowControl)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{FLOW_CONTROL_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{t(`serial.flowControl.${option}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Terminal Options */}
|
||||
<div className="space-y-3 pt-2 border-t border-border/60">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="local-echo" className="text-sm font-medium cursor-pointer">
|
||||
{t('serial.field.localEcho')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.localEchoDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="local-echo"
|
||||
checked={localEcho}
|
||||
onChange={(e) => setLocalEcho(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="line-mode" className="text-sm font-medium cursor-pointer">
|
||||
{t('serial.field.lineMode')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.lineModeDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="line-mode"
|
||||
checked={lineMode}
|
||||
onChange={(e) => setLineMode(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</AsidePanelContent>
|
||||
|
||||
<AsidePanelFooter>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onCancel} className="flex-1">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!isValid} className="flex-1">
|
||||
<Save size={14} className="mr-2" />
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</AsidePanelFooter>
|
||||
</AsidePanel>
|
||||
);
|
||||
};
|
||||
|
||||
export default SerialHostDetailsPanel;
|
||||
@@ -2,7 +2,7 @@
|
||||
* Settings Page - Standalone settings window content
|
||||
* This component is rendered in a separate Electron window
|
||||
*/
|
||||
import { AppWindow, Cloud, Keyboard, Palette, TerminalSquare, X } from "lucide-react";
|
||||
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, TerminalSquare, X } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { useVaultState } from "../application/state/useVaultState";
|
||||
@@ -10,13 +10,18 @@ import { useWindowControls } from "../application/state/useWindowControls";
|
||||
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
|
||||
import SettingsApplicationTab from "./SettingsApplicationTab";
|
||||
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
|
||||
import SettingsFileAssociationsTab from "./settings/tabs/SettingsFileAssociationsTab";
|
||||
import SettingsShortcutsTab from "./settings/tabs/SettingsShortcutsTab";
|
||||
import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
|
||||
import SettingsSystemTab from "./settings/tabs/SettingsSystemTab";
|
||||
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
import type { TerminalFont } from "../infrastructure/config/fonts";
|
||||
|
||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
type SettingsState = ReturnType<typeof useSettingsState>;
|
||||
type SettingsState = ReturnType<typeof useSettingsState> & {
|
||||
availableFonts: TerminalFont[];
|
||||
};
|
||||
|
||||
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
|
||||
|
||||
@@ -66,7 +71,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
}, [closeSettingsWindow]);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-background text-foreground">
|
||||
<div className="h-screen flex flex-col bg-background text-foreground font-sans">
|
||||
<div className="shrink-0 border-b border-border app-drag">
|
||||
<div className="flex items-center justify-between px-4 pt-3">
|
||||
{isMac && <div className="h-6" />}
|
||||
@@ -117,12 +122,24 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
>
|
||||
<Keyboard size={14} /> {t("settings.tab.shortcuts")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="file-associations"
|
||||
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
|
||||
>
|
||||
<FileType size={14} /> {t("settings.tab.sftpFileAssociations")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="sync"
|
||||
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
|
||||
>
|
||||
<Cloud size={14} /> {t("settings.tab.syncCloud")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="system"
|
||||
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
|
||||
>
|
||||
<HardDrive size={14} /> {t("settings.tab.system")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
@@ -141,6 +158,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setAccentMode={settings.setAccentMode}
|
||||
customAccent={settings.customAccent}
|
||||
setCustomAccent={settings.setCustomAccent}
|
||||
uiFontFamilyId={settings.uiFontFamilyId}
|
||||
setUiFontFamilyId={settings.setUiFontFamilyId}
|
||||
uiLanguage={settings.uiLanguage}
|
||||
setUiLanguage={settings.setUiLanguage}
|
||||
customCSS={settings.customCSS}
|
||||
@@ -158,6 +177,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setTerminalFontSize={settings.setTerminalFontSize}
|
||||
terminalSettings={settings.terminalSettings}
|
||||
updateTerminalSetting={settings.updateTerminalSetting}
|
||||
availableFonts={settings.availableFonts}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -173,11 +193,31 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
/>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("file-associations") && (
|
||||
<SettingsFileAssociationsTab />
|
||||
)}
|
||||
|
||||
{mountedTabs.has("sync") && (
|
||||
<React.Suspense fallback={null}>
|
||||
<SettingsSyncTabWithVault />
|
||||
</React.Suspense>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("system") && (
|
||||
<SettingsSystemTab
|
||||
sessionLogsEnabled={settings.sessionLogsEnabled}
|
||||
setSessionLogsEnabled={settings.setSessionLogsEnabled}
|
||||
sessionLogsDir={settings.sessionLogsDir}
|
||||
setSessionLogsDir={settings.setSessionLogsDir}
|
||||
sessionLogsFormat={settings.sessionLogsFormat}
|
||||
setSessionLogsFormat={settings.setSessionLogsFormat}
|
||||
toggleWindowHotkey={settings.toggleWindowHotkey}
|
||||
setToggleWindowHotkey={settings.setToggleWindowHotkey}
|
||||
closeToTray={settings.closeToTray}
|
||||
setCloseToTray={settings.setCloseToTray}
|
||||
hotkeyRegistrationError={settings.hotkeyRegistrationError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,11 @@
|
||||
import { Check, ChevronDown, Clock, Copy, Edit2, FileCode, FolderPlus, LayoutGrid, List as ListIcon, Loader2, Package, Play, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Check, ChevronDown, Clock, Copy, Edit2, FileCode, FolderPlus, Keyboard, LayoutGrid, List as ListIcon, Loader2, Package, Play, Plus, RotateCcw, Search, Trash2 } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useStoredViewMode } from '../application/state/useStoredViewMode';
|
||||
import { STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE } from '../infrastructure/config/storageKeys';
|
||||
import { cn } from '../lib/utils';
|
||||
import { cn, isMacPlatform } from '../lib/utils';
|
||||
import { Host, ShellHistoryEntry, Snippet, SSHKey } from '../types';
|
||||
import { HotkeyScheme, KeyBinding, keyEventToString, ManagedSource, matchesKeyBinding, parseKeyCombo } from '../domain/models';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import SelectHostPanel from './SelectHostPanel';
|
||||
import { AsidePanel, AsidePanelContent } from './ui/aside-panel';
|
||||
@@ -24,12 +25,15 @@ interface SnippetsManagerProps {
|
||||
hosts: Host[];
|
||||
customGroups?: string[];
|
||||
shellHistory: ShellHistoryEntry[];
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
onSave: (snippet: Snippet) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onPackagesChange: (packages: string[]) => void;
|
||||
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
|
||||
// Props for inline host creation
|
||||
availableKeys?: SSHKey[];
|
||||
managedSources?: ManagedSource[];
|
||||
onSaveHost?: (host: Host) => void;
|
||||
onCreateGroup?: (groupPath: string) => void;
|
||||
}
|
||||
@@ -44,11 +48,14 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
hosts,
|
||||
customGroups = [],
|
||||
shellHistory,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
onSave,
|
||||
onDelete,
|
||||
onPackagesChange,
|
||||
onRunSnippet,
|
||||
availableKeys = [],
|
||||
managedSources = [],
|
||||
onSaveHost,
|
||||
onCreateGroup,
|
||||
}) => {
|
||||
@@ -67,6 +74,12 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
const [newPackageName, setNewPackageName] = useState('');
|
||||
const [isPackageDialogOpen, setIsPackageDialogOpen] = useState(false);
|
||||
|
||||
// Rename package state
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const [renamingPackagePath, setRenamingPackagePath] = useState<string | null>(null);
|
||||
const [renamePackageName, setRenamePackageName] = useState('');
|
||||
const [renameError, setRenameError] = useState('');
|
||||
|
||||
// Search, sort, and view mode state
|
||||
const [search, setSearch] = useState('');
|
||||
const [viewMode, setViewMode] = useStoredViewMode(
|
||||
@@ -80,6 +93,187 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
const historyScrollRef = useRef<HTMLDivElement>(null);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
|
||||
// Shortkey recording state
|
||||
const [isRecordingShortkey, setIsRecordingShortkey] = useState(false);
|
||||
const [shortkeyError, setShortkeyError] = useState<string | null>(null);
|
||||
|
||||
const existingShortkeys = useMemo(() => (
|
||||
snippets.filter(s => Boolean(s.shortkey) && s.id !== editingSnippet.id)
|
||||
), [snippets, editingSnippet.id]);
|
||||
|
||||
const isMac = useMemo(() => (
|
||||
hotkeyScheme === 'mac' || (hotkeyScheme === 'disabled' && isMacPlatform())
|
||||
), [hotkeyScheme]);
|
||||
|
||||
const activeSystemBindings = useMemo(() => {
|
||||
return keyBindings.flatMap((binding) => {
|
||||
const entries: { binding: string; isMac: boolean }[] = [];
|
||||
const macBinding = binding.mac;
|
||||
const pcBinding = binding.pc;
|
||||
|
||||
if (hotkeyScheme === 'mac') {
|
||||
if (macBinding && macBinding !== 'Disabled') {
|
||||
entries.push({ binding: macBinding, isMac: true });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (hotkeyScheme === 'pc') {
|
||||
if (pcBinding && pcBinding !== 'Disabled') {
|
||||
entries.push({ binding: pcBinding, isMac: false });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (macBinding && macBinding !== 'Disabled') {
|
||||
entries.push({ binding: macBinding, isMac: true });
|
||||
}
|
||||
if (pcBinding && pcBinding !== 'Disabled') {
|
||||
entries.push({ binding: pcBinding, isMac: false });
|
||||
}
|
||||
return entries;
|
||||
});
|
||||
}, [hotkeyScheme, keyBindings]);
|
||||
|
||||
const buildKeyEventFromString = useCallback((keyString: string) => {
|
||||
const parsed = parseKeyCombo(keyString);
|
||||
if (!parsed) return null;
|
||||
|
||||
const modifiers = new Set(parsed.modifiers);
|
||||
const key = parsed.key;
|
||||
const normalizedKey = (() => {
|
||||
switch (key) {
|
||||
case 'Space':
|
||||
return ' ';
|
||||
case '↑':
|
||||
return 'ArrowUp';
|
||||
case '↓':
|
||||
return 'ArrowDown';
|
||||
case '←':
|
||||
return 'ArrowLeft';
|
||||
case '→':
|
||||
return 'ArrowRight';
|
||||
case 'Esc':
|
||||
return 'Escape';
|
||||
case '⌫':
|
||||
return 'Backspace';
|
||||
case 'Del':
|
||||
return 'Delete';
|
||||
case '↵':
|
||||
return 'Enter';
|
||||
case '⇥':
|
||||
return 'Tab';
|
||||
default:
|
||||
return key.length === 1 ? key.toLowerCase() : key;
|
||||
}
|
||||
})();
|
||||
|
||||
return new KeyboardEvent('keydown', {
|
||||
key: normalizedKey,
|
||||
metaKey: modifiers.has('⌘') || modifiers.has('Win'),
|
||||
ctrlKey: modifiers.has('⌃') || modifiers.has('Ctrl'),
|
||||
altKey: modifiers.has('⌥') || modifiers.has('Alt'),
|
||||
shiftKey: modifiers.has('Shift'),
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Validate shortkey for conflicts (case-insensitive comparison)
|
||||
const normalizeKeyString = useCallback((value: string) => (
|
||||
value.toLowerCase().replace(/\s+/g, '')
|
||||
), []);
|
||||
|
||||
const validateShortkey = useCallback((key: string): string | null => {
|
||||
if (!key) return null;
|
||||
|
||||
const syntheticEvent = buildKeyEventFromString(key);
|
||||
if (syntheticEvent) {
|
||||
const conflictsSystem = activeSystemBindings.some(({ binding, isMac: bindingIsMac }) => (
|
||||
matchesKeyBinding(syntheticEvent, binding, bindingIsMac)
|
||||
));
|
||||
if (conflictsSystem) {
|
||||
return t('snippets.shortkey.error.systemConflict');
|
||||
}
|
||||
}
|
||||
|
||||
// Check other snippet shortcuts
|
||||
if (syntheticEvent) {
|
||||
for (const snippet of existingShortkeys) {
|
||||
if (snippet.shortkey && matchesKeyBinding(syntheticEvent, snippet.shortkey, isMac)) {
|
||||
return t('snippets.shortkey.error.snippetConflict', { name: snippet.label });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const normalizedKey = normalizeKeyString(key);
|
||||
const conflictingSnippet = existingShortkeys.find(snippet => (
|
||||
snippet.shortkey && normalizeKeyString(snippet.shortkey) === normalizedKey
|
||||
));
|
||||
if (conflictingSnippet) {
|
||||
return t('snippets.shortkey.error.snippetConflict', { name: conflictingSnippet.label });
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [
|
||||
activeSystemBindings,
|
||||
buildKeyEventFromString,
|
||||
existingShortkeys,
|
||||
isMac,
|
||||
normalizeKeyString,
|
||||
t,
|
||||
]);
|
||||
|
||||
// Handle shortkey recording
|
||||
useEffect(() => {
|
||||
if (!isRecordingShortkey) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Escape cancels recording
|
||||
if (e.key === 'Escape') {
|
||||
setIsRecordingShortkey(false);
|
||||
setShortkeyError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip pure modifier keys
|
||||
if (['Meta', 'Control', 'Alt', 'Shift'].includes(e.key)) return;
|
||||
|
||||
const keyString = keyEventToString(e, isMac);
|
||||
|
||||
// Validate the new shortkey
|
||||
const error = validateShortkey(keyString);
|
||||
if (error) {
|
||||
setShortkeyError(error);
|
||||
// Don't stop recording, let user try again
|
||||
return;
|
||||
}
|
||||
|
||||
setShortkeyError(null);
|
||||
setEditingSnippet(prev => ({ ...prev, shortkey: keyString }));
|
||||
setIsRecordingShortkey(false);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
setIsRecordingShortkey(false);
|
||||
setShortkeyError(null);
|
||||
};
|
||||
|
||||
// Delay adding click handler by 100ms to prevent the button click that
|
||||
// initiated recording from immediately triggering the click handler
|
||||
const timer = setTimeout(() => {
|
||||
window.addEventListener('click', handleClick, true);
|
||||
}, 100);
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener('keydown', handleKeyDown, true);
|
||||
window.removeEventListener('click', handleClick, true);
|
||||
};
|
||||
}, [isRecordingShortkey, isMac, validateShortkey]);
|
||||
|
||||
const handleEdit = (snippet?: Snippet) => {
|
||||
if (snippet) {
|
||||
setEditingSnippet(snippet);
|
||||
@@ -105,6 +299,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
tags: editingSnippet.tags || [],
|
||||
package: editingSnippet.package || '',
|
||||
targets: targetSelection,
|
||||
shortkey: editingSnippet.shortkey,
|
||||
});
|
||||
setRightPanelMode('none');
|
||||
}
|
||||
@@ -144,23 +339,60 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
|
||||
const displayedPackages = useMemo(() => {
|
||||
if (!selectedPackage) {
|
||||
const roots = packages
|
||||
// Separate absolute paths (starting with /) from relative paths
|
||||
const absolutePaths = packages.filter(p => p.startsWith('/'));
|
||||
const relativePaths = packages.filter(p => !p.startsWith('/'));
|
||||
|
||||
const results: { name: string; path: string; count: number }[] = [];
|
||||
|
||||
// Process relative paths (traditional behavior)
|
||||
const relativeRoots = relativePaths
|
||||
.map((p) => p.split('/')[0])
|
||||
.filter(Boolean);
|
||||
return Array.from(new Set(roots)).map((name) => {
|
||||
const path = name;
|
||||
const count = snippets.filter((s) => (s.package || '') === path).length;
|
||||
return { name, path, count };
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
|
||||
Array.from(new Set(relativeRoots)).forEach((name: string) => {
|
||||
const path: string = name;
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
results.push({ name, path, count });
|
||||
});
|
||||
|
||||
// Process absolute paths - show them as separate roots with "/" prefix
|
||||
const absoluteRoots = absolutePaths
|
||||
.map((p) => {
|
||||
const cleanPath = p.substring(1); // Remove leading slash
|
||||
const firstSegment = cleanPath.split('/')[0];
|
||||
return firstSegment;
|
||||
})
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
|
||||
Array.from(new Set(absoluteRoots)).forEach((name: string) => {
|
||||
const path: string = `/${name}`;
|
||||
const displayName: string = `/${name}`; // Show with leading slash to distinguish
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
results.push({ name: displayName, path, count });
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
const prefix = selectedPackage + '/';
|
||||
const children = packages
|
||||
.filter((p) => p.startsWith(prefix))
|
||||
.map((p) => p.replace(prefix, '').split('/')[0])
|
||||
.filter(Boolean);
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
return Array.from(new Set(children)).map((name) => {
|
||||
const path = `${selectedPackage}/${name}`;
|
||||
const count = snippets.filter((s) => (s.package || '') === path).length;
|
||||
// Count snippets in this package AND all nested packages
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
return { name, path, count };
|
||||
});
|
||||
}, [packages, selectedPackage, snippets]);
|
||||
@@ -191,28 +423,76 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
|
||||
const breadcrumb = useMemo(() => {
|
||||
if (!selectedPackage) return [];
|
||||
const isAbsolute = selectedPackage.startsWith('/');
|
||||
const parts = selectedPackage.split('/').filter(Boolean);
|
||||
return parts.map((name, idx) => ({ name, path: parts.slice(0, idx + 1).join('/') }));
|
||||
return parts.map((name, idx) => {
|
||||
const pathSegments = parts.slice(0, idx + 1);
|
||||
const path = isAbsolute ? `/${pathSegments.join('/')}` : pathSegments.join('/');
|
||||
return { name, path };
|
||||
});
|
||||
}, [selectedPackage]);
|
||||
|
||||
const createPackage = () => {
|
||||
const name = newPackageName.trim();
|
||||
if (!name) return;
|
||||
const full = selectedPackage ? `${selectedPackage}/${name}` : name;
|
||||
if (!packages.includes(full)) onPackagesChange([...packages, full]);
|
||||
|
||||
// Allow leading slash and validate the rest - allow hyphens anywhere in package names
|
||||
if (!/^\/?([\w-]+(\/[\w-]+)*)\/?$/.test(name)) {
|
||||
// Could add toast notification here for invalid characters
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize path construction to avoid double slashes
|
||||
let full: string;
|
||||
if (selectedPackage) {
|
||||
// Strip leading slash from name when we're inside a package to avoid double slashes
|
||||
const normalizedName = name.startsWith('/') ? name.substring(1) : name;
|
||||
full = `${selectedPackage}/${normalizedName}`;
|
||||
} else {
|
||||
// At root level, preserve the leading slash if user intended it
|
||||
full = name;
|
||||
}
|
||||
|
||||
// Strip trailing slash to ensure consistent path handling
|
||||
if (full.endsWith('/')) {
|
||||
full = full.slice(0, -1);
|
||||
}
|
||||
|
||||
// Check for duplicate package names (case-insensitive)
|
||||
const existingPackage = packages.find(p => p.toLowerCase() === full.toLowerCase());
|
||||
if (existingPackage) {
|
||||
// Could add toast notification here for duplicate package
|
||||
return;
|
||||
}
|
||||
|
||||
onPackagesChange([...packages, full]);
|
||||
setNewPackageName('');
|
||||
setIsPackageDialogOpen(false);
|
||||
};
|
||||
|
||||
const deletePackage = (path: string) => {
|
||||
// Remove the package and all its children
|
||||
const keep = packages.filter((p) => !(p === path || p.startsWith(path + '/')));
|
||||
|
||||
// Move all snippets from deleted packages to root
|
||||
const updatedSnippets = snippets.map((s) => {
|
||||
if (!s.package) return s;
|
||||
if (s.package === path || s.package.startsWith(path + '/')) return { ...s, package: '' };
|
||||
if (s.package === path || s.package.startsWith(path + '/')) {
|
||||
return { ...s, package: '' };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
// Update packages first, then save snippets
|
||||
onPackagesChange(keep);
|
||||
updatedSnippets.forEach(onSave);
|
||||
|
||||
// Only save snippets that were actually modified
|
||||
const modifiedSnippets = updatedSnippets.filter((s, index) =>
|
||||
s.package !== snippets[index].package
|
||||
);
|
||||
modifiedSnippets.forEach(onSave);
|
||||
|
||||
// Reset selected package if it was deleted
|
||||
if (selectedPackage && (selectedPackage === path || selectedPackage.startsWith(path + '/'))) {
|
||||
setSelectedPackage(null);
|
||||
}
|
||||
@@ -220,24 +500,125 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
|
||||
const movePackage = (source: string, target: string | null) => {
|
||||
const name = source.split('/').pop() || '';
|
||||
const newPath = target ? `${target}/${name}` : name;
|
||||
const isAbsolute = source.startsWith('/');
|
||||
const newPath = target ? `${target}/${name}` : (isAbsolute ? `/${name}` : name);
|
||||
if (newPath === source || newPath.startsWith(source + '/')) return;
|
||||
|
||||
// Check if target path already exists
|
||||
if (packages.includes(newPath)) return;
|
||||
|
||||
const updatedPackages = packages.map((p) => {
|
||||
if (p === source) return newPath;
|
||||
if (p.startsWith(source + '/')) return p.replace(source, newPath);
|
||||
// Use more precise replacement to avoid substring issues
|
||||
if (p.startsWith(source + '/')) {
|
||||
return newPath + p.substring(source.length);
|
||||
}
|
||||
return p;
|
||||
});
|
||||
|
||||
const updatedSnippets = snippets.map((s) => {
|
||||
if (!s.package) return s;
|
||||
if (s.package === source) return { ...s, package: newPath };
|
||||
if (s.package.startsWith(source + '/')) return { ...s, package: s.package.replace(source, newPath) };
|
||||
// Use more precise replacement to avoid substring issues
|
||||
if (s.package.startsWith(source + '/')) {
|
||||
return { ...s, package: newPath + s.package.substring(source.length) };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
onPackagesChange(Array.from(new Set(updatedPackages)));
|
||||
updatedSnippets.forEach(onSave);
|
||||
if (selectedPackage === source) setSelectedPackage(newPath);
|
||||
};
|
||||
|
||||
const openRenameDialog = (path: string) => {
|
||||
const name = path.split('/').pop() || '';
|
||||
setRenamingPackagePath(path);
|
||||
setRenamePackageName(name);
|
||||
setRenameError('');
|
||||
setIsRenameDialogOpen(true);
|
||||
};
|
||||
|
||||
const renamePackage = () => {
|
||||
if (!renamingPackagePath) return;
|
||||
|
||||
const newName = renamePackageName.trim();
|
||||
|
||||
// Validate: empty name
|
||||
if (!newName) {
|
||||
setRenameError(t('snippets.renameDialog.error.empty'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate: same rules as createPackage - only allow letters, numbers, hyphens, underscores
|
||||
// Since we're renaming a single segment (no slashes allowed), use the segment-level pattern
|
||||
if (!/^[\w-]+$/.test(newName)) {
|
||||
setRenameError(t('snippets.renameDialog.error.invalidChars'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Build new path
|
||||
const parts = renamingPackagePath.split('/');
|
||||
parts[parts.length - 1] = newName;
|
||||
const newPath = parts.join('/');
|
||||
|
||||
// Validate: same name
|
||||
if (newPath === renamingPackagePath) {
|
||||
setIsRenameDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate: duplicate (case-insensitive)
|
||||
const existingPackage = packages.find(p => p.toLowerCase() === newPath.toLowerCase());
|
||||
if (existingPackage) {
|
||||
setRenameError(t('snippets.renameDialog.error.duplicate'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Update all packages with this path or nested under it
|
||||
const updatedPackages = packages.map((p) => {
|
||||
if (p === renamingPackagePath) return newPath;
|
||||
if (p.startsWith(renamingPackagePath + '/')) {
|
||||
return newPath + p.substring(renamingPackagePath.length);
|
||||
}
|
||||
return p;
|
||||
});
|
||||
|
||||
// Update all snippets with this package or nested under it
|
||||
const updatedSnippets = snippets.map((s) => {
|
||||
if (!s.package) return s;
|
||||
if (s.package === renamingPackagePath) return { ...s, package: newPath };
|
||||
if (s.package.startsWith(renamingPackagePath + '/')) {
|
||||
return { ...s, package: newPath + s.package.substring(renamingPackagePath.length) };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
onPackagesChange(Array.from(new Set(updatedPackages)));
|
||||
updatedSnippets.forEach(onSave);
|
||||
|
||||
// Update selected package if it was renamed
|
||||
if (selectedPackage === renamingPackagePath) {
|
||||
setSelectedPackage(newPath);
|
||||
} else if (selectedPackage?.startsWith(renamingPackagePath + '/')) {
|
||||
setSelectedPackage(newPath + selectedPackage.substring(renamingPackagePath.length));
|
||||
}
|
||||
|
||||
// Update editingSnippet.package if it's in the renamed package (fixes stale state when editing)
|
||||
if (editingSnippet.package) {
|
||||
if (editingSnippet.package === renamingPackagePath) {
|
||||
setEditingSnippet(prev => ({ ...prev, package: newPath }));
|
||||
} else if (editingSnippet.package.startsWith(renamingPackagePath + '/')) {
|
||||
setEditingSnippet(prev => ({
|
||||
...prev,
|
||||
package: newPath + prev.package!.substring(renamingPackagePath.length)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
setIsRenameDialogOpen(false);
|
||||
};
|
||||
|
||||
const moveSnippet = (id: string, pkg: string | null) => {
|
||||
const sn = snippets.find((s) => s.id === id);
|
||||
if (!sn) return;
|
||||
@@ -246,11 +627,36 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
|
||||
// Package options for Combobox
|
||||
const packageOptions: ComboboxOption[] = useMemo(() => {
|
||||
return packages.map(p => ({
|
||||
value: p,
|
||||
label: p.includes('/') ? p.split('/').pop()! : p,
|
||||
sublabel: p.includes('/') ? p : undefined,
|
||||
}));
|
||||
// Generate all possible parent paths for each package
|
||||
const allPaths = new Set<string>();
|
||||
|
||||
packages.forEach(pkg => {
|
||||
// Add the full package path
|
||||
allPaths.add(pkg);
|
||||
|
||||
// Add all parent paths
|
||||
const parts = pkg.split('/').filter(Boolean);
|
||||
const isAbsolute = pkg.startsWith('/');
|
||||
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const parentPath = (isAbsolute ? '/' : '') + parts.slice(0, i).join('/');
|
||||
allPaths.add(parentPath);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(allPaths)
|
||||
.sort((a, b) => {
|
||||
// Sort by depth first (shorter paths first), then alphabetically
|
||||
const depthA = (a.match(/\//g) || []).length;
|
||||
const depthB = (b.match(/\//g) || []).length;
|
||||
if (depthA !== depthB) return depthA - depthB;
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map(p => ({
|
||||
value: p,
|
||||
label: p.includes('/') ? p.split('/').pop()! : p,
|
||||
sublabel: p.includes('/') ? p : undefined,
|
||||
}));
|
||||
}, [packages]);
|
||||
|
||||
// Shell history lazy loading
|
||||
@@ -310,6 +716,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
onBack={handleTargetPickerBack}
|
||||
onContinue={handleTargetPickerBack}
|
||||
availableKeys={availableKeys}
|
||||
managedSources={managedSources}
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={onCreateGroup}
|
||||
title={t('snippets.targets.add')}
|
||||
@@ -354,7 +761,13 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
<Combobox
|
||||
options={packageOptions}
|
||||
value={editingSnippet.package || selectedPackage || ''}
|
||||
onValueChange={(val) => setEditingSnippet({ ...editingSnippet, package: val })}
|
||||
onValueChange={(val) => {
|
||||
setEditingSnippet({ ...editingSnippet, package: val });
|
||||
// If selecting an implicit parent path, persist it to packages
|
||||
if (val && !packages.includes(val)) {
|
||||
onPackagesChange([...packages, val]);
|
||||
}
|
||||
}}
|
||||
placeholder={t('snippets.field.packagePlaceholder')}
|
||||
allowCreate={true}
|
||||
onCreateNew={(val) => {
|
||||
@@ -379,6 +792,50 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Shortkey */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold text-muted-foreground">{t('snippets.field.shortkey')}</p>
|
||||
{editingSnippet.shortkey && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => {
|
||||
setEditingSnippet(prev => ({ ...prev, shortkey: undefined }));
|
||||
setShortkeyError(null);
|
||||
}}
|
||||
title={t('snippets.shortkey.clear')}
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRecordingShortkey(true);
|
||||
setShortkeyError(null);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full h-10 px-3 text-sm font-mono rounded-lg border transition-colors flex items-center justify-center gap-2",
|
||||
isRecordingShortkey
|
||||
? "border-primary bg-primary/10 animate-pulse"
|
||||
: "border-border hover:border-primary/50 bg-background"
|
||||
)}
|
||||
>
|
||||
<Keyboard size={14} className="text-muted-foreground" />
|
||||
{isRecordingShortkey
|
||||
? t('snippets.shortkey.recording')
|
||||
: editingSnippet.shortkey || t('snippets.shortkey.placeholder')}
|
||||
</button>
|
||||
{shortkeyError && (
|
||||
<p className="text-xs text-destructive">{shortkeyError}</p>
|
||||
)}
|
||||
<p className="text-[11px] text-muted-foreground">{t('snippets.shortkey.hint')}</p>
|
||||
</Card>
|
||||
|
||||
{/* Targets */}
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -565,12 +1022,12 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
|
||||
{!snippets.length && displayedPackages.length === 0 && (
|
||||
<div className="flex-1 flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full text-center space-y-3 py-12 rounded-2xl bg-secondary/60 border border-border/60 shadow-lg">
|
||||
<div className="mx-auto h-12 w-12 rounded-xl bg-muted text-muted-foreground flex items-center justify-center">
|
||||
<FileCode size={22} />
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground">
|
||||
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
||||
<FileCode size={32} className="opacity-60" />
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-foreground">{t('snippets.empty.title')}</div>
|
||||
<div className="text-xs text-muted-foreground px-8">{t('snippets.empty.desc')}</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">{t('snippets.empty.title')}</h3>
|
||||
<p className="text-sm text-center max-w-sm">{t('snippets.empty.desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -624,6 +1081,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => setSelectedPackage(pkg.path)}>{t('action.open')}</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => openRenameDialog(pkg.path)}>{t('common.rename')}</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={() => deletePackage(pkg.path)}>{t('action.delete')}</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
@@ -667,6 +1125,11 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
{snippet.command.replace(/\s+/g, ' ') || t('snippets.commandFallback')}
|
||||
</div>
|
||||
</div>
|
||||
{snippet.shortkey && (
|
||||
<div className="shrink-0 px-2 py-1 text-[10px] font-mono rounded border border-border bg-muted/50 text-muted-foreground">
|
||||
{snippet.shortkey}
|
||||
</div>
|
||||
)}
|
||||
{viewMode === 'list' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -729,6 +1192,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
value={newPackageName}
|
||||
onChange={(e) => setNewPackageName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && createPackage()}
|
||||
pattern="^/?([\w-]+(/[\w-]+)*)?/?$"
|
||||
title="Package names can contain letters, numbers, hyphens, underscores, and forward slashes. Can optionally start with /"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">{t('snippets.packageDialog.hint')}</p>
|
||||
</div>
|
||||
@@ -742,6 +1207,40 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rename Package Dialog */}
|
||||
{isRenameDialogOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<Card className="w-full max-w-sm p-4 space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{t('snippets.renameDialog.title')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('snippets.renameDialog.currentPath', { path: renamingPackagePath })}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('field.name')}</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t('snippets.renameDialog.placeholder')}
|
||||
value={renamePackageName}
|
||||
onChange={(e) => {
|
||||
setRenamePackageName(e.target.value);
|
||||
setRenameError('');
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Enter' && renamePackage()}
|
||||
/>
|
||||
{renameError && (
|
||||
<p className="text-[11px] text-destructive">{renameError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setIsRenameDialogOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={renamePackage}>{t('common.rename')}</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right Panel */}
|
||||
{renderRightPanel()}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* SyncStatusButton - Cloud Sync Status Indicator for Top Bar
|
||||
*
|
||||
*
|
||||
* Shows current sync state with cloud icon and colored indicators:
|
||||
* - Green dot: All synced
|
||||
* - Blue dot + spin: Syncing in progress
|
||||
* - Blue dot + spin: Syncing in progress
|
||||
* - Red dot: Error
|
||||
* - Gray dot: No providers connected
|
||||
*
|
||||
*
|
||||
* Clicking opens a popover with sync status details and history.
|
||||
*/
|
||||
|
||||
@@ -239,7 +239,7 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
<CloudOff size={32} className="mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-sm font-medium mb-1">{t('sync.notConfigured')}</p>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Connect a cloud provider to sync your data across devices.
|
||||
{t('sync.autoSync.noProvider')}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -249,7 +249,7 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
onOpenSettings?.();
|
||||
}}
|
||||
>
|
||||
Configure Cloud Sync
|
||||
{t('sync.settings')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -683,6 +683,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
terminalSettings={terminalSettings}
|
||||
sessionId={session.id}
|
||||
startupCommand={session.startupCommand}
|
||||
serialConfig={session.serialConfig}
|
||||
onUpdateTerminalThemeId={onUpdateTerminalThemeId}
|
||||
onUpdateTerminalFontFamilyId={onUpdateTerminalFontFamilyId}
|
||||
onUpdateTerminalFontSize={onUpdateTerminalFontSize}
|
||||
|
||||
468
components/TextEditorModal.tsx
Normal file
468
components/TextEditorModal.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* TextEditorModal - Modal for editing text files in SFTP with syntax highlighting
|
||||
*/
|
||||
import {
|
||||
CloudUpload,
|
||||
Loader2,
|
||||
Search,
|
||||
WrapText,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import Editor, { type OnMount, loader, useMonaco } from '@monaco-editor/react';
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
// Configure Monaco to use local files instead of CDN
|
||||
const monacoBasePath = import.meta.env.DEV
|
||||
? './node_modules/monaco-editor/min/vs'
|
||||
: `${import.meta.env.BASE_URL}monaco/vs`;
|
||||
loader.config({ paths: { vs: monacoBasePath } });
|
||||
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useClipboardBackend } from '../application/state/useClipboardBackend';
|
||||
import { getLanguageId, getLanguageName, getSupportedLanguages } from '../lib/sftpFileUtils';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
import { Combobox } from './ui/combobox';
|
||||
import { toast } from './ui/toast';
|
||||
|
||||
interface TextEditorModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
fileName: string;
|
||||
initialContent: string;
|
||||
onSave: (content: string) => Promise<void>;
|
||||
editorWordWrap: boolean;
|
||||
onToggleWordWrap: () => void;
|
||||
}
|
||||
|
||||
// Map our language IDs to Monaco language IDs
|
||||
const languageIdToMonaco = (langId: string): string => {
|
||||
const mapping: Record<string, string> = {
|
||||
'javascript': 'javascript',
|
||||
'typescript': 'typescript',
|
||||
'python': 'python',
|
||||
'shell': 'shell',
|
||||
'batch': 'bat',
|
||||
'powershell': 'powershell',
|
||||
'c': 'c',
|
||||
'cpp': 'cpp',
|
||||
'java': 'java',
|
||||
'kotlin': 'kotlin',
|
||||
'go': 'go',
|
||||
'rust': 'rust',
|
||||
'ruby': 'ruby',
|
||||
'php': 'php',
|
||||
'perl': 'perl',
|
||||
'lua': 'lua',
|
||||
'r': 'r',
|
||||
'swift': 'swift',
|
||||
'dart': 'dart',
|
||||
'csharp': 'csharp',
|
||||
'fsharp': 'fsharp',
|
||||
'vb': 'vb',
|
||||
'html': 'html',
|
||||
'css': 'css',
|
||||
'scss': 'scss',
|
||||
'sass': 'sass',
|
||||
'less': 'less',
|
||||
'json': 'json',
|
||||
'jsonc': 'json',
|
||||
'json5': 'json',
|
||||
'xml': 'xml',
|
||||
'yaml': 'yaml',
|
||||
'toml': 'ini',
|
||||
'ini': 'ini',
|
||||
'sql': 'sql',
|
||||
'graphql': 'graphql',
|
||||
'markdown': 'markdown',
|
||||
'plaintext': 'plaintext',
|
||||
'vue': 'html',
|
||||
'svelte': 'html',
|
||||
'dockerfile': 'dockerfile',
|
||||
'makefile': 'makefile',
|
||||
'diff': 'diff',
|
||||
};
|
||||
return mapping[langId] || 'plaintext';
|
||||
};
|
||||
|
||||
// Convert HSL string "h s% l%" to hex color
|
||||
const hslToHex = (hslString: string): string => {
|
||||
const parts = hslString.trim().split(/\s+/);
|
||||
if (parts.length < 3) return '#1e1e1e';
|
||||
const h = parseFloat(parts[0]) / 360;
|
||||
const s = parseFloat(parts[1].replace('%', '')) / 100;
|
||||
const l = parseFloat(parts[2].replace('%', '')) / 100;
|
||||
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
let r: number, g: number, b: number;
|
||||
if (s === 0) {
|
||||
r = g = b = l;
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1 / 3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1 / 3);
|
||||
}
|
||||
|
||||
const toHex = (x: number) => {
|
||||
const hex = Math.round(x * 255).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
};
|
||||
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
};
|
||||
|
||||
// Get background color from CSS variable
|
||||
const getBackgroundColor = (): string => {
|
||||
const bgValue = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--background')
|
||||
.trim();
|
||||
return bgValue ? hslToHex(bgValue) : '#1e1e1e';
|
||||
};
|
||||
|
||||
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
fileName,
|
||||
initialContent,
|
||||
onSave,
|
||||
editorWordWrap,
|
||||
onToggleWordWrap,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { readClipboardText: readClipboardTextFromBridge } = useClipboardBackend();
|
||||
const monaco = useMonaco();
|
||||
const [content, setContent] = useState(initialContent);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [languageId, setLanguageId] = useState(() => getLanguageId(fileName));
|
||||
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||
|
||||
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
|
||||
const handleSaveRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
const handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
|
||||
// Track theme from document.documentElement class (syncs with app theme)
|
||||
const [isDarkTheme, setIsDarkTheme] = useState(() =>
|
||||
document.documentElement.classList.contains('dark')
|
||||
);
|
||||
|
||||
// Track background color for custom theme
|
||||
const [bgColor, setBgColor] = useState(() => getBackgroundColor());
|
||||
|
||||
// Custom theme name
|
||||
const customThemeName = isDarkTheme ? 'netcatty-dark' : 'netcatty-light';
|
||||
|
||||
// Define and update custom Monaco themes based on UI background color
|
||||
useEffect(() => {
|
||||
if (!monaco) return;
|
||||
|
||||
// Define dark theme with custom background
|
||||
monaco.editor.defineTheme('netcatty-dark', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editor.background': bgColor,
|
||||
},
|
||||
});
|
||||
|
||||
// Define light theme with custom background
|
||||
monaco.editor.defineTheme('netcatty-light', {
|
||||
base: 'vs',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editor.background': bgColor,
|
||||
},
|
||||
});
|
||||
|
||||
// Apply the current theme
|
||||
monaco.editor.setTheme(customThemeName);
|
||||
}, [monaco, isDarkTheme, bgColor, customThemeName]);
|
||||
|
||||
// Listen for theme changes via MutationObserver on <html> class and style
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const updateTheme = () => {
|
||||
setIsDarkTheme(root.classList.contains('dark'));
|
||||
setBgColor(getBackgroundColor());
|
||||
};
|
||||
const observer = new MutationObserver(updateTheme);
|
||||
observer.observe(root, { attributes: true, attributeFilter: ['class', 'style'] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Reset content when file changes
|
||||
useEffect(() => {
|
||||
setContent(initialContent);
|
||||
setHasChanges(false);
|
||||
setLanguageId(getLanguageId(fileName));
|
||||
}, [initialContent, fileName]);
|
||||
|
||||
// Track changes
|
||||
useEffect(() => {
|
||||
setHasChanges(content !== initialContent);
|
||||
}, [content, initialContent]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(content);
|
||||
setHasChanges(false);
|
||||
toast.success(t('sftp.editor.saved'), 'SFTP');
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t('sftp.editor.saveFailed'),
|
||||
'SFTP'
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [content, onSave, saving, t]);
|
||||
|
||||
// Keep the ref updated with the latest handleSave function
|
||||
useEffect(() => {
|
||||
handleSaveRef.current = handleSave;
|
||||
}, [handleSave]);
|
||||
|
||||
const readClipboardText = useCallback(async (): Promise<string | null> => {
|
||||
try {
|
||||
if (navigator.clipboard?.readText) {
|
||||
return await navigator.clipboard.readText();
|
||||
}
|
||||
} catch {
|
||||
// Fall through to Electron bridge
|
||||
}
|
||||
|
||||
try {
|
||||
return await readClipboardTextFromBridge();
|
||||
} catch {
|
||||
// Both clipboard APIs unavailable; signal failure so caller can fall back.
|
||||
return null;
|
||||
}
|
||||
}, [readClipboardTextFromBridge]);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const text = await readClipboardText();
|
||||
if (text === null) {
|
||||
// Clipboard read unavailable; fall back to Monaco's native paste.
|
||||
editor.trigger('keyboard', 'editor.action.clipboardPasteAction', null);
|
||||
return;
|
||||
}
|
||||
if (!text) return;
|
||||
|
||||
const selections = editor.getSelections();
|
||||
if (!selections || selections.length === 0) return;
|
||||
|
||||
// Match Monaco's default multicursorPaste:'spread' behavior:
|
||||
// distribute one line per cursor when line count equals cursor count.
|
||||
const lines = text.split(/\r\n|\n/);
|
||||
const distribute = selections.length > 1 && lines.length === selections.length;
|
||||
|
||||
editor.executeEdits(
|
||||
'netcatty-paste',
|
||||
selections.map((selection, i) => ({
|
||||
range: selection,
|
||||
text: distribute ? lines[i] : text,
|
||||
forceMoveMarkers: true,
|
||||
})),
|
||||
);
|
||||
editor.focus();
|
||||
}, [readClipboardText]);
|
||||
|
||||
useEffect(() => {
|
||||
handlePasteRef.current = handlePaste;
|
||||
}, [handlePaste]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (hasChanges) {
|
||||
const confirmed = confirm(t('sftp.editor.unsavedChanges'));
|
||||
if (!confirmed) return;
|
||||
}
|
||||
onClose();
|
||||
}, [hasChanges, onClose, t]);
|
||||
|
||||
const handleEditorChange = useCallback((value: string | undefined) => {
|
||||
setContent(value || '');
|
||||
}, []);
|
||||
|
||||
const handleEditorMount: OnMount = useCallback((editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
|
||||
// Add save shortcut - use ref to avoid stale closure
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
handleSaveRef.current();
|
||||
});
|
||||
|
||||
// Add find shortcut (Ctrl+F / Cmd+F)
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => {
|
||||
// Trigger Monaco's built-in find widget
|
||||
editor.trigger('keyboard', 'actions.find', null);
|
||||
});
|
||||
|
||||
// Fallback paste path for Electron environments where Monaco paste can fail.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
|
||||
void handlePasteRef.current();
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Trigger search dialog
|
||||
const handleSearch = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.trigger('keyboard', 'actions.find', null);
|
||||
editorRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const supportedLanguages = useMemo(() => getSupportedLanguages(), []);
|
||||
const monacoLanguage = useMemo(() => languageIdToMonaco(languageId), [languageId]);
|
||||
const languageOptions = useMemo(
|
||||
() => supportedLanguages.map((lang) => ({ value: lang.id, label: lang.name })),
|
||||
[supportedLanguages],
|
||||
);
|
||||
|
||||
const handleLanguageChange = useCallback((nextValue: string) => {
|
||||
setLanguageId(nextValue || 'plaintext');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent className="max-w-5xl h-[85vh] flex flex-col p-0 gap-0" hideCloseButton>
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<DialogTitle className="text-sm font-semibold truncate">
|
||||
{fileName}
|
||||
{hasChanges && <span className="text-primary ml-1">*</span>}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* Search button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleSearch}
|
||||
title={t('common.search')}
|
||||
>
|
||||
<Search size={14} />
|
||||
</Button>
|
||||
|
||||
{/* Word wrap toggle */}
|
||||
<Button
|
||||
variant={editorWordWrap ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onToggleWordWrap}
|
||||
title={t('sftp.editor.wordWrap')}
|
||||
>
|
||||
<WrapText size={14} />
|
||||
</Button>
|
||||
|
||||
{/* Language selector */}
|
||||
<Combobox
|
||||
options={languageOptions}
|
||||
value={languageId}
|
||||
onValueChange={handleLanguageChange}
|
||||
placeholder={t('sftp.editor.syntaxHighlight')}
|
||||
triggerClassName="h-7 max-w-[180px] min-w-[120px] text-xs"
|
||||
/>
|
||||
|
||||
{/* Save button */}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !hasChanges}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 size={14} className="mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<CloudUpload size={14} className="mr-1.5" />
|
||||
)}
|
||||
{saving ? t('sftp.editor.saving') : t('sftp.editor.save')}
|
||||
</Button>
|
||||
|
||||
{/* Close button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Monaco Editor */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<Editor
|
||||
height="100%"
|
||||
language={monacoLanguage}
|
||||
value={content}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorMount}
|
||||
theme={customThemeName}
|
||||
loading={
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background">
|
||||
<Loader2 size={32} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
}
|
||||
options={{
|
||||
// Prefer native context menu in Electron so right-click Paste uses OS clipboard path.
|
||||
contextmenu: false,
|
||||
minimap: { enabled: true },
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
roundedSelection: false,
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
wordWrap: editorWordWrap ? 'on' : 'off',
|
||||
folding: true,
|
||||
renderWhitespace: 'selection',
|
||||
bracketPairColorization: { enabled: true },
|
||||
find: {
|
||||
addExtraSpaceOnTop: false,
|
||||
autoFindInSelection: 'never',
|
||||
seedSearchStringFromSelection: 'selection',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
|
||||
<span>
|
||||
{getLanguageName(languageId)}
|
||||
</span>
|
||||
<span>
|
||||
{content.split('\n').length} lines • {content.length} characters
|
||||
</span>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextEditorModal;
|
||||
@@ -25,6 +25,7 @@ interface TopTabsProps {
|
||||
isMacClient: boolean;
|
||||
onCloseSession: (sessionId: string, e?: React.MouseEvent) => void;
|
||||
onRenameSession: (sessionId: string) => void;
|
||||
onCopySession: (sessionId: string) => void;
|
||||
onRenameWorkspace: (workspaceId: string) => void;
|
||||
onCloseWorkspace: (workspaceId: string) => void;
|
||||
onCloseLogView: (logViewId: string) => void;
|
||||
@@ -121,6 +122,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
isMacClient,
|
||||
onCloseSession,
|
||||
onRenameSession,
|
||||
onCopySession,
|
||||
onRenameWorkspace,
|
||||
onCloseWorkspace,
|
||||
onCloseLogView,
|
||||
@@ -410,6 +412,9 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<ContextMenuItem onClick={() => onRenameSession(session.id)}>
|
||||
{t('common.rename')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onCopySession(session.id)}>
|
||||
{t('tabs.copyTab')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={() => onCloseSession(session.id)}>
|
||||
{t('common.close')}
|
||||
</ContextMenuItem>
|
||||
@@ -538,8 +543,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{/* Always-on drag stripe so the window can be moved even when tabs fill the bar */}
|
||||
<div className="absolute inset-x-0 top-0 h-1 app-drag pointer-events-auto z-10" style={dragRegionStyle} aria-hidden />
|
||||
<div
|
||||
className="h-8 px-3 flex items-center gap-2 app-drag"
|
||||
style={{ ...dragRegionStyle, paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12 }}
|
||||
className="h-8 flex items-center gap-2 app-drag"
|
||||
style={{ ...dragRegionStyle, paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12, paddingRight: isMacClient ? 12 : 0 }}
|
||||
>
|
||||
{/* Fixed left tabs: Vaults and SFTP */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0 app-drag">
|
||||
@@ -649,8 +654,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
</div>
|
||||
{/* Custom window controls for Windows/Linux */}
|
||||
{!isMacClient && <WindowControls />}
|
||||
{/* Small drag shim to the right edge */}
|
||||
<div className="w-2 h-8 app-drag flex-shrink-0" />
|
||||
{/* Small drag shim to the right edge (macOS only – on Windows the close button should touch the edge) */}
|
||||
{isMacClient && <div className="w-2 h-8 app-drag flex-shrink-0" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
410
components/TrayPanel.tsx
Normal file
410
components/TrayPanel.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { useSessionState } from "../application/state/useSessionState";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import { useVaultState } from "../application/state/useVaultState";
|
||||
import { toast } from "./ui/toast";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
|
||||
import { useActiveTabId } from "../application/state/activeTabStore";
|
||||
import { X, Maximize2, ChevronRight, ChevronDown, Power } from "lucide-react";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
|
||||
const StatusDot: React.FC<{ status: "success" | "warning" | "error" | "neutral"; spinning?: boolean }> = ({
|
||||
status,
|
||||
spinning,
|
||||
}) => {
|
||||
const color =
|
||||
status === "success"
|
||||
? "bg-emerald-500"
|
||||
: status === "warning"
|
||||
? "bg-amber-500"
|
||||
: status === "error"
|
||||
? "bg-rose-500"
|
||||
: "bg-zinc-500";
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-2 w-2 rounded-full",
|
||||
color,
|
||||
spinning ? "animate-spin" : "",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Session type for workspace grouping
|
||||
type TraySession = {
|
||||
id: string;
|
||||
label: string;
|
||||
hostLabel: string;
|
||||
status: "connecting" | "connected" | "disconnected";
|
||||
workspaceId?: string;
|
||||
workspaceTitle?: string;
|
||||
};
|
||||
|
||||
// Collapsible workspace group component
|
||||
const WorkspaceGroup: React.FC<{
|
||||
workspaceId: string;
|
||||
title: string;
|
||||
sessions: TraySession[];
|
||||
activeTabId: string | null;
|
||||
jumpToSession: (sessionId: string) => Promise<void>;
|
||||
t: (key: string) => string;
|
||||
}> = ({ workspaceId, title, sessions, activeTabId, jumpToSession, t }) => {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const isAnyActive = sessions.some((s) => s.id === activeTabId) || activeTabId === workspaceId;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center gap-1",
|
||||
isAnyActive ? "bg-muted" : "",
|
||||
)}
|
||||
>
|
||||
{expanded ? <ChevronDown size={14} className="text-muted-foreground" /> : <ChevronRight size={14} className="text-muted-foreground" />}
|
||||
<span className="font-medium truncate">{title}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{sessions.length}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="ml-4 mt-0.5 space-y-0.5">
|
||||
{sessions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
title={s.hostLabel || s.label}
|
||||
onClick={() => {
|
||||
// Jump to session (using session id)
|
||||
void jumpToSession(s.id);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1 rounded hover:bg-muted flex items-center justify-between text-sm",
|
||||
s.status === "connected" ? "" : "text-muted-foreground",
|
||||
activeTabId === s.id ? "bg-muted/60" : "",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
|
||||
spinning={s.status === "connecting"}
|
||||
/>
|
||||
<span className="truncate">{s.hostLabel || s.label}</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TrayPanelContent: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
hideTrayPanel,
|
||||
openMainWindow,
|
||||
quitApp,
|
||||
jumpToSession,
|
||||
onTrayPanelCloseRequest,
|
||||
onTrayPanelRefresh,
|
||||
onTrayPanelMenuData,
|
||||
} = useTrayPanelBackend();
|
||||
|
||||
const { hosts, keys } = useVaultState();
|
||||
useSessionState();
|
||||
const { rules: portForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
|
||||
const activeTabId = useActiveTabId();
|
||||
|
||||
const [traySessions, setTraySessions] = useState<TraySession[]>([]);
|
||||
|
||||
const jumpableSessions = useMemo(
|
||||
() => traySessions.filter((s) => s.status === "connected" || s.status === "connecting"),
|
||||
[traySessions],
|
||||
);
|
||||
|
||||
const activeSession = useMemo(() => {
|
||||
if (!activeTabId) return null;
|
||||
return traySessions.find((s) => s.id === activeTabId) || null;
|
||||
}, [activeTabId, traySessions]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onTrayPanelMenuData?.((data) => {
|
||||
setTraySessions(data.sessions || []);
|
||||
});
|
||||
return () => unsubscribe?.();
|
||||
}, [onTrayPanelMenuData]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onTrayPanelRefresh?.(() => {
|
||||
try {
|
||||
window.dispatchEvent(new Event("storage"));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
return () => unsubscribe?.();
|
||||
}, [onTrayPanelRefresh]);
|
||||
|
||||
const keysForPf = useMemo(
|
||||
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
|
||||
[keys],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
void hideTrayPanel();
|
||||
}, [hideTrayPanel]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [handleClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
const target = e.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (document.body && !document.body.contains(target)) return;
|
||||
// Ignore clicks on interactive elements inside the panel.
|
||||
if (target instanceof HTMLElement && target.closest("button,a,input,select,textarea,[role='button']")) {
|
||||
return;
|
||||
}
|
||||
// Clicking on background should close panel
|
||||
const root = document.getElementById("tray-panel-root");
|
||||
if (root && !root.contains(target)) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("pointerdown", onPointerDown, true);
|
||||
return () => window.removeEventListener("pointerdown", onPointerDown, true);
|
||||
}, [handleClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onTrayPanelCloseRequest(() => {
|
||||
handleClose();
|
||||
});
|
||||
return () => unsubscribe?.();
|
||||
}, [handleClose, onTrayPanelCloseRequest]);
|
||||
|
||||
const handleOpenMain = useCallback(() => {
|
||||
void openMainWindow();
|
||||
}, [openMainWindow]);
|
||||
|
||||
const handleQuit = useCallback(() => {
|
||||
void quitApp();
|
||||
}, [quitApp]);
|
||||
|
||||
return (
|
||||
<div id="tray-panel-root" className="w-full h-full bg-background/95 backdrop-blur border border-border/60 rounded-lg shadow-lg overflow-hidden flex flex-col">
|
||||
<div className="px-3 py-2 border-b border-border/60 flex items-center justify-between app-no-drag">
|
||||
<div className="flex items-center gap-2">
|
||||
<AppLogo className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">Netcatty</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
onClick={handleOpenMain}
|
||||
title={t("tray.openMainWindow")}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
onClick={handleClose}
|
||||
title="Close"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-2 space-y-3 text-sm flex-1 overflow-y-auto min-h-0">
|
||||
|
||||
{jumpableSessions.length > 0 && (() => {
|
||||
// Group sessions by workspace
|
||||
const workspaceGroups = new Map<string, { title: string; sessions: typeof jumpableSessions }>();
|
||||
const soloSessions: typeof jumpableSessions = [];
|
||||
|
||||
jumpableSessions.forEach((s) => {
|
||||
if (s.workspaceId) {
|
||||
const existing = workspaceGroups.get(s.workspaceId);
|
||||
if (existing) {
|
||||
existing.sessions.push(s);
|
||||
} else {
|
||||
workspaceGroups.set(s.workspaceId, {
|
||||
title: s.workspaceTitle || "Workspace",
|
||||
sessions: [s],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
soloSessions.push(s);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground">{t("tray.sessions")}</div>
|
||||
<div className="space-y-1">
|
||||
{/* Workspace groups */}
|
||||
{Array.from(workspaceGroups.entries()).map(([wsId, group]) => (
|
||||
<WorkspaceGroup
|
||||
key={wsId}
|
||||
workspaceId={wsId}
|
||||
title={group.title}
|
||||
sessions={group.sessions}
|
||||
activeTabId={activeTabId}
|
||||
jumpToSession={jumpToSession}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
{/* Solo sessions */}
|
||||
{soloSessions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
title={s.hostLabel || s.label}
|
||||
onClick={() => {
|
||||
void jumpToSession(s.id);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
|
||||
s.status === "connected" ? "" : "text-muted-foreground",
|
||||
activeTabId === s.id ? "bg-muted" : "",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
|
||||
spinning={s.status === "connecting"}
|
||||
/>
|
||||
<span className="truncate">{s.hostLabel || s.label}</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{activeSession && (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground">Current</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start px-2 h-8"
|
||||
title={activeSession.hostLabel || activeSession.label}
|
||||
onClick={() => {
|
||||
void jumpToSession(activeSession.id);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{activeSession.hostLabel || activeSession.label}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{portForwardingRules.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground">{t("tray.portForwarding")}</div>
|
||||
<div className="space-y-1">
|
||||
{portForwardingRules.map((rule) => {
|
||||
const isConnecting = rule.status === "connecting";
|
||||
const isActive = rule.status === "active";
|
||||
const label = rule.label || (rule.type === "dynamic"
|
||||
? `SOCKS:${rule.localPort}`
|
||||
: `${rule.localPort} → ${rule.remoteHost}:${rule.remotePort}`);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={rule.id}
|
||||
disabled={isConnecting}
|
||||
title={label}
|
||||
onClick={() => {
|
||||
const host = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
|
||||
if (!host) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
if (isActive) {
|
||||
void stopTunnel(rule.id);
|
||||
} else {
|
||||
void startTunnel(rule, host, keysForPf, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
|
||||
isConnecting ? "opacity-60" : "",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
status={
|
||||
rule.status === "active"
|
||||
? "success"
|
||||
: rule.status === "connecting"
|
||||
? "warning"
|
||||
: rule.status === "error"
|
||||
? "error"
|
||||
: "neutral"
|
||||
}
|
||||
spinning={rule.status === "connecting"}
|
||||
/>
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{t(`tray.status.${rule.status}`)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state - show when nothing is active */}
|
||||
{jumpableSessions.length === 0 && portForwardingRules.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<span className="text-2xl mb-2">😴</span>
|
||||
<span className="text-sm text-muted-foreground">{t("tray.empty.title")}</span>
|
||||
<span className="text-xs text-muted-foreground/60 mt-1">{t("tray.empty.subtitle")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quit button at the bottom */}
|
||||
<div className="px-3 py-2 border-t border-border/60">
|
||||
<button
|
||||
className="w-full text-left px-2 py-1.5 rounded hover:bg-destructive/10 flex items-center gap-2 text-sm text-muted-foreground hover:text-destructive transition-colors"
|
||||
onClick={handleQuit}
|
||||
>
|
||||
<Power size={14} />
|
||||
<span>{t("tray.quit")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TrayPanel: React.FC = () => {
|
||||
const settings = useSettingsState();
|
||||
return (
|
||||
<I18nProvider locale={settings.uiLanguage}>
|
||||
<TrayPanelContent />
|
||||
</I18nProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrayPanel;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -97,6 +97,8 @@ export const ExportKeyPanel: React.FC<ExportKeyPanelProps> = ({
|
||||
privateKey: hostPrivateKey,
|
||||
command,
|
||||
timeout: 30000,
|
||||
enableKeyboardInteractive: true,
|
||||
sessionId: `export-key:${exportHost.id}:${keyItem.id}`,
|
||||
});
|
||||
|
||||
// Check result
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Import Key Panel - Import existing SSH key
|
||||
*/
|
||||
|
||||
import { Upload } from 'lucide-react';
|
||||
import { Eye, EyeOff, Upload } from 'lucide-react';
|
||||
import React,{ useCallback,useRef } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { SSHKey } from '../../types';
|
||||
@@ -15,12 +15,16 @@ import { detectKeyType } from './utils';
|
||||
interface ImportKeyPanelProps {
|
||||
draftKey: Partial<SSHKey>;
|
||||
setDraftKey: (key: Partial<SSHKey>) => void;
|
||||
showPassphrase: boolean;
|
||||
setShowPassphrase: (show: boolean) => void;
|
||||
onImport: () => void;
|
||||
}
|
||||
|
||||
export const ImportKeyPanel: React.FC<ImportKeyPanelProps> = ({
|
||||
draftKey,
|
||||
setDraftKey,
|
||||
showPassphrase,
|
||||
setShowPassphrase,
|
||||
onImport,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -84,7 +88,6 @@ export const ImportKeyPanel: React.FC<ImportKeyPanelProps> = ({
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pem,.key,.pub,.ppk,*"
|
||||
className="hidden"
|
||||
onChange={handleFileImport}
|
||||
/>
|
||||
@@ -133,6 +136,41 @@ export const ImportKeyPanel: React.FC<ImportKeyPanelProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('terminal.auth.passphrase')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassphrase ? 'text' : 'password'}
|
||||
value={draftKey.passphrase || ''}
|
||||
onChange={e => setDraftKey({ ...draftKey, passphrase: e.target.value })}
|
||||
placeholder={t('keychain.generate.passphrasePlaceholder')}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8"
|
||||
onClick={() => setShowPassphrase(!showPassphrase)}
|
||||
>
|
||||
{showPassphrase ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="savePassphraseImport"
|
||||
checked={draftKey.savePassphrase || false}
|
||||
onChange={e => setDraftKey({ ...draftKey, savePassphrase: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<Label htmlFor="savePassphraseImport" className="text-sm font-normal cursor-pointer">
|
||||
{t('keychain.generate.savePassphrase')}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="border border-dashed border-border/80 rounded-xl p-4 text-center space-y-2 bg-background/60 transition-colors hover:border-primary/50"
|
||||
onDrop={handleDrop}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AsideActionMenu,AsideActionMenuItem,AsidePanel,AsidePanelContent,AsideP
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Switch } from '../ui/switch';
|
||||
|
||||
export interface EditPanelProps {
|
||||
rule: PortForwardingRule;
|
||||
@@ -152,6 +153,18 @@ export const EditPanel: React.FC<EditPanelProps> = ({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Auto Start Toggle */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">{t('pf.form.autoStart')}</Label>
|
||||
<p className="text-[10px] text-muted-foreground">{t('pf.form.autoStartDesc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={draft.autoStart ?? false}
|
||||
onCheckedChange={checked => onDraftChange({ autoStart: checked })}
|
||||
/>
|
||||
</div>
|
||||
</AsidePanelContent>
|
||||
<AsidePanelFooter className="space-y-2">
|
||||
<Button
|
||||
|
||||
@@ -13,6 +13,7 @@ import { AsidePanel,AsidePanelContent,AsidePanelFooter } from '../ui/aside-panel
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Switch } from '../ui/switch';
|
||||
import { getTypeLabel } from './utils';
|
||||
|
||||
export interface NewFormPanelProps {
|
||||
@@ -153,6 +154,18 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Auto Start Toggle */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">{t('pf.form.autoStart')}</Label>
|
||||
<p className="text-[10px] text-muted-foreground">{t('pf.form.autoStartDesc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={draft.autoStart ?? false}
|
||||
onCheckedChange={checked => onDraftChange({ autoStart: checked })}
|
||||
/>
|
||||
</div>
|
||||
</AsidePanelContent>
|
||||
<AsidePanelFooter className="space-y-2">
|
||||
<Button
|
||||
|
||||
@@ -5,16 +5,18 @@
|
||||
import { Copy,Loader2,Pencil,Play,Square,Trash2 } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { PortForwardingRule } from '../../domain/models';
|
||||
import { Host, PortForwardingRule } from '../../domain/models';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { ContextMenu,ContextMenuContent,ContextMenuItem,ContextMenuSeparator,ContextMenuTrigger } from '../ui/context-menu';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
|
||||
import { getStatusColor,getTypeColor } from './utils';
|
||||
|
||||
export type ViewMode = 'grid' | 'list';
|
||||
|
||||
export interface RuleCardProps {
|
||||
rule: PortForwardingRule;
|
||||
host?: Host; // The relay host for this rule (for tooltip display)
|
||||
viewMode: ViewMode;
|
||||
isSelected: boolean;
|
||||
isPending: boolean;
|
||||
@@ -28,6 +30,7 @@ export interface RuleCardProps {
|
||||
|
||||
export const RuleCard: React.FC<RuleCardProps> = ({
|
||||
rule,
|
||||
host,
|
||||
viewMode,
|
||||
isSelected,
|
||||
isPending,
|
||||
@@ -74,12 +77,39 @@ export const RuleCard: React.FC<RuleCardProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span className="truncate">
|
||||
{rule.type === 'dynamic'
|
||||
? t('pf.rule.summary.dynamic', { bindAddress: rule.bindAddress, localPort: rule.localPort })
|
||||
: t('pf.rule.summary.default', { bindAddress: rule.bindAddress, localPort: rule.localPort, remoteHost: rule.remoteHost, remotePort: rule.remotePort })
|
||||
}
|
||||
</span>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="truncate cursor-default">
|
||||
{rule.type === 'dynamic'
|
||||
? t('pf.rule.summary.dynamic', { bindAddress: rule.bindAddress, localPort: rule.localPort })
|
||||
: t('pf.rule.summary.default', { bindAddress: rule.bindAddress, localPort: rule.localPort, remoteHost: rule.remoteHost, remotePort: rule.remotePort })
|
||||
}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-xs">
|
||||
<div className="space-y-1 text-xs">
|
||||
{host ? (
|
||||
<>
|
||||
<div className="font-medium">{t('pf.tooltip.relayHost')}</div>
|
||||
<div>{t('pf.tooltip.hostLabel')}: {host.label}</div>
|
||||
<div>{t('pf.tooltip.hostAddress')}: {host.username}@{host.hostname}:{host.port}</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-muted-foreground">{t('pf.tooltip.noHost')}</div>
|
||||
)}
|
||||
<div className="border-t border-border/40 pt-1 mt-1">
|
||||
{rule.type === 'dynamic'
|
||||
? t('pf.tooltip.dynamicDesc')
|
||||
: rule.type === 'local'
|
||||
? t('pf.tooltip.localDesc')
|
||||
: t('pf.tooltip.remoteDesc')
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
|
||||
77
components/settings/FontSelect.tsx
Normal file
77
components/settings/FontSelect.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { UIFont } from '../../infrastructure/config/uiFonts';
|
||||
|
||||
interface FontSelectProps {
|
||||
value: string;
|
||||
fonts: UIFont[];
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const FontSelect: React.FC<FontSelectProps> = ({
|
||||
value,
|
||||
fonts,
|
||||
onChange,
|
||||
className,
|
||||
disabled,
|
||||
}) => {
|
||||
const selectedFont = fonts.find(f => f.id === value);
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Root value={value} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectPrimitive.Trigger
|
||||
className={cn(
|
||||
'flex h-9 items-center justify-between rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<SelectPrimitive.Value>
|
||||
<span style={{ fontFamily: selectedFont?.family }}>
|
||||
{selectedFont?.name || value}
|
||||
</span>
|
||||
</SelectPrimitive.Value>
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
className="z-[200000] max-h-80 min-w-[12rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
|
||||
position="popper"
|
||||
sideOffset={4}
|
||||
>
|
||||
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
<SelectPrimitive.Viewport className="p-1 h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]">
|
||||
{fonts.map((font) => (
|
||||
<SelectPrimitive.Item
|
||||
key={font.id}
|
||||
value={font.id}
|
||||
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>
|
||||
<span style={{ fontFamily: font.family }}>{font.name}</span>
|
||||
</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
</SelectPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default FontSelect;
|
||||
77
components/settings/TerminalFontSelect.tsx
Normal file
77
components/settings/TerminalFontSelect.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { TerminalFont } from '../../infrastructure/config/fonts';
|
||||
|
||||
interface TerminalFontSelectProps {
|
||||
value: string;
|
||||
fonts: TerminalFont[];
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const TerminalFontSelect: React.FC<TerminalFontSelectProps> = ({
|
||||
value,
|
||||
fonts,
|
||||
onChange,
|
||||
className,
|
||||
disabled,
|
||||
}) => {
|
||||
const selectedFont = fonts.find(f => f.id === value);
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Root value={value} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectPrimitive.Trigger
|
||||
className={cn(
|
||||
'flex h-9 items-center justify-between rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<SelectPrimitive.Value>
|
||||
<span style={{ fontFamily: selectedFont?.family }}>
|
||||
{selectedFont?.name || value}
|
||||
</span>
|
||||
</SelectPrimitive.Value>
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
className="z-[200000] max-h-80 min-w-[14rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
|
||||
position="popper"
|
||||
sideOffset={4}
|
||||
>
|
||||
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
<SelectPrimitive.Viewport className="p-1 h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]">
|
||||
{fonts.map((font) => (
|
||||
<SelectPrimitive.Item
|
||||
key={font.id}
|
||||
value={font.id}
|
||||
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>
|
||||
<span style={{ fontFamily: font.family }}>{font.name}</span>
|
||||
</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
</SelectPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalFontSelect;
|
||||
186
components/settings/ThemeSelectModal.tsx
Normal file
186
components/settings/ThemeSelectModal.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Theme Select Modal
|
||||
* A modal dialog for selecting terminal themes in settings
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Check, Palette, X } from 'lucide-react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
|
||||
import { Button } from '../ui/button';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
// Memoized theme item component to prevent unnecessary re-renders
|
||||
const ThemeItem = memo(({
|
||||
theme,
|
||||
isSelected,
|
||||
onSelect
|
||||
}: {
|
||||
theme: TerminalThemeConfig;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) => (
|
||||
<button
|
||||
onClick={() => onSelect(theme.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all',
|
||||
isSelected
|
||||
? 'bg-primary/15 ring-1 ring-primary'
|
||||
: 'hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
{/* Color swatch preview */}
|
||||
<div
|
||||
className="w-12 h-8 rounded-md flex-shrink-0 flex flex-col justify-center items-start pl-1.5 gap-0.5 border border-border/50"
|
||||
style={{ backgroundColor: theme.colors.background }}
|
||||
>
|
||||
<div className="h-1 w-4 rounded-full" style={{ backgroundColor: theme.colors.green }} />
|
||||
<div className="h-1 w-6 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
|
||||
<div className="h-1 w-3 rounded-full" style={{ backgroundColor: theme.colors.yellow }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn('text-sm font-medium truncate', isSelected ? 'text-primary' : 'text-foreground')}>
|
||||
{theme.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground capitalize">{theme.type}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check size={16} className="text-primary flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
));
|
||||
ThemeItem.displayName = 'ThemeItem';
|
||||
|
||||
interface ThemeSelectModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
selectedThemeId: string;
|
||||
onSelect: (themeId: string) => void;
|
||||
}
|
||||
|
||||
export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
selectedThemeId,
|
||||
onSelect,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
// Group themes by type
|
||||
const { darkThemes, lightThemes } = useMemo(() => {
|
||||
const dark = TERMINAL_THEMES.filter(t => t.type === 'dark');
|
||||
const light = TERMINAL_THEMES.filter(t => t.type === 'light');
|
||||
return { darkThemes: dark, lightThemes: light };
|
||||
}, []);
|
||||
|
||||
// Handle theme selection - select and close
|
||||
const handleThemeSelect = useCallback((themeId: string) => {
|
||||
onSelect(themeId);
|
||||
onClose();
|
||||
}, [onSelect, onClose]);
|
||||
|
||||
// Handle ESC key
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Handle backdrop click
|
||||
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}, [onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const modalTitleId = 'theme-select-modal-title';
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/60"
|
||||
style={{ zIndex: 99999 }}
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={modalTitleId}
|
||||
>
|
||||
<div
|
||||
className="w-[480px] max-h-[600px] bg-background border border-border rounded-2xl shadow-2xl flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 shrink-0 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-primary/10">
|
||||
<Palette size={16} className="text-primary" />
|
||||
</div>
|
||||
<h2 id={modalTitleId} className="text-sm font-semibold text-foreground">{t('settings.terminal.themeModal.title')}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Theme List */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-4">
|
||||
{/* Dark Themes Section */}
|
||||
<div className="mb-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
|
||||
{t('settings.terminal.themeModal.darkThemes')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{darkThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Light Themes Section */}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
|
||||
{t('settings.terminal.themeModal.lightThemes')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{lightThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end px-5 py-3 shrink-0 border-t border-border bg-muted/20">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Use Portal to render at document root
|
||||
return createPortal(modalContent, document.body);
|
||||
};
|
||||
|
||||
export default ThemeSelectModal;
|
||||
@@ -2,9 +2,11 @@ import React, { useCallback } from "react";
|
||||
import { Check, Moon, Palette, Sun } from "lucide-react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES } from "../../../infrastructure/config/uiThemes";
|
||||
import { useAvailableUIFonts } from "../../../application/state/uiFontStore";
|
||||
import { SUPPORTED_UI_LOCALES } from "../../../infrastructure/config/i18n";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { SectionHeader, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui";
|
||||
import { FontSelect } from "../FontSelect";
|
||||
|
||||
export default function SettingsAppearanceTab(props: {
|
||||
theme: "dark" | "light";
|
||||
@@ -17,12 +19,15 @@ export default function SettingsAppearanceTab(props: {
|
||||
setAccentMode: (mode: "theme" | "custom") => void;
|
||||
customAccent: string;
|
||||
setCustomAccent: (color: string) => void;
|
||||
uiFontFamilyId: string;
|
||||
setUiFontFamilyId: (fontId: string) => void;
|
||||
uiLanguage: string;
|
||||
setUiLanguage: (language: string) => void;
|
||||
customCSS: string;
|
||||
setCustomCSS: (css: string) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const availableUIFonts = useAvailableUIFonts();
|
||||
const {
|
||||
theme,
|
||||
setTheme,
|
||||
@@ -34,6 +39,8 @@ export default function SettingsAppearanceTab(props: {
|
||||
setAccentMode,
|
||||
customAccent,
|
||||
setCustomAccent,
|
||||
uiFontFamilyId,
|
||||
setUiFontFamilyId,
|
||||
uiLanguage,
|
||||
setUiLanguage,
|
||||
customCSS,
|
||||
@@ -130,6 +137,17 @@ export default function SettingsAppearanceTab(props: {
|
||||
className="w-40"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.appearance.uiFont")}
|
||||
description={t("settings.appearance.uiFont.desc")}
|
||||
>
|
||||
<FontSelect
|
||||
value={uiFontFamilyId}
|
||||
fonts={availableUIFonts}
|
||||
onChange={(v) => setUiFontFamilyId(v)}
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.appearance.uiTheme")} />
|
||||
|
||||
330
components/settings/tabs/SettingsFileAssociationsTab.tsx
Normal file
330
components/settings/tabs/SettingsFileAssociationsTab.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* SettingsFileAssociationsTab - Manage SFTP file opener associations and behavior
|
||||
*/
|
||||
import { FileType, Pencil, Trash2 } from "lucide-react";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { useSftpFileAssociations } from "../../../application/state/useSftpFileAssociations";
|
||||
import { useSettingsState } from "../../../application/state/useSettingsState";
|
||||
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Label } from "../../ui/label";
|
||||
import { SectionHeader, SettingsTabContent } from "../settings-ui";
|
||||
|
||||
const getOpenerLabel = (
|
||||
openerType: FileOpenerType,
|
||||
systemApp: SystemAppInfo | undefined,
|
||||
t: (key: string) => string
|
||||
): string => {
|
||||
if (openerType === 'builtin-editor') {
|
||||
return t('sftp.opener.builtInEditor');
|
||||
} else if (openerType === 'system-app' && systemApp) {
|
||||
return systemApp.name;
|
||||
}
|
||||
return openerType;
|
||||
};
|
||||
|
||||
export default function SettingsFileAssociationsTab() {
|
||||
const { t } = useI18n();
|
||||
const { getAllAssociations, removeAssociation, setOpenerForExtension } = useSftpFileAssociations();
|
||||
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload } = useSettingsState();
|
||||
const associations = getAllAssociations();
|
||||
const [editingExtension, setEditingExtension] = useState<string | null>(null);
|
||||
|
||||
// Debug log for Settings page
|
||||
console.log('[SettingsFileAssociationsTab] Rendering with', associations.length, 'associations:', associations);
|
||||
|
||||
const handleRemove = useCallback((extension: string) => {
|
||||
if (confirm(t('settings.sftpFileAssociations.removeConfirm', { ext: extension === 'file' ? t('sftp.opener.noExtension') : extension }))) {
|
||||
removeAssociation(extension);
|
||||
}
|
||||
}, [removeAssociation, t]);
|
||||
|
||||
const handleEdit = useCallback(async (extension: string) => {
|
||||
setEditingExtension(extension);
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.selectApplication) {
|
||||
return;
|
||||
}
|
||||
const result = await bridge.selectApplication();
|
||||
if (result) {
|
||||
setOpenerForExtension(extension, 'system-app', { path: result.path, name: result.name });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to select application:', e);
|
||||
} finally {
|
||||
setEditingExtension(null);
|
||||
}
|
||||
}, [setOpenerForExtension]);
|
||||
|
||||
return (
|
||||
<SettingsTabContent value="file-associations">
|
||||
<div className="space-y-8">
|
||||
{/* Double-click behavior section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftp.doubleClickBehavior')} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.doubleClickBehavior.desc')}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => setSftpDoubleClickBehavior('open')}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
sftpDoubleClickBehavior === 'open'
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
sftpDoubleClickBehavior === 'open'
|
||||
? "border-primary"
|
||||
: "border-muted-foreground/30"
|
||||
)}>
|
||||
{sftpDoubleClickBehavior === 'open' && (
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{t('settings.sftp.doubleClickBehavior.open')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.doubleClickBehavior.openDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSftpDoubleClickBehavior('transfer')}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
sftpDoubleClickBehavior === 'transfer'
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
sftpDoubleClickBehavior === 'transfer'
|
||||
? "border-primary"
|
||||
: "border-muted-foreground/30"
|
||||
)}>
|
||||
{sftpDoubleClickBehavior === 'transfer' && (
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{t('settings.sftp.doubleClickBehavior.transfer')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.doubleClickBehavior.transferDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-sync section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftp.autoSync')} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.autoSync.desc')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSftpAutoSync(!sftpAutoSync)}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
sftpAutoSync
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
sftpAutoSync
|
||||
? "border-primary bg-primary"
|
||||
: "border-muted-foreground/30"
|
||||
)}>
|
||||
{sftpAutoSync && (
|
||||
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{t('settings.sftp.autoSync.enable')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.autoSync.enableDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Show hidden files section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftp.showHiddenFiles')} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.showHiddenFiles.desc')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSftpShowHiddenFiles(!sftpShowHiddenFiles)}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
sftpShowHiddenFiles
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
sftpShowHiddenFiles
|
||||
? "border-primary bg-primary"
|
||||
: "border-muted-foreground/30"
|
||||
)}>
|
||||
{sftpShowHiddenFiles && (
|
||||
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{t('settings.sftp.showHiddenFiles.enable')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.showHiddenFiles.enableDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Compressed folder upload section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftp.compressedUpload')} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.compressedUpload.desc')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSftpUseCompressedUpload(!sftpUseCompressedUpload)}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
sftpUseCompressedUpload
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
sftpUseCompressedUpload
|
||||
? "border-primary bg-primary"
|
||||
: "border-muted-foreground/30"
|
||||
)}>
|
||||
{sftpUseCompressedUpload && (
|
||||
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{t('settings.sftp.compressedUpload.enable')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.compressedUpload.enableDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* File associations section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftpFileAssociations.title')} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftpFileAssociations.desc')}
|
||||
</p>
|
||||
|
||||
{associations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<FileType size={48} strokeWidth={1} className="mb-4 opacity-50" />
|
||||
<p className="text-sm">{t('settings.sftpFileAssociations.noAssociations')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-border rounded-md overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/50 border-b border-border">
|
||||
<th className="text-left px-4 py-2 font-medium">
|
||||
{t('settings.sftpFileAssociations.extension')}
|
||||
</th>
|
||||
<th className="text-left px-4 py-2 font-medium">
|
||||
{t('settings.sftpFileAssociations.application')}
|
||||
</th>
|
||||
<th className="text-right px-4 py-2 font-medium w-28">
|
||||
{/* Actions */}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{associations.map(({ extension, openerType, systemApp }) => (
|
||||
<tr key={extension} className="border-b border-border last:border-b-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3">
|
||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
{extension === 'file' ? t('sftp.opener.noExtension') : `.${extension}`}
|
||||
</code>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{openerType === 'system-app' && systemApp ? (
|
||||
<span title={systemApp.path}>{systemApp.name}</span>
|
||||
) : (
|
||||
getOpenerLabel(openerType, systemApp, t)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleEdit(extension)}
|
||||
disabled={editingExtension === extension}
|
||||
title={t('common.edit')}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleRemove(extension)}
|
||||
title={t('settings.sftpFileAssociations.remove')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
}
|
||||
@@ -115,7 +115,7 @@ export default function SettingsShortcutsTab(props: {
|
||||
};
|
||||
}, [recordingBindingId, recordingScheme, setIsHotkeyRecording]);
|
||||
|
||||
const categories = useMemo(() => ["tabs", "terminal", "navigation", "app"] as const, []);
|
||||
const categories = useMemo(() => ["tabs", "terminal", "navigation", "app", "sftp"] as const, []);
|
||||
|
||||
return (
|
||||
<SettingsTabContent value="shortcuts">
|
||||
|
||||
500
components/settings/tabs/SettingsSystemTab.tsx
Normal file
500
components/settings/tabs/SettingsSystemTab.tsx
Normal file
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* Settings System Tab - System information, temp file management, session logs, and global hotkey
|
||||
*/
|
||||
import { FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { SessionLogFormat, keyEventToString } from "../../../domain/models";
|
||||
import { TabsContent } from "../../ui/tabs";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Toggle, Select, SettingRow } from "../settings-ui";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
interface TempDirInfo {
|
||||
path: string;
|
||||
fileCount: number;
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
interface SettingsSystemTabProps {
|
||||
sessionLogsEnabled: boolean;
|
||||
setSessionLogsEnabled: (enabled: boolean) => void;
|
||||
sessionLogsDir: string;
|
||||
setSessionLogsDir: (dir: string) => void;
|
||||
sessionLogsFormat: SessionLogFormat;
|
||||
setSessionLogsFormat: (format: SessionLogFormat) => void;
|
||||
toggleWindowHotkey: string;
|
||||
setToggleWindowHotkey: (hotkey: string) => void;
|
||||
closeToTray: boolean;
|
||||
setCloseToTray: (enabled: boolean) => void;
|
||||
hotkeyRegistrationError: string | null;
|
||||
}
|
||||
|
||||
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
sessionLogsEnabled,
|
||||
setSessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
setSessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
setSessionLogsFormat,
|
||||
toggleWindowHotkey,
|
||||
setToggleWindowHotkey,
|
||||
closeToTray,
|
||||
setCloseToTray,
|
||||
hotkeyRegistrationError,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
|
||||
|
||||
const [tempDirInfo, setTempDirInfo] = useState<TempDirInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [clearResult, setClearResult] = useState<{ deletedCount: number; failedCount: number } | null>(null);
|
||||
const [isRecordingHotkey, setIsRecordingHotkey] = useState(false);
|
||||
const [hotkeyError, setHotkeyError] = useState<string | null>(null);
|
||||
const [credentialsAvailable, setCredentialsAvailable] = useState<boolean | null>(null);
|
||||
const [isCheckingCredentials, setIsCheckingCredentials] = useState(false);
|
||||
|
||||
const loadTempDirInfo = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getTempDirInfo) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const info = await bridge.getTempDirInfo();
|
||||
setTempDirInfo(info);
|
||||
} catch (err) {
|
||||
console.error("[SettingsSystemTab] Failed to get temp dir info:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTempDirInfo();
|
||||
}, [loadTempDirInfo]);
|
||||
|
||||
const loadCredentialProtectionStatus = useCallback(async () => {
|
||||
setIsCheckingCredentials(true);
|
||||
try {
|
||||
const available = await getCredentialProtectionAvailability();
|
||||
setCredentialsAvailable(available);
|
||||
} finally {
|
||||
setIsCheckingCredentials(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCredentialProtectionStatus();
|
||||
}, [loadCredentialProtectionStatus]);
|
||||
|
||||
const handleClearTempFiles = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.clearTempDir) return;
|
||||
|
||||
setIsClearing(true);
|
||||
setClearResult(null);
|
||||
try {
|
||||
const result = await bridge.clearTempDir();
|
||||
setClearResult(result);
|
||||
// Refresh info after clearing
|
||||
await loadTempDirInfo();
|
||||
} catch (err) {
|
||||
console.error("[SettingsSystemTab] Failed to clear temp dir:", err);
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
}, [loadTempDirInfo]);
|
||||
|
||||
const handleOpenTempDir = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!tempDirInfo?.path || !bridge?.openTempDir) return;
|
||||
await bridge.openTempDir();
|
||||
}, [tempDirInfo]);
|
||||
|
||||
const handleSelectSessionLogsDir = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.selectSessionLogsDir) return;
|
||||
|
||||
try {
|
||||
const result = await bridge.selectSessionLogsDir();
|
||||
if (result.success && result.directory) {
|
||||
setSessionLogsDir(result.directory);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[SettingsSystemTab] Failed to select directory:", err);
|
||||
}
|
||||
}, [setSessionLogsDir]);
|
||||
|
||||
const handleOpenSessionLogsDir = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!sessionLogsDir || !bridge?.openSessionLogsDir) return;
|
||||
|
||||
try {
|
||||
await bridge.openSessionLogsDir(sessionLogsDir);
|
||||
} catch (err) {
|
||||
console.error("[SettingsSystemTab] Failed to open directory:", err);
|
||||
}
|
||||
}, [sessionLogsDir]);
|
||||
|
||||
// Handle global toggle hotkey recording
|
||||
const cancelHotkeyRecording = useCallback(() => {
|
||||
setIsRecordingHotkey(false);
|
||||
}, []);
|
||||
|
||||
const handleResetHotkey = useCallback(() => {
|
||||
// Reset to default hotkey (Ctrl+` or ⌃+` on Mac)
|
||||
const defaultHotkey = isMac ? '⌃ + `' : 'Ctrl + `';
|
||||
setToggleWindowHotkey(defaultHotkey);
|
||||
setHotkeyError(null);
|
||||
}, [isMac, setToggleWindowHotkey]);
|
||||
|
||||
// Hotkey recording effect
|
||||
useEffect(() => {
|
||||
if (!isRecordingHotkey) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === "Escape") {
|
||||
cancelHotkeyRecording();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore modifier-only keys
|
||||
if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) return;
|
||||
|
||||
const keyString = keyEventToString(e, isMac);
|
||||
setToggleWindowHotkey(keyString);
|
||||
setHotkeyError(null);
|
||||
cancelHotkeyRecording();
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
cancelHotkeyRecording();
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
window.addEventListener("click", handleClick, true);
|
||||
}, 100);
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener("keydown", handleKeyDown, true);
|
||||
window.removeEventListener("click", handleClick, true);
|
||||
};
|
||||
}, [isRecordingHotkey, isMac, setToggleWindowHotkey, cancelHotkeyRecording]);
|
||||
|
||||
const formatOptions = [
|
||||
{ value: "txt", label: t("settings.sessionLogs.formatTxt") },
|
||||
{ value: "raw", label: t("settings.sessionLogs.formatRaw") },
|
||||
{ value: "html", label: t("settings.sessionLogs.formatHtml") },
|
||||
];
|
||||
|
||||
return (
|
||||
<TabsContent
|
||||
value="system"
|
||||
className="data-[state=inactive]:hidden h-full flex flex-col"
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden px-8 py-6">
|
||||
<div className="max-w-2xl space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t("settings.system.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("settings.system.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Credential Protection Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t("settings.system.credentials.title")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.system.credentials.status")}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium mt-1",
|
||||
credentialsAvailable === true && "text-emerald-600 dark:text-emerald-400",
|
||||
credentialsAvailable === false && "text-amber-600 dark:text-amber-400",
|
||||
)}
|
||||
>
|
||||
{isCheckingCredentials
|
||||
? t("settings.system.credentials.checking")
|
||||
: credentialsAvailable === true
|
||||
? t("settings.system.credentials.available")
|
||||
: credentialsAvailable === false
|
||||
? t("settings.system.credentials.unavailable")
|
||||
: t("settings.system.credentials.unknown")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadCredentialProtectionStatus}
|
||||
disabled={isCheckingCredentials}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw size={14} className={isCheckingCredentials ? "animate-spin" : ""} />
|
||||
{t("settings.system.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{credentialsAvailable === false && (
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400">
|
||||
{t("settings.system.credentials.unavailableHint")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.system.credentials.portabilityHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Temp Directory Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t("settings.system.tempDirectory")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
|
||||
{/* Path */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-muted-foreground">{t("settings.system.location")}</p>
|
||||
<p className="text-sm font-mono mt-1 break-all">
|
||||
{isLoading ? "..." : (tempDirInfo?.path ?? "-")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={handleOpenTempDir}
|
||||
disabled={!tempDirInfo?.path}
|
||||
title={t("settings.system.openFolder")}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("settings.system.fileCount")}:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{isLoading ? "..." : (tempDirInfo?.fileCount ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("settings.system.totalSize")}:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{isLoading ? "..." : formatBytes(tempDirInfo?.totalSize ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadTempDirInfo}
|
||||
disabled={isLoading}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw size={14} className={isLoading ? "animate-spin" : ""} />
|
||||
{t("settings.system.refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearTempFiles}
|
||||
disabled={isClearing || (tempDirInfo?.fileCount ?? 0) === 0}
|
||||
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{isClearing ? t("settings.system.clearing") : t("settings.system.clearTempFiles")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Clear Result */}
|
||||
{clearResult && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.system.clearResult", {
|
||||
deleted: clearResult.deletedCount,
|
||||
failed: clearResult.failedCount,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.system.tempDirectoryHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Session Logs Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t("settings.sessionLogs.title")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-4">
|
||||
{/* Enable Toggle */}
|
||||
<SettingRow
|
||||
label={t("settings.sessionLogs.enableAutoSave")}
|
||||
description={t("settings.sessionLogs.enableAutoSaveDesc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={sessionLogsEnabled}
|
||||
onChange={setSessionLogsEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{/* Directory Selection */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{t("settings.sessionLogs.directory")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="bg-background border border-input rounded-md px-3 py-2 text-sm font-mono truncate">
|
||||
{sessionLogsDir || t("settings.sessionLogs.noDirectory")}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSelectSessionLogsDir}
|
||||
className="shrink-0"
|
||||
>
|
||||
{t("settings.sessionLogs.browse")}
|
||||
</Button>
|
||||
{sessionLogsDir && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleOpenSessionLogsDir}
|
||||
className="shrink-0"
|
||||
title={t("settings.sessionLogs.openFolder")}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.sessionLogs.directoryHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Format Selection */}
|
||||
<SettingRow
|
||||
label={t("settings.sessionLogs.format")}
|
||||
description={t("settings.sessionLogs.formatDesc")}
|
||||
>
|
||||
<Select
|
||||
value={sessionLogsFormat}
|
||||
options={formatOptions}
|
||||
onChange={(val) => setSessionLogsFormat(val as SessionLogFormat)}
|
||||
className="w-32"
|
||||
disabled={!sessionLogsEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.sessionLogs.hint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Global Toggle Window Section (Quake Mode) */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Keyboard size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t("settings.globalHotkey.title")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-4">
|
||||
{/* Toggle Window Hotkey */}
|
||||
<SettingRow
|
||||
label={t("settings.globalHotkey.toggleWindow")}
|
||||
description={t("settings.globalHotkey.toggleWindowDesc")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRecordingHotkey(true);
|
||||
}}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-mono rounded border transition-colors min-w-[100px] text-center",
|
||||
isRecordingHotkey
|
||||
? "border-primary bg-primary/10 animate-pulse"
|
||||
: "border-border hover:border-primary/50",
|
||||
)}
|
||||
>
|
||||
{isRecordingHotkey
|
||||
? t("settings.shortcuts.recording")
|
||||
: toggleWindowHotkey || t("settings.globalHotkey.notSet")}
|
||||
</button>
|
||||
{toggleWindowHotkey && (
|
||||
<button
|
||||
onClick={handleResetHotkey}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
title={t("settings.globalHotkey.reset")}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
{(hotkeyError || hotkeyRegistrationError) && (
|
||||
<p className="text-sm text-destructive">{hotkeyError || hotkeyRegistrationError}</p>
|
||||
)}
|
||||
|
||||
{/* Close to Tray */}
|
||||
<SettingRow
|
||||
label={t("settings.globalHotkey.closeToTray")}
|
||||
description={t("settings.globalHotkey.closeToTrayDesc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={closeToTray}
|
||||
onChange={setCloseToTray}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.globalHotkey.hint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsSystemTab;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { Check, Minus, Plus, RotateCcw } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState, useMemo } from "react";
|
||||
import { AlertCircle, ChevronRight, Minus, Plus, RotateCcw } from "lucide-react";
|
||||
import type {
|
||||
CursorShape,
|
||||
LinkModifier,
|
||||
@@ -9,65 +9,64 @@ import type {
|
||||
} from "../../../domain/models";
|
||||
import { DEFAULT_KEYWORD_HIGHLIGHT_RULES } from "../../../domain/models";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { TERMINAL_FONTS, MAX_FONT_SIZE, MIN_FONT_SIZE } from "../../../infrastructure/config/fonts";
|
||||
import { MAX_FONT_SIZE, MIN_FONT_SIZE, type TerminalFont } from "../../../infrastructure/config/fonts";
|
||||
import { TERMINAL_THEMES } from "../../../infrastructure/config/terminalThemes";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Input } from "../../ui/input";
|
||||
import { Label } from "../../ui/label";
|
||||
import { SectionHeader, Select, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
|
||||
import { ThemeSelectModal } from "../ThemeSelectModal";
|
||||
import { TerminalFontSelect } from "../TerminalFontSelect";
|
||||
|
||||
// Helper: render terminal preview
|
||||
const renderTerminalPreview = (theme: (typeof TERMINAL_THEMES)[0]) => {
|
||||
// Theme preview button component
|
||||
const ThemePreviewButton: React.FC<{
|
||||
theme: (typeof TERMINAL_THEMES)[0];
|
||||
onClick: () => void;
|
||||
buttonLabel: string;
|
||||
}> = ({ theme, onClick, buttonLabel }) => {
|
||||
const c = theme.colors;
|
||||
const lines = [
|
||||
{ prompt: "~", cmd: "ssh prod-server", color: c.foreground },
|
||||
{ prompt: "prod", cmd: "ls -la", color: c.green },
|
||||
{ prompt: "prod", cmd: "cat config.json", color: c.cyan },
|
||||
];
|
||||
return (
|
||||
<div
|
||||
className="font-mono text-[9px] leading-tight p-1.5 rounded overflow-hidden h-full"
|
||||
style={{ backgroundColor: c.background, color: c.foreground }}
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-4 p-3 rounded-lg border bg-card hover:bg-accent/50 transition-all text-left",
|
||||
)}
|
||||
>
|
||||
{lines.map((l, i) => (
|
||||
<div key={i} className="flex gap-1 truncate">
|
||||
<span style={{ color: c.blue }}>{l.prompt}</span>
|
||||
<span style={{ color: c.magenta }}>$</span>
|
||||
<span style={{ color: l.color }}>{l.cmd}</span>
|
||||
{/* Theme preview swatch */}
|
||||
<div
|
||||
className="w-20 h-14 rounded-lg flex-shrink-0 flex flex-col justify-center items-start pl-2 gap-0.5 border border-border/50"
|
||||
style={{ backgroundColor: c.background }}
|
||||
>
|
||||
<div className="flex gap-1 items-center">
|
||||
<span className="font-mono text-[8px]" style={{ color: c.green }}>$</span>
|
||||
<span className="font-mono text-[8px]" style={{ color: c.blue }}>ls</span>
|
||||
</div>
|
||||
<div className="flex gap-0.5">
|
||||
<div className="h-1 w-3 rounded-full" style={{ backgroundColor: c.cyan }} />
|
||||
<div className="h-1 w-4 rounded-full" style={{ backgroundColor: c.magenta }} />
|
||||
</div>
|
||||
<div className="flex gap-1 items-center">
|
||||
<span className="font-mono text-[8px]" style={{ color: c.green }}>$</span>
|
||||
<span className="inline-block w-1.5 h-2 animate-pulse" style={{ backgroundColor: c.cursor }} />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-1">
|
||||
<span style={{ color: c.blue }}>~</span>
|
||||
<span style={{ color: c.magenta }}>$</span>
|
||||
<span className="inline-block w-1.5 h-2.5 animate-pulse" style={{ backgroundColor: c.cursor }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">{theme.name}</div>
|
||||
<div className="text-xs text-muted-foreground capitalize">{theme.type}</div>
|
||||
</div>
|
||||
|
||||
{/* Action button area */}
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className="text-xs">{buttonLabel}</span>
|
||||
<ChevronRight size={16} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const TerminalThemeCard: React.FC<{
|
||||
theme: (typeof TERMINAL_THEMES)[0];
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}> = ({ theme, active, onClick }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"relative flex flex-col rounded-lg border-2 transition-all overflow-hidden text-left",
|
||||
active ? "border-primary ring-2 ring-primary/20" : "border-border hover:border-primary/50",
|
||||
)}
|
||||
>
|
||||
<div className="h-16">{renderTerminalPreview(theme)}</div>
|
||||
<div className="px-2 py-1.5 text-xs font-medium border-t bg-card">{theme.name}</div>
|
||||
{active && (
|
||||
<div className="absolute top-1 right-1 w-4 h-4 bg-primary rounded-full flex items-center justify-center">
|
||||
<Check size={10} className="text-primary-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
export default function SettingsTerminalTab(props: {
|
||||
terminalThemeId: string;
|
||||
setTerminalThemeId: (id: string) => void;
|
||||
@@ -80,6 +79,7 @@ export default function SettingsTerminalTab(props: {
|
||||
key: K,
|
||||
value: TerminalSettings[K],
|
||||
) => void;
|
||||
availableFonts: TerminalFont[];
|
||||
}) {
|
||||
const {
|
||||
terminalThemeId,
|
||||
@@ -90,9 +90,97 @@ export default function SettingsTerminalTab(props: {
|
||||
setTerminalFontSize,
|
||||
terminalSettings,
|
||||
updateTerminalSetting,
|
||||
availableFonts,
|
||||
} = props;
|
||||
const { t } = useI18n();
|
||||
|
||||
// Local shell settings state
|
||||
const [defaultShell, setDefaultShell] = useState<string>("");
|
||||
const [shellValidation, setShellValidation] = useState<{ valid: boolean; message?: string } | null>(null);
|
||||
const [dirValidation, setDirValidation] = useState<{ valid: boolean; message?: string } | null>(null);
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false);
|
||||
|
||||
// Get current selected theme
|
||||
const currentTheme = useMemo(() => {
|
||||
return TERMINAL_THEMES.find(t => t.id === terminalThemeId) || TERMINAL_THEMES[0];
|
||||
}, [terminalThemeId]);
|
||||
|
||||
// Fetch default shell on mount
|
||||
useEffect(() => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (bridge?.getDefaultShell) {
|
||||
bridge.getDefaultShell().then((shell) => {
|
||||
setDefaultShell(shell);
|
||||
}).catch(() => {
|
||||
// Ignore errors - might not be in Electron
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Validate shell path when it changes
|
||||
useEffect(() => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
const shellPath = terminalSettings.localShell;
|
||||
|
||||
if (!shellPath) {
|
||||
setShellValidation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bridge?.validatePath) {
|
||||
setShellValidation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
bridge.validatePath(shellPath, 'file').then((result) => {
|
||||
if (result.exists && result.isFile) {
|
||||
setShellValidation({ valid: true });
|
||||
} else if (result.exists && result.isDirectory) {
|
||||
setShellValidation({ valid: false, message: t("settings.terminal.localShell.shell.isDirectory") });
|
||||
} else {
|
||||
setShellValidation({ valid: false, message: t("settings.terminal.localShell.shell.notFound") });
|
||||
}
|
||||
}).catch(() => {
|
||||
setShellValidation(null);
|
||||
});
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [terminalSettings.localShell, t]);
|
||||
|
||||
// Validate directory path when it changes
|
||||
useEffect(() => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
const dirPath = terminalSettings.localStartDir;
|
||||
|
||||
if (!dirPath) {
|
||||
setDirValidation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bridge?.validatePath) {
|
||||
setDirValidation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
bridge.validatePath(dirPath, 'directory').then((result) => {
|
||||
if (result.exists && result.isDirectory) {
|
||||
setDirValidation({ valid: true });
|
||||
} else if (result.exists && result.isFile) {
|
||||
setDirValidation({ valid: false, message: t("settings.terminal.localShell.startDir.isFile") });
|
||||
} else {
|
||||
setDirValidation({ valid: false, message: t("settings.terminal.localShell.startDir.notFound") });
|
||||
}
|
||||
}).catch(() => {
|
||||
setDirValidation(null);
|
||||
});
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [terminalSettings.localStartDir, t]);
|
||||
|
||||
const clampFontSize = useCallback((next: number) => {
|
||||
const safe = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, next));
|
||||
setTerminalFontSize(safe);
|
||||
@@ -101,16 +189,18 @@ export default function SettingsTerminalTab(props: {
|
||||
return (
|
||||
<SettingsTabContent value="terminal">
|
||||
<SectionHeader title={t("settings.terminal.section.theme")} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{TERMINAL_THEMES.map((t) => (
|
||||
<TerminalThemeCard
|
||||
key={t.id}
|
||||
theme={t}
|
||||
active={terminalThemeId === t.id}
|
||||
onClick={() => setTerminalThemeId(t.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ThemePreviewButton
|
||||
theme={currentTheme}
|
||||
onClick={() => setThemeModalOpen(true)}
|
||||
buttonLabel={t("settings.terminal.theme.selectButton")}
|
||||
/>
|
||||
|
||||
<ThemeSelectModal
|
||||
open={themeModalOpen}
|
||||
onClose={() => setThemeModalOpen(false)}
|
||||
selectedThemeId={terminalThemeId}
|
||||
onSelect={setTerminalThemeId}
|
||||
/>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.font")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
@@ -118,11 +208,11 @@ export default function SettingsTerminalTab(props: {
|
||||
label={t("settings.terminal.font.family")}
|
||||
description={t("settings.terminal.font.family.desc")}
|
||||
>
|
||||
<Select
|
||||
<TerminalFontSelect
|
||||
value={terminalFontFamilyId}
|
||||
options={TERMINAL_FONTS.map((f) => ({ value: f.id, label: f.name }))}
|
||||
fonts={availableFonts}
|
||||
onChange={(id) => setTerminalFontFamilyId(id)}
|
||||
className="w-40"
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
@@ -443,6 +533,138 @@ export default function SettingsTerminalTab(props: {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.localShell")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
label={t("settings.terminal.localShell.shell")}
|
||||
description={t("settings.terminal.localShell.shell.desc")}
|
||||
>
|
||||
<div className="flex flex-col gap-1 items-end">
|
||||
<Input
|
||||
value={terminalSettings.localShell}
|
||||
placeholder={t("settings.terminal.localShell.shell.placeholder")}
|
||||
onChange={(e) => updateTerminalSetting("localShell", e.target.value)}
|
||||
className={cn(
|
||||
"w-48",
|
||||
shellValidation && !shellValidation.valid && "border-destructive focus-visible:ring-destructive"
|
||||
)}
|
||||
/>
|
||||
{defaultShell && !terminalSettings.localShell && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("settings.terminal.localShell.shell.detected")}: {defaultShell}
|
||||
</span>
|
||||
)}
|
||||
{shellValidation && !shellValidation.valid && shellValidation.message && (
|
||||
<span className="text-xs text-destructive flex items-center gap-1">
|
||||
<AlertCircle size={12} />
|
||||
{shellValidation.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.localShell.startDir")}
|
||||
description={t("settings.terminal.localShell.startDir.desc")}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Input
|
||||
value={terminalSettings.localStartDir}
|
||||
placeholder={t("settings.terminal.localShell.startDir.placeholder")}
|
||||
onChange={(e) => updateTerminalSetting("localStartDir", e.target.value)}
|
||||
className={cn(
|
||||
"w-48",
|
||||
dirValidation && !dirValidation.valid && "border-destructive focus-visible:ring-destructive"
|
||||
)}
|
||||
/>
|
||||
{dirValidation && !dirValidation.valid && dirValidation.message && (
|
||||
<span className="text-xs text-destructive flex items-center gap-1">
|
||||
<AlertCircle size={12} />
|
||||
{dirValidation.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.connection")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
label={t("settings.terminal.connection.keepaliveInterval")}
|
||||
description={t("settings.terminal.connection.keepaliveInterval.desc")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={3600}
|
||||
value={terminalSettings.keepaliveInterval}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value) || 0;
|
||||
if (val >= 0 && val <= 3600) {
|
||||
updateTerminalSetting("keepaliveInterval", val);
|
||||
}
|
||||
}}
|
||||
className="w-24"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.serverStats")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
label={t("settings.terminal.serverStats.show")}
|
||||
description={t("settings.terminal.serverStats.show.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.showServerStats}
|
||||
onChange={(v) => updateTerminalSetting("showServerStats", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{terminalSettings.showServerStats && (
|
||||
<SettingRow
|
||||
label={t("settings.terminal.serverStats.refreshInterval")}
|
||||
description={t("settings.terminal.serverStats.refreshInterval.desc")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={5}
|
||||
max={300}
|
||||
value={terminalSettings.serverStatsRefreshInterval}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value) || 5;
|
||||
if (val >= 5 && val <= 300) {
|
||||
updateTerminalSetting("serverStatsRefreshInterval", val);
|
||||
}
|
||||
}}
|
||||
className="w-20"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{t("settings.terminal.serverStats.seconds")}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.rendering")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
label={t("settings.terminal.rendering.renderer")}
|
||||
description={t("settings.terminal.rendering.renderer.desc")}
|
||||
>
|
||||
<Select
|
||||
value={terminalSettings.rendererType}
|
||||
options={[
|
||||
{ value: "auto", label: t("settings.terminal.rendering.auto") },
|
||||
{ value: "webgl", label: "WebGL" },
|
||||
{ value: "canvas", label: "Canvas" },
|
||||
]}
|
||||
onChange={(v) => updateTerminalSetting("rendererType", v as "auto" | "webgl" | "canvas")}
|
||||
className="w-32"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
}
|
||||
|
||||
139
components/sftp-modal/SftpModalDialogs.tsx
Normal file
139
components/sftp-modal/SftpModalDialogs.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
|
||||
import { Input } from "../ui/input";
|
||||
import type { RemoteFile } from "../../types";
|
||||
|
||||
interface PermissionsState {
|
||||
owner: { read: boolean; write: boolean; execute: boolean };
|
||||
group: { read: boolean; write: boolean; execute: boolean };
|
||||
others: { read: boolean; write: boolean; execute: boolean };
|
||||
}
|
||||
|
||||
interface SftpModalDialogsProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
showRenameDialog: boolean;
|
||||
setShowRenameDialog: (open: boolean) => void;
|
||||
renameTarget: RemoteFile | null;
|
||||
renameName: string;
|
||||
setRenameName: (value: string) => void;
|
||||
handleRename: () => void;
|
||||
isRenaming: boolean;
|
||||
showPermissionsDialog: boolean;
|
||||
setShowPermissionsDialog: (open: boolean) => void;
|
||||
permissionsTarget: RemoteFile | null;
|
||||
permissions: PermissionsState;
|
||||
togglePermission: (role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => void;
|
||||
getOctalPermissions: () => string;
|
||||
getSymbolicPermissions: () => string;
|
||||
handleSavePermissions: () => void;
|
||||
isChangingPermissions: boolean;
|
||||
}
|
||||
|
||||
export const SftpModalDialogs: React.FC<SftpModalDialogsProps> = ({
|
||||
t,
|
||||
showRenameDialog,
|
||||
setShowRenameDialog,
|
||||
renameTarget,
|
||||
renameName,
|
||||
setRenameName,
|
||||
handleRename,
|
||||
isRenaming,
|
||||
showPermissionsDialog,
|
||||
setShowPermissionsDialog,
|
||||
permissionsTarget,
|
||||
permissions,
|
||||
togglePermission,
|
||||
getOctalPermissions,
|
||||
getSymbolicPermissions,
|
||||
handleSavePermissions,
|
||||
isChangingPermissions,
|
||||
}) => (
|
||||
<>
|
||||
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sftp.rename.title")}</DialogTitle>
|
||||
<DialogDescription className="truncate">
|
||||
{renameTarget?.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Input
|
||||
value={renameName}
|
||||
onChange={(e) => setRenameName(e.target.value)}
|
||||
placeholder={t("sftp.rename.placeholder")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleRename();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowRenameDialog(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleRename} disabled={isRenaming || !renameName.trim()}>
|
||||
{isRenaming ? <Loader2 size={14} className="mr-2 animate-spin" /> : null}
|
||||
{t("common.apply")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showPermissionsDialog} onOpenChange={setShowPermissionsDialog}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sftp.permissions.title")}</DialogTitle>
|
||||
<DialogDescription className="truncate">
|
||||
{permissionsTarget?.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-3">
|
||||
{(["owner", "group", "others"] as const).map((role) => (
|
||||
<div key={role} className="flex items-center gap-4">
|
||||
<div className="w-16 text-sm font-medium">
|
||||
{t(`sftp.permissions.${role}`)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{(["read", "write", "execute"] as const).map((perm) => (
|
||||
<label key={perm} className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={permissions[role][perm]}
|
||||
onChange={() => togglePermission(role, perm)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-xs">
|
||||
{perm === "read" ? "R" : perm === "write" ? "W" : "X"}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2 border-t border-border/60">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("sftp.permissions.octal")}: <span className="font-mono text-foreground">{getOctalPermissions()}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("sftp.permissions.symbolic")}: <span className="font-mono text-foreground">{getSymbolicPermissions()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowPermissionsDialog(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSavePermissions} disabled={isChangingPermissions}>
|
||||
{isChangingPermissions ? <Loader2 size={14} className="mr-2 animate-spin" /> : null}
|
||||
{t("common.apply")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
428
components/sftp-modal/SftpModalFileList.tsx
Normal file
428
components/sftp-modal/SftpModalFileList.tsx
Normal file
@@ -0,0 +1,428 @@
|
||||
import React from "react";
|
||||
import { Download, Edit2, Folder, FolderOpen, FolderUp, Link, Loader2, MoreHorizontal, Plus, RefreshCw, Shield, Trash2, Upload } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import type { RemoteFile } from "../../types";
|
||||
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from "../ui/context-menu";
|
||||
import { Button } from "../ui/button";
|
||||
import { getFileIcon } from "./fileIcons";
|
||||
|
||||
interface VisibleRow {
|
||||
file: RemoteFile;
|
||||
index: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
interface SftpModalFileListProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
files: RemoteFile[];
|
||||
selectedFiles: Set<string>;
|
||||
dragActive: boolean;
|
||||
loading: boolean;
|
||||
loadingTextContent: boolean;
|
||||
reconnecting: boolean;
|
||||
columnWidths: { name: number; size: number; modified: number; actions: number };
|
||||
sortField: "name" | "size" | "modified";
|
||||
sortOrder: "asc" | "desc";
|
||||
shouldVirtualize: boolean;
|
||||
totalHeight: number;
|
||||
visibleRows: VisibleRow[];
|
||||
fileListRef: React.RefObject<HTMLDivElement>;
|
||||
inputRef: React.RefObject<HTMLInputElement>;
|
||||
folderInputRef: React.RefObject<HTMLInputElement>;
|
||||
handleSort: (field: "name" | "size" | "modified") => void;
|
||||
handleResizeStart: (field: string, e: React.MouseEvent) => void;
|
||||
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
|
||||
handleDrag: (e: React.DragEvent) => void;
|
||||
handleDrop: (e: React.DragEvent) => void;
|
||||
handleFileClick: (file: RemoteFile, index: number, e: React.MouseEvent) => void;
|
||||
handleFileDoubleClick: (file: RemoteFile) => void;
|
||||
handleDownload: (file: RemoteFile) => void;
|
||||
handleDelete: (file: RemoteFile) => void;
|
||||
handleOpenFile: (file: RemoteFile) => void;
|
||||
openFileOpenerDialog: (file: RemoteFile) => void;
|
||||
handleEditFile: (file: RemoteFile) => void;
|
||||
openRenameDialog: (file: RemoteFile) => void;
|
||||
openPermissionsDialog: (file: RemoteFile) => void;
|
||||
handleNavigate: (path: string) => void;
|
||||
handleCreateFolder: () => void;
|
||||
handleCreateFile: () => void;
|
||||
handleDownloadSelected: () => void;
|
||||
handleDeleteSelected: () => void;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => void;
|
||||
formatBytes: (bytes: number | string) => string;
|
||||
formatDate: (dateStr: string | number | undefined) => string;
|
||||
}
|
||||
|
||||
export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
|
||||
t,
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
files,
|
||||
selectedFiles,
|
||||
dragActive,
|
||||
loading,
|
||||
loadingTextContent,
|
||||
reconnecting,
|
||||
columnWidths,
|
||||
sortField,
|
||||
sortOrder,
|
||||
shouldVirtualize,
|
||||
totalHeight,
|
||||
visibleRows,
|
||||
fileListRef,
|
||||
inputRef,
|
||||
folderInputRef,
|
||||
handleSort,
|
||||
handleResizeStart,
|
||||
handleFileListScroll,
|
||||
handleDrag,
|
||||
handleDrop,
|
||||
handleFileClick,
|
||||
handleFileDoubleClick,
|
||||
handleDownload,
|
||||
handleDelete,
|
||||
handleOpenFile,
|
||||
openFileOpenerDialog,
|
||||
handleEditFile,
|
||||
openRenameDialog,
|
||||
openPermissionsDialog,
|
||||
handleNavigate,
|
||||
handleCreateFolder,
|
||||
handleCreateFile,
|
||||
handleDownloadSelected,
|
||||
handleDeleteSelected,
|
||||
loadFiles,
|
||||
formatBytes,
|
||||
formatDate,
|
||||
}) => (
|
||||
<>
|
||||
<div
|
||||
className="shrink-0 bg-muted/80 backdrop-blur-sm border-b border-border/60 px-4 py-2 flex items-center text-xs font-medium text-muted-foreground select-none"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
|
||||
onClick={() => handleSort("name")}
|
||||
>
|
||||
<span>{t("sftp.columns.name")}</span>
|
||||
{sortField === "name" && (
|
||||
<span className="text-primary">{sortOrder === "asc" ? "^" : "v"}</span>
|
||||
)}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
|
||||
onMouseDown={(e) => handleResizeStart("name", e)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
|
||||
onClick={() => handleSort("size")}
|
||||
>
|
||||
<span>{t("sftp.columns.size")}</span>
|
||||
{sortField === "size" && (
|
||||
<span className="text-primary">{sortOrder === "asc" ? "^" : "v"}</span>
|
||||
)}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
|
||||
onMouseDown={(e) => handleResizeStart("size", e)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
|
||||
onClick={() => handleSort("modified")}
|
||||
>
|
||||
<span>{t("sftp.columns.modified")}</span>
|
||||
{sortField === "modified" && (
|
||||
<span className="text-primary">{sortOrder === "asc" ? "^" : "v"}</span>
|
||||
)}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
|
||||
onMouseDown={(e) => handleResizeStart("modified", e)}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right">{t("sftp.columns.actions")}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={fileListRef}
|
||||
className={cn(
|
||||
"flex-1 min-h-0 overflow-y-auto relative",
|
||||
dragActive && "bg-primary/5 ring-2 ring-inset ring-primary",
|
||||
)}
|
||||
onScroll={handleFileListScroll}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{dragActive && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||
<div className="bg-background/95 p-6 rounded-xl shadow-lg border-2 border-dashed border-primary text-primary font-medium flex flex-col items-center gap-2">
|
||||
<Upload size={32} />
|
||||
<span>{t("sftp.dropFilesHere")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && files.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingTextContent && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-20">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("sftp.status.loading")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reconnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-20">
|
||||
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-secondary/90 border border-border/60 shadow-lg">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium">{t("sftp.reconnecting.title")}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t("sftp.reconnecting.desc")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{files.length === 0 && !loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Folder size={48} className="mb-3 opacity-50" />
|
||||
<div className="text-sm font-medium">{t("sftp.emptyDirectory")}</div>
|
||||
<div className="text-xs mt-1">{t("sftp.dragDropToUpload")}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={shouldVirtualize ? "relative" : "divide-y divide-border/30"}
|
||||
style={shouldVirtualize ? { height: totalHeight } : undefined}
|
||||
>
|
||||
{visibleRows.map(({ file, index: idx, top }) => {
|
||||
const isNavigableDirectory =
|
||||
file.type === "directory" ||
|
||||
(file.type === "symlink" && file.linkTarget === "directory");
|
||||
const isDownloadableFile =
|
||||
file.type === "file" ||
|
||||
(file.type === "symlink" && file.linkTarget === "file");
|
||||
const isParentEntry = file.name === "..";
|
||||
|
||||
return (
|
||||
<ContextMenu key={file.name}>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
data-sftp-modal-row="true"
|
||||
className={cn(
|
||||
"px-4 py-2.5 items-center hover:bg-muted/50 cursor-pointer transition-colors text-sm",
|
||||
selectedFiles.has(file.name) && !isParentEntry && "bg-primary/10",
|
||||
shouldVirtualize ? "absolute left-0 right-0 border-b border-border/30" : "",
|
||||
)}
|
||||
style={
|
||||
shouldVirtualize
|
||||
? {
|
||||
top,
|
||||
display: "grid",
|
||||
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
|
||||
}
|
||||
: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
|
||||
}
|
||||
}
|
||||
onClick={(e) => handleFileClick(file, idx, e)}
|
||||
onDoubleClick={() => handleFileDoubleClick(file)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="relative shrink-0 h-7 w-7 flex items-center justify-center">
|
||||
{getFileIcon(
|
||||
file.name,
|
||||
isNavigableDirectory,
|
||||
file.type === "symlink" && !isNavigableDirectory,
|
||||
)}
|
||||
{file.type === "symlink" && (
|
||||
<Link
|
||||
size={10}
|
||||
className="absolute -bottom-0.5 -right-0.5 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate font-medium",
|
||||
file.type === "symlink" && "italic pr-1",
|
||||
)}
|
||||
>
|
||||
{file.name}
|
||||
{file.type === "symlink" && (
|
||||
<span className="sr-only"> (symbolic link)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{isNavigableDirectory ? "--" : formatBytes(file.size)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{formatDate(file.lastModified)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{isDownloadableFile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownload(file);
|
||||
}}
|
||||
title={t("sftp.context.download")}
|
||||
>
|
||||
<Download size={14} />
|
||||
</Button>
|
||||
)}
|
||||
{!isParentEntry && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(file);
|
||||
}}
|
||||
title={t("sftp.context.delete")}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{isParentEntry ? (
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
const segments = currentPath.split("/").filter(Boolean);
|
||||
segments.pop();
|
||||
const parentPath =
|
||||
segments.length === 0 ? "/" : `/${segments.join("/")}`;
|
||||
handleNavigate(parentPath);
|
||||
}}
|
||||
>
|
||||
{t("sftp.context.open")}
|
||||
</ContextMenuItem>
|
||||
) : (
|
||||
<>
|
||||
{isNavigableDirectory && (
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
handleNavigate(
|
||||
currentPath === "/"
|
||||
? `/${file.name}`
|
||||
: `${currentPath}/${file.name}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<FolderOpen size={14} className="mr-2" />
|
||||
{t("sftp.context.open")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{isDownloadableFile && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => handleOpenFile(file)}>
|
||||
<FolderOpen size={14} className="mr-2" />
|
||||
{t("sftp.context.open")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => openFileOpenerDialog(file)}>
|
||||
<MoreHorizontal size={14} className="mr-2" />
|
||||
{t("sftp.context.openWith")}
|
||||
</ContextMenuItem>
|
||||
{!isKnownBinaryFile(file.name) && (
|
||||
<ContextMenuItem onClick={() => handleEditFile(file)}>
|
||||
<Edit2 size={14} className="mr-2" />
|
||||
{t("sftp.context.edit")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => handleDownload(file)}>
|
||||
<Download size={14} className="mr-2" />
|
||||
{t("sftp.context.download")}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
<ContextMenuItem onClick={() => openRenameDialog(file)}>
|
||||
<Edit2 size={14} className="mr-2" />
|
||||
{t("sftp.context.rename")}
|
||||
</ContextMenuItem>
|
||||
{!isLocalSession && (
|
||||
<ContextMenuItem onClick={() => openPermissionsDialog(file)}>
|
||||
<Shield size={14} className="mr-2" />
|
||||
{t("sftp.context.permissions")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDelete(file)}
|
||||
>
|
||||
<Trash2 size={14} className="mr-2" />
|
||||
{t("sftp.context.delete")}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={handleCreateFolder}>
|
||||
<Plus className="h-4 w-4 mr-2" /> {t("sftp.newFolder")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handleCreateFile}>
|
||||
<Plus className="h-4 w-4 mr-2" /> {t("sftp.newFile")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => inputRef.current?.click()}>
|
||||
<Upload className="h-4 w-4 mr-2" /> {t("sftp.uploadFiles")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => folderInputRef.current?.click()}>
|
||||
<FolderUp className="h-4 w-4 mr-2" /> {t("sftp.uploadFolder")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => loadFiles(currentPath, { force: true })}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" /> {t("sftp.context.refresh")}
|
||||
</ContextMenuItem>
|
||||
{selectedFiles.size > 0 && (
|
||||
<>
|
||||
<ContextMenuItem onClick={handleDownloadSelected}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
{t("sftp.context.downloadSelected", { count: selectedFiles.size })}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={handleDeleteSelected}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t("sftp.context.deleteSelected", { count: selectedFiles.size })}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
61
components/sftp-modal/SftpModalFooter.tsx
Normal file
61
components/sftp-modal/SftpModalFooter.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import { Download, Trash2 } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import type { RemoteFile } from "../../types";
|
||||
|
||||
interface SftpModalFooterProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
files: RemoteFile[];
|
||||
selectedFiles: Set<string>;
|
||||
loading: boolean;
|
||||
uploading: boolean;
|
||||
onDownloadSelected: () => void;
|
||||
onDeleteSelected: () => void;
|
||||
}
|
||||
|
||||
export const SftpModalFooter: React.FC<SftpModalFooterProps> = ({
|
||||
t,
|
||||
files,
|
||||
selectedFiles,
|
||||
loading,
|
||||
uploading,
|
||||
onDownloadSelected,
|
||||
onDeleteSelected,
|
||||
}) => (
|
||||
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
|
||||
<span>
|
||||
{t("sftp.itemsCount", { count: files.length })}
|
||||
{selectedFiles.size > 0 && (
|
||||
<>
|
||||
<span className="mx-2">|</span>
|
||||
<span className="text-primary">
|
||||
{t("sftp.selectedCount", { count: selectedFiles.size })}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-2 ml-2 text-xs text-primary hover:text-primary"
|
||||
onClick={onDownloadSelected}
|
||||
>
|
||||
<Download size={10} className="mr-1" /> {t("sftp.context.download")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-2 text-xs text-destructive hover:text-destructive"
|
||||
onClick={onDeleteSelected}
|
||||
>
|
||||
<Trash2 size={10} className="mr-1" /> {t("sftp.context.delete")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{loading
|
||||
? t("sftp.status.loading")
|
||||
: uploading
|
||||
? t("sftp.status.uploading")
|
||||
: t("sftp.status.ready")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
355
components/sftp-modal/SftpModalHeader.tsx
Normal file
355
components/sftp-modal/SftpModalHeader.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ArrowUp, Check, ChevronRight, FilePlus, FolderPlus, FolderUp, Home, Languages, MoreHorizontal, RefreshCw, Upload } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import type { Host, SftpFilenameEncoding } from "../../types";
|
||||
import { DistroAvatar } from "../DistroAvatar";
|
||||
import { Button } from "../ui/button";
|
||||
import { DialogHeader, DialogTitle } from "../ui/dialog";
|
||||
import { Input } from "../ui/input";
|
||||
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
|
||||
|
||||
interface BreadcrumbPart {
|
||||
part: string;
|
||||
originalIndex: number;
|
||||
}
|
||||
|
||||
interface SftpModalHeaderProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
host: Host;
|
||||
credentials: { username?: string; hostname: string; port?: number };
|
||||
showEncoding: boolean;
|
||||
filenameEncoding: SftpFilenameEncoding;
|
||||
onFilenameEncodingChange: (encoding: SftpFilenameEncoding) => void;
|
||||
currentPath: string;
|
||||
isEditingPath: boolean;
|
||||
editingPathValue: string;
|
||||
setEditingPathValue: (value: string) => void;
|
||||
handlePathSubmit: () => void;
|
||||
handlePathKeyDown: (e: React.KeyboardEvent) => void;
|
||||
handlePathDoubleClick: () => void;
|
||||
isAtRoot: boolean;
|
||||
rootLabel: string;
|
||||
isRefreshing: boolean;
|
||||
onUp: () => void;
|
||||
onHome: () => void;
|
||||
onRefresh: () => void;
|
||||
visibleBreadcrumbs: BreadcrumbPart[];
|
||||
hiddenBreadcrumbs: BreadcrumbPart[];
|
||||
needsBreadcrumbTruncation: boolean;
|
||||
breadcrumbs: string[];
|
||||
onBreadcrumbSelect: (index: number) => void;
|
||||
onRootSelect: () => void;
|
||||
inputRef: React.RefObject<HTMLInputElement>;
|
||||
folderInputRef: React.RefObject<HTMLInputElement>;
|
||||
pathInputRef: React.RefObject<HTMLInputElement>;
|
||||
uploading: boolean;
|
||||
onTriggerUpload: () => void;
|
||||
onTriggerFolderUpload: () => void;
|
||||
onCreateFolder: () => void;
|
||||
onCreateFile: () => void;
|
||||
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onFolderSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
|
||||
t,
|
||||
host,
|
||||
credentials,
|
||||
showEncoding,
|
||||
filenameEncoding,
|
||||
onFilenameEncodingChange,
|
||||
currentPath,
|
||||
isEditingPath,
|
||||
editingPathValue,
|
||||
setEditingPathValue,
|
||||
handlePathSubmit,
|
||||
handlePathKeyDown,
|
||||
handlePathDoubleClick,
|
||||
isAtRoot,
|
||||
rootLabel,
|
||||
isRefreshing,
|
||||
onUp,
|
||||
onHome,
|
||||
onRefresh,
|
||||
visibleBreadcrumbs,
|
||||
hiddenBreadcrumbs,
|
||||
needsBreadcrumbTruncation,
|
||||
breadcrumbs,
|
||||
onBreadcrumbSelect,
|
||||
onRootSelect,
|
||||
inputRef,
|
||||
folderInputRef,
|
||||
pathInputRef,
|
||||
uploading,
|
||||
onTriggerUpload,
|
||||
onTriggerFolderUpload,
|
||||
onCreateFolder,
|
||||
onCreateFile,
|
||||
onFileSelect,
|
||||
onFolderSelect,
|
||||
}) => {
|
||||
// Delay tooltip activation to prevent flickering when modal opens
|
||||
const [tooltipsReady, setTooltipsReady] = useState(false);
|
||||
const [openTooltip, setOpenTooltip] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setTooltipsReady(true), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const handleTooltipOpenChange = (id: string) => (open: boolean) => {
|
||||
if (!tooltipsReady) return;
|
||||
setOpenTooltip(open ? id : null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
|
||||
<div className="flex items-center gap-3 pr-8">
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.label.slice(0, 2).toUpperCase()}
|
||||
className="h-8 w-8"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<DialogTitle className="text-sm font-semibold">
|
||||
{host.label}
|
||||
</DialogTitle>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{credentials.username || "root"}@{credentials.hostname}:
|
||||
{credentials.port || 22}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<TooltipProvider delayDuration={500} skipDelayDuration={800} disableHoverableContent>
|
||||
<div className="px-4 py-2 border-b border-border/60 flex items-center gap-2 flex-shrink-0 bg-muted/30">
|
||||
<Tooltip open={openTooltip === 'up'} onOpenChange={handleTooltipOpenChange('up')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onUp}
|
||||
disabled={isAtRoot}
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.nav.up")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip open={openTooltip === 'home'} onOpenChange={handleTooltipOpenChange('home')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onHome}
|
||||
>
|
||||
<Home size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.nav.home")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip open={openTooltip === 'refresh'} onOpenChange={handleTooltipOpenChange('refresh')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw
|
||||
size={14}
|
||||
className={cn(isRefreshing && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.nav.refresh")}</TooltipContent>
|
||||
</Tooltip>
|
||||
{showEncoding && (
|
||||
<Popover>
|
||||
<Tooltip open={openTooltip === 'encoding'} onOpenChange={handleTooltipOpenChange('encoding')}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<Languages size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.encoding.label")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-36 p-1" align="start">
|
||||
{(["auto", "utf-8", "gb18030"] as const).map((encoding) => (
|
||||
<PopoverClose asChild key={encoding}>
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-sm hover:bg-secondary transition-colors",
|
||||
filenameEncoding === encoding && "bg-secondary"
|
||||
)}
|
||||
onClick={() => onFilenameEncodingChange(encoding)}
|
||||
>
|
||||
<Check
|
||||
size={14}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
filenameEncoding === encoding ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{t(`sftp.encoding.${encoding === "utf-8" ? "utf8" : encoding}`)}
|
||||
</button>
|
||||
</PopoverClose>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-hidden">
|
||||
{isEditingPath ? (
|
||||
<Input
|
||||
ref={pathInputRef}
|
||||
value={editingPathValue}
|
||||
onChange={(e) => setEditingPathValue(e.target.value)}
|
||||
onBlur={handlePathSubmit}
|
||||
onKeyDown={handlePathKeyDown}
|
||||
className="h-7 text-sm bg-background"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-1 flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 py-0.5 transition-colors"
|
||||
onDoubleClick={handlePathDoubleClick}
|
||||
title={currentPath}
|
||||
>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground px-1 shrink-0"
|
||||
onClick={onRootSelect}
|
||||
>
|
||||
{rootLabel}
|
||||
</button>
|
||||
{visibleBreadcrumbs.map(({ part, originalIndex }, displayIdx) => {
|
||||
const isLast = originalIndex === breadcrumbs.length - 1;
|
||||
const showEllipsisBefore =
|
||||
needsBreadcrumbTruncation && displayIdx === 1;
|
||||
|
||||
return (
|
||||
<React.Fragment key={originalIndex}>
|
||||
{showEllipsisBefore && (
|
||||
<>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<span
|
||||
className="text-muted-foreground px-1 shrink-0 flex items-center cursor-default"
|
||||
title={`${t("sftp.showHiddenPaths")}: ${hiddenBreadcrumbs
|
||||
.map((h) => h.part)
|
||||
.join(" > ")}`}
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground truncate px-1 max-w-[100px]",
|
||||
isLast && "text-foreground font-medium",
|
||||
)}
|
||||
onClick={() => onBreadcrumbSelect(originalIndex)}
|
||||
title={part}
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<Tooltip open={openTooltip === 'upload'} onOpenChange={handleTooltipOpenChange('upload')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onTriggerUpload}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Upload size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.upload")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip open={openTooltip === 'uploadFolder'} onOpenChange={handleTooltipOpenChange('uploadFolder')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onTriggerFolderUpload}
|
||||
disabled={uploading}
|
||||
>
|
||||
<FolderUp size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.uploadFolder")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip open={openTooltip === 'newFolder'} onOpenChange={handleTooltipOpenChange('newFolder')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onCreateFolder}
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.newFolder")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip open={openTooltip === 'newFile'} onOpenChange={handleTooltipOpenChange('newFile')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onCreateFile}
|
||||
>
|
||||
<FilePlus size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.newFile")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
ref={inputRef}
|
||||
onChange={onFileSelect}
|
||||
multiple
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
ref={folderInputRef}
|
||||
onChange={onFolderSelect}
|
||||
webkitdirectory=""
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
223
components/sftp-modal/SftpModalUploadTasks.tsx
Normal file
223
components/sftp-modal/SftpModalUploadTasks.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import React from "react";
|
||||
import { Download, Loader2, Upload, X, XCircle } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface TransferTask {
|
||||
id: string;
|
||||
fileName: string;
|
||||
totalBytes: number;
|
||||
transferredBytes: number;
|
||||
progress: number;
|
||||
speed: number;
|
||||
status: "pending" | "uploading" | "downloading" | "completed" | "failed" | "cancelled";
|
||||
error?: string;
|
||||
direction: "upload" | "download";
|
||||
}
|
||||
|
||||
interface SftpModalUploadTasksProps {
|
||||
tasks: TransferTask[];
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
onCancel?: () => void;
|
||||
onCancelTask?: (taskId: string) => void;
|
||||
onDismiss?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ tasks, t, onCancel, onCancelTask, onDismiss }) => {
|
||||
if (tasks.length === 0) return null;
|
||||
|
||||
// Helper function to get localized display name for compressed uploads
|
||||
const getDisplayName = (task: TransferTask) => {
|
||||
// Check for explicit phase marker format: "folderName|phase"
|
||||
// This is the format sent by uploadService.ts for compressed uploads
|
||||
if (task.fileName.includes('|')) {
|
||||
const pipeIndex = task.fileName.lastIndexOf('|');
|
||||
const baseName = task.fileName.substring(0, pipeIndex);
|
||||
const phase = task.fileName.substring(pipeIndex + 1);
|
||||
|
||||
if (phase === 'compressing' || phase === 'extracting' || phase === 'uploading' || phase === 'compressed') {
|
||||
const phaseLabel = t(`sftp.upload.phase.${phase}`);
|
||||
return `${baseName} (${phaseLabel})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for exact matches of phase status strings (legacy support)
|
||||
if (task.fileName === t('sftp.upload.compressing') || task.fileName === 'Compressing...' || task.fileName === 'Compressing') {
|
||||
return t('sftp.upload.compressing');
|
||||
}
|
||||
if (task.fileName === t('sftp.upload.extracting') || task.fileName === 'Extracting...' || task.fileName === 'Extracting') {
|
||||
return t('sftp.upload.extracting');
|
||||
}
|
||||
if (task.fileName === t('sftp.upload.scanning') || task.fileName === 'Scanning files...' || task.fileName === 'Scanning files') {
|
||||
return t('sftp.upload.scanning');
|
||||
}
|
||||
|
||||
// Check if this is a compressed upload task (legacy format)
|
||||
if (task.fileName.includes('(compressed)')) {
|
||||
const baseName = task.fileName.replace(' (compressed)', '');
|
||||
return `${baseName} (${t('sftp.upload.compressed')})`;
|
||||
}
|
||||
|
||||
return task.fileName;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-border/60 bg-secondary/50 flex-shrink-0">
|
||||
<div className="max-h-40 overflow-y-auto overflow-x-hidden">
|
||||
{tasks.map((task) => {
|
||||
const formatSpeed = (bytesPerSec: number) => {
|
||||
if (bytesPerSec <= 0) return "";
|
||||
if (bytesPerSec >= 1024 * 1024)
|
||||
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
|
||||
if (bytesPerSec >= 1024)
|
||||
return `${(bytesPerSec / 1024).toFixed(1)} KB/s`;
|
||||
return `${Math.round(bytesPerSec)} B/s`;
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return "0 B";
|
||||
if (bytes >= 1024 * 1024)
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${bytes} B`;
|
||||
};
|
||||
|
||||
const remainingBytes = task.totalBytes - task.transferredBytes;
|
||||
const remainingTime =
|
||||
task.speed > 0 ? Math.ceil(remainingBytes / task.speed) : 0;
|
||||
const remainingStr =
|
||||
remainingTime > 60
|
||||
? `~${Math.ceil(remainingTime / 60)}m left`
|
||||
: remainingTime > 0
|
||||
? `~${remainingTime}s left`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="px-4 py-2.5 flex items-center gap-3 border-b border-border/30 last:border-b-0"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
{(task.status === "uploading" || task.status === "downloading") && (
|
||||
<Loader2 size={14} className="animate-spin text-primary" />
|
||||
)}
|
||||
{task.status === "pending" && (
|
||||
task.direction === "download"
|
||||
? <Download size={14} className="text-muted-foreground animate-pulse" />
|
||||
: <Upload size={14} className="text-muted-foreground animate-pulse" />
|
||||
)}
|
||||
{task.status === "completed" && (
|
||||
task.direction === "download"
|
||||
? <Download size={14} className="text-green-500" />
|
||||
: <Upload size={14} className="text-green-500" />
|
||||
)}
|
||||
{task.status === "failed" && (
|
||||
<XCircle size={14} className="text-destructive" />
|
||||
)}
|
||||
{task.status === "cancelled" && (
|
||||
<XCircle size={14} className="text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium truncate">
|
||||
{getDisplayName(task)}
|
||||
</span>
|
||||
{(task.status === "uploading" || task.status === "downloading") && task.speed > 0 && (
|
||||
<span className="text-[10px] text-primary font-mono shrink-0">
|
||||
{formatSpeed(task.speed)}
|
||||
</span>
|
||||
)}
|
||||
{(task.status === "uploading" || task.status === "downloading") && remainingStr && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
{remainingStr}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(task.status === "uploading" || task.status === "downloading" || task.status === "pending") && (
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-150",
|
||||
task.status === "pending"
|
||||
? "bg-muted-foreground/50 animate-pulse w-full"
|
||||
: "bg-primary",
|
||||
)}
|
||||
style={{
|
||||
width:
|
||||
task.status === "uploading" || task.status === "downloading"
|
||||
? `${task.progress}%`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-mono shrink-0 w-8 text-right">
|
||||
{task.status === "uploading" || task.status === "downloading" ? `${Math.round(task.progress)}%` : "..."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(task.status === "uploading" || task.status === "downloading") && task.totalBytes > 0 && (
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
|
||||
{formatBytes(task.transferredBytes)} / {formatBytes(task.totalBytes)}
|
||||
</div>
|
||||
)}
|
||||
{task.status === "completed" && (
|
||||
<div className="text-[10px] text-green-600 mt-0.5">
|
||||
{t(task.direction === "download" ? "sftp.download.completed" : "sftp.upload.completed")} - {formatBytes(task.totalBytes)}
|
||||
</div>
|
||||
)}
|
||||
{task.status === "cancelled" && (
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5">
|
||||
{t(task.direction === "download" ? "sftp.download.cancelled" : "sftp.upload.cancelled")}
|
||||
</div>
|
||||
)}
|
||||
{task.status === "failed" && task.error && (
|
||||
<div className="text-[10px] text-destructive truncate mt-0.5">
|
||||
{task.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-1">
|
||||
{task.status === "pending" && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("sftp.task.waiting")}
|
||||
</span>
|
||||
)}
|
||||
{(task.status === "uploading" || task.status === "downloading" || task.status === "pending") && (onCancelTask || onCancel) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
// For download tasks or when onCancelTask is available, use task-specific cancel
|
||||
if (onCancelTask) {
|
||||
onCancelTask(task.id);
|
||||
} else if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
title={t("sftp.action.cancel")}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
)}
|
||||
{(task.status === "completed" || task.status === "failed" || task.status === "cancelled") && onDismiss && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onDismiss(task.id)}
|
||||
title={t("sftp.action.dismiss")}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
149
components/sftp-modal/fileIcons.tsx
Normal file
149
components/sftp-modal/fileIcons.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
Database,
|
||||
ExternalLink,
|
||||
File,
|
||||
FileArchive,
|
||||
FileAudio,
|
||||
FileCode,
|
||||
FileImage,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileType,
|
||||
FileVideo,
|
||||
Folder,
|
||||
Globe,
|
||||
Lock,
|
||||
Settings,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
export const getFileIcon = (fileName: string, isDirectory: boolean, isSymlink?: boolean) => {
|
||||
if (isDirectory)
|
||||
return (
|
||||
<Folder
|
||||
size={18}
|
||||
fill="currentColor"
|
||||
fillOpacity={0.2}
|
||||
className="text-blue-400"
|
||||
/>
|
||||
);
|
||||
|
||||
if (isSymlink) {
|
||||
return <ExternalLink size={18} className="text-cyan-500" />;
|
||||
}
|
||||
|
||||
const ext = fileName.split(".").pop()?.toLowerCase() || "";
|
||||
|
||||
if (["doc", "docx", "rtf", "odt"].includes(ext))
|
||||
return <FileText size={18} className="text-blue-500" />;
|
||||
if (["xls", "xlsx", "csv", "ods"].includes(ext))
|
||||
return <FileSpreadsheet size={18} className="text-green-500" />;
|
||||
if (["ppt", "pptx", "odp"].includes(ext))
|
||||
return <FileType size={18} className="text-orange-500" />;
|
||||
if (["pdf"].includes(ext))
|
||||
return <FileText size={18} className="text-red-500" />;
|
||||
|
||||
if (["js", "jsx", "ts", "tsx", "mjs", "cjs"].includes(ext))
|
||||
return <FileCode size={18} className="text-yellow-500" />;
|
||||
if (["py", "pyc", "pyw"].includes(ext))
|
||||
return <FileCode size={18} className="text-blue-400" />;
|
||||
if (["sh", "bash", "zsh", "fish", "bat", "cmd", "ps1"].includes(ext))
|
||||
return <Terminal size={18} className="text-green-400" />;
|
||||
if (["c", "cpp", "h", "hpp", "cc", "cxx"].includes(ext))
|
||||
return <FileCode size={18} className="text-blue-600" />;
|
||||
if (["java", "class", "jar"].includes(ext))
|
||||
return <FileCode size={18} className="text-orange-600" />;
|
||||
if (["go"].includes(ext))
|
||||
return <FileCode size={18} className="text-cyan-500" />;
|
||||
if (["rs"].includes(ext))
|
||||
return <FileCode size={18} className="text-orange-400" />;
|
||||
if (["rb"].includes(ext))
|
||||
return <FileCode size={18} className="text-red-400" />;
|
||||
if (["php"].includes(ext))
|
||||
return <FileCode size={18} className="text-purple-500" />;
|
||||
if (["html", "htm", "xhtml"].includes(ext))
|
||||
return <Globe size={18} className="text-orange-500" />;
|
||||
if (["css", "scss", "sass", "less"].includes(ext))
|
||||
return <FileCode size={18} className="text-blue-500" />;
|
||||
if (["vue", "svelte"].includes(ext))
|
||||
return <FileCode size={18} className="text-green-500" />;
|
||||
|
||||
if (["json", "json5"].includes(ext))
|
||||
return <FileCode size={18} className="text-yellow-600" />;
|
||||
if (["xml", "xsl", "xslt"].includes(ext))
|
||||
return <FileCode size={18} className="text-orange-400" />;
|
||||
if (["yml", "yaml"].includes(ext))
|
||||
return <Settings size={18} className="text-pink-400" />;
|
||||
if (["toml", "ini", "conf", "cfg", "config"].includes(ext))
|
||||
return <Settings size={18} className="text-gray-400" />;
|
||||
if (["env"].includes(ext))
|
||||
return <Lock size={18} className="text-yellow-500" />;
|
||||
if (["sql", "sqlite", "db"].includes(ext))
|
||||
return <Database size={18} className="text-blue-400" />;
|
||||
|
||||
if (
|
||||
[
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"gif",
|
||||
"bmp",
|
||||
"webp",
|
||||
"svg",
|
||||
"ico",
|
||||
"tiff",
|
||||
"tif",
|
||||
"heic",
|
||||
"heif",
|
||||
"avif",
|
||||
].includes(ext)
|
||||
)
|
||||
return <FileImage size={18} className="text-purple-400" />;
|
||||
|
||||
if (
|
||||
[
|
||||
"mp4",
|
||||
"mkv",
|
||||
"avi",
|
||||
"mov",
|
||||
"wmv",
|
||||
"flv",
|
||||
"webm",
|
||||
"m4v",
|
||||
"3gp",
|
||||
"mpeg",
|
||||
"mpg",
|
||||
].includes(ext)
|
||||
)
|
||||
return <FileVideo size={18} className="text-pink-500" />;
|
||||
|
||||
if (
|
||||
["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma", "opus", "aiff"].includes(
|
||||
ext,
|
||||
)
|
||||
)
|
||||
return <FileAudio size={18} className="text-green-400" />;
|
||||
|
||||
if (
|
||||
[
|
||||
"zip",
|
||||
"rar",
|
||||
"7z",
|
||||
"tar",
|
||||
"gz",
|
||||
"bz2",
|
||||
"xz",
|
||||
"tgz",
|
||||
"tbz2",
|
||||
"lz",
|
||||
"lzma",
|
||||
"cab",
|
||||
"iso",
|
||||
"dmg",
|
||||
].includes(ext)
|
||||
)
|
||||
return <FileArchive size={18} className="text-yellow-600" />;
|
||||
|
||||
return <File size={18} className="text-muted-foreground" />;
|
||||
};
|
||||
108
components/sftp-modal/hooks/useSftpModalCreateDelete.ts
Normal file
108
components/sftp-modal/hooks/useSftpModalCreateDelete.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useCallback } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
interface UseSftpModalCreateDeleteParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
deleteLocalFile: (path: string) => Promise<void>;
|
||||
deleteSftp: (sftpId: string, path: string) => Promise<void>;
|
||||
mkdirLocal: (path: string) => Promise<void>;
|
||||
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
|
||||
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
|
||||
writeSftpBinary: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
|
||||
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
interface UseSftpModalCreateDeleteResult {
|
||||
handleDelete: (file: RemoteFile) => Promise<void>;
|
||||
handleCreateFolder: () => Promise<void>;
|
||||
handleCreateFile: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpModalCreateDelete = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
deleteLocalFile,
|
||||
deleteSftp,
|
||||
mkdirLocal,
|
||||
mkdirSftp,
|
||||
writeLocalFile,
|
||||
writeSftpBinary,
|
||||
writeSftp,
|
||||
t,
|
||||
}: UseSftpModalCreateDeleteParams): UseSftpModalCreateDeleteResult => {
|
||||
const handleDelete = useCallback(
|
||||
async (file: RemoteFile) => {
|
||||
if (file.name === "..") return;
|
||||
if (!confirm(t("sftp.deleteConfirm.single", { name: file.name }))) return;
|
||||
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, file.name);
|
||||
if (isLocalSession) {
|
||||
await deleteLocalFile(fullPath);
|
||||
} else {
|
||||
await deleteSftp(await ensureSftp(), fullPath);
|
||||
}
|
||||
await loadFiles(currentPath, { force: true });
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.deleteFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
},
|
||||
[currentPath, deleteLocalFile, deleteSftp, ensureSftp, isLocalSession, joinPath, loadFiles, t],
|
||||
);
|
||||
|
||||
const handleCreateFolder = useCallback(async () => {
|
||||
const folderName = prompt(t("sftp.prompt.newFolderName"));
|
||||
if (!folderName) return;
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, folderName);
|
||||
if (isLocalSession) {
|
||||
await mkdirLocal(fullPath);
|
||||
} else {
|
||||
await mkdirSftp(await ensureSftp(), fullPath);
|
||||
}
|
||||
await loadFiles(currentPath, { force: true });
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.createFolderFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
}, [currentPath, ensureSftp, isLocalSession, joinPath, loadFiles, mkdirLocal, mkdirSftp, t]);
|
||||
|
||||
const handleCreateFile = useCallback(async () => {
|
||||
const fileName = prompt(t("sftp.fileName.placeholder"));
|
||||
if (!fileName) return;
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, fileName);
|
||||
if (isLocalSession) {
|
||||
await writeLocalFile(fullPath, new ArrayBuffer(0));
|
||||
} else {
|
||||
try {
|
||||
await writeSftpBinary(await ensureSftp(), fullPath, new ArrayBuffer(0));
|
||||
} catch {
|
||||
await writeSftp(await ensureSftp(), fullPath, "");
|
||||
}
|
||||
}
|
||||
await loadFiles(currentPath, { force: true });
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.createFileFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
}, [currentPath, ensureSftp, isLocalSession, joinPath, loadFiles, t, writeLocalFile, writeSftp, writeSftpBinary]);
|
||||
|
||||
return { handleDelete, handleCreateFolder, handleCreateFile };
|
||||
};
|
||||
252
components/sftp-modal/hooks/useSftpModalFileActions.ts
Normal file
252
components/sftp-modal/hooks/useSftpModalFileActions.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { useSftpModalCreateDelete } from "./useSftpModalCreateDelete";
|
||||
import { useSftpModalRename } from "./useSftpModalRename";
|
||||
import { useSftpModalPermissions } from "./useSftpModalPermissions";
|
||||
import { useSftpModalTextEditor } from "./useSftpModalTextEditor";
|
||||
import { useSftpModalFileOpener } from "./useSftpModalFileOpener";
|
||||
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
|
||||
interface UseSftpModalFileActionsParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
readLocalFile: (path: string) => Promise<ArrayBuffer>;
|
||||
readSftp: (sftpId: string, path: string) => Promise<string>;
|
||||
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
|
||||
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
|
||||
writeSftpBinary: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
|
||||
deleteLocalFile: (path: string) => Promise<void>;
|
||||
deleteSftp: (sftpId: string, path: string) => Promise<void>;
|
||||
mkdirLocal: (path: string) => Promise<void>;
|
||||
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
|
||||
renameSftp: (sftpId: string, oldPath: string, newPath: string) => Promise<void>;
|
||||
chmodSftp: (sftpId: string, path: string, permissions: string) => Promise<void>;
|
||||
statSftp: (sftpId: string, path: string) => Promise<{ permissions?: string }>;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
sftpAutoSync: boolean;
|
||||
getOpenerForFile: (name: string) => { openerType: FileOpenerType; systemApp?: SystemAppInfo } | null;
|
||||
setOpenerForExtension: (ext: string, openerType: FileOpenerType, systemApp?: SystemAppInfo) => void;
|
||||
downloadSftpToTempAndOpen: (sftpId: string, path: string, fileName: string, appPath: string, opts: { enableWatch: boolean }) => Promise<void>;
|
||||
selectApplication: () => Promise<{ path: string; name: string } | null>;
|
||||
}
|
||||
|
||||
interface UseSftpModalFileActionsResult {
|
||||
handleDelete: (file: RemoteFile) => Promise<void>;
|
||||
handleCreateFolder: () => Promise<void>;
|
||||
handleCreateFile: () => Promise<void>;
|
||||
showRenameDialog: boolean;
|
||||
setShowRenameDialog: (open: boolean) => void;
|
||||
renameTarget: RemoteFile | null;
|
||||
renameName: string;
|
||||
setRenameName: (value: string) => void;
|
||||
isRenaming: boolean;
|
||||
openRenameDialog: (file: RemoteFile) => void;
|
||||
handleRename: () => Promise<void>;
|
||||
showPermissionsDialog: boolean;
|
||||
setShowPermissionsDialog: (open: boolean) => void;
|
||||
permissionsTarget: RemoteFile | null;
|
||||
permissions: {
|
||||
owner: { read: boolean; write: boolean; execute: boolean };
|
||||
group: { read: boolean; write: boolean; execute: boolean };
|
||||
others: { read: boolean; write: boolean; execute: boolean };
|
||||
};
|
||||
isChangingPermissions: boolean;
|
||||
openPermissionsDialog: (file: RemoteFile) => Promise<void>;
|
||||
togglePermission: (role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => void;
|
||||
getOctalPermissions: () => string;
|
||||
getSymbolicPermissions: () => string;
|
||||
handleSavePermissions: () => Promise<void>;
|
||||
showFileOpenerDialog: boolean;
|
||||
setShowFileOpenerDialog: (open: boolean) => void;
|
||||
fileOpenerTarget: RemoteFile | null;
|
||||
setFileOpenerTarget: (target: RemoteFile | null) => void;
|
||||
openFileOpenerDialog: (file: RemoteFile) => void;
|
||||
handleFileOpenerSelect: (
|
||||
openerType: FileOpenerType,
|
||||
setAsDefault: boolean,
|
||||
systemApp?: SystemAppInfo,
|
||||
) => Promise<void>;
|
||||
handleSelectSystemApp: () => Promise<SystemAppInfo | null>;
|
||||
showTextEditor: boolean;
|
||||
setShowTextEditor: (open: boolean) => void;
|
||||
textEditorTarget: RemoteFile | null;
|
||||
setTextEditorTarget: (target: RemoteFile | null) => void;
|
||||
textEditorContent: string;
|
||||
setTextEditorContent: (value: string) => void;
|
||||
loadingTextContent: boolean;
|
||||
handleEditFile: (file: RemoteFile) => Promise<void>;
|
||||
handleSaveTextFile: (content: string) => Promise<void>;
|
||||
handleOpenFile: (file: RemoteFile) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpModalFileActions = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
readLocalFile,
|
||||
readSftp,
|
||||
writeLocalFile,
|
||||
writeSftp,
|
||||
writeSftpBinary,
|
||||
deleteLocalFile,
|
||||
deleteSftp,
|
||||
mkdirLocal,
|
||||
mkdirSftp,
|
||||
renameSftp,
|
||||
chmodSftp,
|
||||
statSftp,
|
||||
t,
|
||||
sftpAutoSync,
|
||||
getOpenerForFile,
|
||||
setOpenerForExtension,
|
||||
downloadSftpToTempAndOpen,
|
||||
selectApplication,
|
||||
}: UseSftpModalFileActionsParams): UseSftpModalFileActionsResult => {
|
||||
const { handleDelete, handleCreateFolder, handleCreateFile } =
|
||||
useSftpModalCreateDelete({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
deleteLocalFile,
|
||||
deleteSftp,
|
||||
mkdirLocal,
|
||||
mkdirSftp,
|
||||
writeLocalFile,
|
||||
writeSftpBinary,
|
||||
writeSftp,
|
||||
t,
|
||||
});
|
||||
|
||||
const {
|
||||
showRenameDialog,
|
||||
setShowRenameDialog,
|
||||
renameTarget,
|
||||
renameName,
|
||||
setRenameName,
|
||||
isRenaming,
|
||||
openRenameDialog,
|
||||
handleRename,
|
||||
} = useSftpModalRename({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
renameSftp,
|
||||
t,
|
||||
});
|
||||
|
||||
const {
|
||||
showPermissionsDialog,
|
||||
setShowPermissionsDialog,
|
||||
permissionsTarget,
|
||||
permissions,
|
||||
isChangingPermissions,
|
||||
openPermissionsDialog,
|
||||
togglePermission,
|
||||
getOctalPermissions,
|
||||
getSymbolicPermissions,
|
||||
handleSavePermissions,
|
||||
} = useSftpModalPermissions({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
chmodSftp,
|
||||
statSftp,
|
||||
t,
|
||||
});
|
||||
|
||||
const {
|
||||
showTextEditor,
|
||||
setShowTextEditor,
|
||||
textEditorTarget,
|
||||
setTextEditorTarget,
|
||||
textEditorContent,
|
||||
setTextEditorContent,
|
||||
loadingTextContent,
|
||||
handleEditFile,
|
||||
handleSaveTextFile,
|
||||
} = useSftpModalTextEditor({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
readLocalFile,
|
||||
readSftp,
|
||||
writeLocalFile,
|
||||
writeSftp,
|
||||
t,
|
||||
});
|
||||
|
||||
const {
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
openFileOpenerDialog,
|
||||
handleOpenFile,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
} = useSftpModalFileOpener({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
sftpAutoSync,
|
||||
getOpenerForFile,
|
||||
setOpenerForExtension,
|
||||
downloadSftpToTempAndOpen,
|
||||
selectApplication,
|
||||
t,
|
||||
handleEditFile,
|
||||
});
|
||||
|
||||
return {
|
||||
handleDelete,
|
||||
handleCreateFolder,
|
||||
handleCreateFile,
|
||||
showRenameDialog,
|
||||
setShowRenameDialog,
|
||||
renameTarget,
|
||||
renameName,
|
||||
setRenameName,
|
||||
isRenaming,
|
||||
openRenameDialog,
|
||||
handleRename,
|
||||
showPermissionsDialog,
|
||||
setShowPermissionsDialog,
|
||||
permissionsTarget,
|
||||
permissions,
|
||||
isChangingPermissions,
|
||||
openPermissionsDialog,
|
||||
togglePermission,
|
||||
getOctalPermissions,
|
||||
getSymbolicPermissions,
|
||||
handleSavePermissions,
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
openFileOpenerDialog,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
showTextEditor,
|
||||
setShowTextEditor,
|
||||
textEditorTarget,
|
||||
setTextEditorTarget,
|
||||
textEditorContent,
|
||||
setTextEditorContent,
|
||||
loadingTextContent,
|
||||
handleEditFile,
|
||||
handleSaveTextFile,
|
||||
handleOpenFile,
|
||||
};
|
||||
};
|
||||
154
components/sftp-modal/hooks/useSftpModalFileOpener.ts
Normal file
154
components/sftp-modal/hooks/useSftpModalFileOpener.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
import { getFileExtension, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
|
||||
interface UseSftpModalFileOpenerParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
sftpAutoSync: boolean;
|
||||
getOpenerForFile: (name: string) => { openerType: FileOpenerType; systemApp?: SystemAppInfo } | null;
|
||||
setOpenerForExtension: (ext: string, openerType: FileOpenerType, systemApp?: SystemAppInfo) => void;
|
||||
downloadSftpToTempAndOpen: (sftpId: string, path: string, fileName: string, appPath: string, opts: { enableWatch: boolean }) => Promise<void>;
|
||||
selectApplication: () => Promise<{ path: string; name: string } | null>;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
handleEditFile: (file: RemoteFile) => Promise<void>;
|
||||
}
|
||||
|
||||
interface UseSftpModalFileOpenerResult {
|
||||
showFileOpenerDialog: boolean;
|
||||
setShowFileOpenerDialog: (open: boolean) => void;
|
||||
fileOpenerTarget: RemoteFile | null;
|
||||
setFileOpenerTarget: (target: RemoteFile | null) => void;
|
||||
openFileOpenerDialog: (file: RemoteFile) => void;
|
||||
handleOpenFile: (file: RemoteFile) => Promise<void>;
|
||||
handleFileOpenerSelect: (
|
||||
openerType: FileOpenerType,
|
||||
setAsDefault: boolean,
|
||||
systemApp?: SystemAppInfo,
|
||||
) => Promise<void>;
|
||||
handleSelectSystemApp: () => Promise<SystemAppInfo | null>;
|
||||
}
|
||||
|
||||
export const useSftpModalFileOpener = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
sftpAutoSync,
|
||||
getOpenerForFile,
|
||||
setOpenerForExtension,
|
||||
downloadSftpToTempAndOpen,
|
||||
selectApplication,
|
||||
t,
|
||||
handleEditFile,
|
||||
}: UseSftpModalFileOpenerParams): UseSftpModalFileOpenerResult => {
|
||||
const [showFileOpenerDialog, setShowFileOpenerDialog] = useState(false);
|
||||
const [fileOpenerTarget, setFileOpenerTarget] = useState<RemoteFile | null>(null);
|
||||
|
||||
const openFileOpenerDialog = useCallback((file: RemoteFile) => {
|
||||
setFileOpenerTarget(file);
|
||||
setShowFileOpenerDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleOpenFile = useCallback(async (file: RemoteFile) => {
|
||||
const savedOpener = getOpenerForFile(file.name);
|
||||
|
||||
if (savedOpener) {
|
||||
if (savedOpener.openerType === "builtin-editor") {
|
||||
await handleEditFile(file);
|
||||
} else if (savedOpener.openerType === "system-app" && savedOpener.systemApp) {
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, file.name);
|
||||
if (isLocalSession) {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (bridge?.openWithApplication) {
|
||||
await bridge.openWithApplication(fullPath, savedOpener.systemApp.path);
|
||||
}
|
||||
} else {
|
||||
const sftpId = await ensureSftp();
|
||||
await downloadSftpToTempAndOpen(
|
||||
sftpId,
|
||||
fullPath,
|
||||
file.name,
|
||||
savedOpener.systemApp.path,
|
||||
{ enableWatch: sftpAutoSync },
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.openFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
openFileOpenerDialog(file);
|
||||
}
|
||||
}, [currentPath, downloadSftpToTempAndOpen, ensureSftp, getOpenerForFile, handleEditFile, isLocalSession, joinPath, openFileOpenerDialog, sftpAutoSync, t]);
|
||||
|
||||
const handleFileOpenerSelect = useCallback(
|
||||
async (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => {
|
||||
if (!fileOpenerTarget) return;
|
||||
|
||||
if (setAsDefault) {
|
||||
const ext = getFileExtension(fileOpenerTarget.name);
|
||||
setOpenerForExtension(ext, openerType, systemApp);
|
||||
}
|
||||
|
||||
setShowFileOpenerDialog(false);
|
||||
|
||||
if (openerType === "builtin-editor") {
|
||||
await handleEditFile(fileOpenerTarget);
|
||||
} else if (openerType === "system-app" && systemApp) {
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, fileOpenerTarget.name);
|
||||
if (isLocalSession) {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (bridge?.openWithApplication) {
|
||||
await bridge.openWithApplication(fullPath, systemApp.path);
|
||||
}
|
||||
} else {
|
||||
const sftpId = await ensureSftp();
|
||||
await downloadSftpToTempAndOpen(
|
||||
sftpId,
|
||||
fullPath,
|
||||
fileOpenerTarget.name,
|
||||
systemApp.path,
|
||||
{ enableWatch: sftpAutoSync },
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.openFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setFileOpenerTarget(null);
|
||||
},
|
||||
[currentPath, downloadSftpToTempAndOpen, ensureSftp, fileOpenerTarget, handleEditFile, isLocalSession, joinPath, sftpAutoSync, setOpenerForExtension, t],
|
||||
);
|
||||
|
||||
const handleSelectSystemApp = useCallback(async (): Promise<SystemAppInfo | null> => {
|
||||
const result = await selectApplication();
|
||||
if (result) {
|
||||
return { path: result.path, name: result.name };
|
||||
}
|
||||
return null;
|
||||
}, [selectApplication]);
|
||||
|
||||
return {
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
openFileOpenerDialog,
|
||||
handleOpenFile,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
};
|
||||
};
|
||||
156
components/sftp-modal/hooks/useSftpModalKeyboardShortcuts.ts
Normal file
156
components/sftp-modal/hooks/useSftpModalKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* useSftpModalKeyboardShortcuts
|
||||
*
|
||||
* Hook that handles keyboard shortcuts for SFTPModal operations.
|
||||
* Supports select all, rename, delete, refresh, and new folder.
|
||||
* Note: Copy/Cut/Paste are not supported in the modal as it's a single-pane view.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { KeyBinding, matchesKeyBinding } from "../../../domain/models";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
|
||||
// SFTP Modal action names that we handle (subset of main SFTP actions)
|
||||
const SFTP_MODAL_ACTIONS = new Set([
|
||||
"sftpSelectAll",
|
||||
"sftpRename",
|
||||
"sftpDelete",
|
||||
"sftpRefresh",
|
||||
"sftpNewFolder",
|
||||
]);
|
||||
|
||||
interface UseSftpModalKeyboardShortcutsParams {
|
||||
keyBindings: KeyBinding[];
|
||||
hotkeyScheme: "disabled" | "mac" | "pc";
|
||||
open: boolean;
|
||||
files: RemoteFile[];
|
||||
visibleFiles: RemoteFile[];
|
||||
selectedFiles: Set<string>;
|
||||
setSelectedFiles: (files: Set<string>) => void;
|
||||
onRefresh: () => void;
|
||||
onRename?: (file: RemoteFile) => void;
|
||||
onDelete?: (fileNames: string[]) => void;
|
||||
onNewFolder?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a keyboard event matches any SFTP action
|
||||
*/
|
||||
const matchSftpAction = (
|
||||
e: KeyboardEvent,
|
||||
keyBindings: KeyBinding[],
|
||||
isMac: boolean
|
||||
): { action: string; binding: KeyBinding } | null => {
|
||||
for (const binding of keyBindings) {
|
||||
if (binding.category !== "sftp") continue;
|
||||
const keyStr = isMac ? binding.mac : binding.pc;
|
||||
if (matchesKeyBinding(e, keyStr, isMac)) {
|
||||
return { action: binding.action, binding };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const useSftpModalKeyboardShortcuts = ({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
open,
|
||||
files,
|
||||
visibleFiles,
|
||||
selectedFiles,
|
||||
setSelectedFiles,
|
||||
onRefresh,
|
||||
onRename,
|
||||
onDelete,
|
||||
onNewFolder,
|
||||
}: UseSftpModalKeyboardShortcutsParams) => {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
// Skip if shortcuts are disabled or modal is not open
|
||||
if (hotkeyScheme === "disabled" || !open) return;
|
||||
|
||||
// Skip if focus is on an input element
|
||||
const target = e.target as HTMLElement;
|
||||
const isEditableTarget =
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable ||
|
||||
!!target.closest?.(".monaco-editor, .monaco-diff-editor, .monaco-inputbox");
|
||||
if (isEditableTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isMac = hotkeyScheme === "mac";
|
||||
const matched = matchSftpAction(e, keyBindings, isMac);
|
||||
if (!matched) return;
|
||||
|
||||
const { action } = matched;
|
||||
if (!SFTP_MODAL_ACTIONS.has(action)) return;
|
||||
|
||||
// Prevent default behavior
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
switch (action) {
|
||||
case "sftpSelectAll": {
|
||||
// Select all files
|
||||
const allFileNames = new Set(
|
||||
visibleFiles.filter((f) => f.name !== "..").map((f) => f.name)
|
||||
);
|
||||
setSelectedFiles(allFileNames);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpRename": {
|
||||
// Trigger rename for the first selected file
|
||||
const selectedArray = Array.from(selectedFiles);
|
||||
if (selectedArray.length !== 1) return;
|
||||
const file = files.find((f) => f.name === selectedArray[0]);
|
||||
if (file && onRename) {
|
||||
onRename(file);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpDelete": {
|
||||
// Delete selected files
|
||||
const selectedArray = Array.from(selectedFiles);
|
||||
if (selectedArray.length === 0) return;
|
||||
onDelete?.(selectedArray);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpRefresh": {
|
||||
// Refresh file list
|
||||
onRefresh();
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpNewFolder": {
|
||||
// Create new folder
|
||||
onNewFolder?.();
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
hotkeyScheme,
|
||||
open,
|
||||
files,
|
||||
visibleFiles,
|
||||
selectedFiles,
|
||||
setSelectedFiles,
|
||||
onRefresh,
|
||||
onRename,
|
||||
onDelete,
|
||||
onNewFolder,
|
||||
keyBindings,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Use capture phase to intercept before other handlers
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, [handleKeyDown]);
|
||||
};
|
||||
135
components/sftp-modal/hooks/useSftpModalPath.ts
Normal file
135
components/sftp-modal/hooks/useSftpModalPath.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { breadcrumbPathAt, getBreadcrumbs, getRootPath, getWindowsDrive, isWindowsPath } from "../pathUtils";
|
||||
|
||||
interface UseSftpModalPathParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
localHomePath: string | null;
|
||||
onNavigate: (path: string) => void;
|
||||
maxVisibleBreadcrumbParts?: number;
|
||||
}
|
||||
|
||||
interface UseSftpModalPathResult {
|
||||
isEditingPath: boolean;
|
||||
editingPathValue: string;
|
||||
setEditingPathValue: (value: string) => void;
|
||||
pathInputRef: React.RefObject<HTMLInputElement>;
|
||||
handlePathDoubleClick: () => void;
|
||||
handlePathSubmit: () => void;
|
||||
handlePathKeyDown: (e: React.KeyboardEvent) => void;
|
||||
breadcrumbs: string[];
|
||||
visibleBreadcrumbs: { part: string; originalIndex: number }[];
|
||||
hiddenBreadcrumbs: { part: string; originalIndex: number }[];
|
||||
needsBreadcrumbTruncation: boolean;
|
||||
breadcrumbPathAtForIndex: (index: number) => string;
|
||||
rootLabel: string;
|
||||
rootPath: string;
|
||||
}
|
||||
|
||||
export const useSftpModalPath = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
localHomePath,
|
||||
onNavigate,
|
||||
maxVisibleBreadcrumbParts = 4,
|
||||
}: UseSftpModalPathParams): UseSftpModalPathResult => {
|
||||
const [isEditingPath, setIsEditingPath] = useState(false);
|
||||
const [editingPathValue, setEditingPathValue] = useState("");
|
||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handlePathDoubleClick = () => {
|
||||
setEditingPathValue(currentPath);
|
||||
setIsEditingPath(true);
|
||||
setTimeout(() => pathInputRef.current?.select(), 0);
|
||||
};
|
||||
|
||||
const handlePathSubmit = () => {
|
||||
const fallbackPath = localHomePath || getRootPath(currentPath, isLocalSession);
|
||||
const newPath = editingPathValue.trim() || fallbackPath;
|
||||
setIsEditingPath(false);
|
||||
if (newPath !== currentPath) {
|
||||
if (isLocalSession) {
|
||||
onNavigate(newPath);
|
||||
} else {
|
||||
onNavigate(newPath.startsWith("/") ? newPath : `/${newPath}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePathKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handlePathSubmit();
|
||||
} else if (e.key === "Escape") {
|
||||
setIsEditingPath(false);
|
||||
}
|
||||
};
|
||||
|
||||
const breadcrumbs = useMemo(
|
||||
() => getBreadcrumbs(currentPath, isLocalSession),
|
||||
[currentPath, isLocalSession],
|
||||
);
|
||||
|
||||
const { visibleBreadcrumbs, hiddenBreadcrumbs, needsBreadcrumbTruncation } =
|
||||
useMemo(() => {
|
||||
if (breadcrumbs.length <= maxVisibleBreadcrumbParts) {
|
||||
return {
|
||||
visibleBreadcrumbs: breadcrumbs.map((part, idx) => ({ part, originalIndex: idx })),
|
||||
hiddenBreadcrumbs: [] as { part: string; originalIndex: number }[],
|
||||
needsBreadcrumbTruncation: false,
|
||||
};
|
||||
}
|
||||
|
||||
const firstPart = [{ part: breadcrumbs[0], originalIndex: 0 }];
|
||||
const lastPartsCount = maxVisibleBreadcrumbParts - 1;
|
||||
const lastParts = breadcrumbs.slice(-lastPartsCount).map((part, idx) => ({
|
||||
part,
|
||||
originalIndex: breadcrumbs.length - lastPartsCount + idx,
|
||||
}));
|
||||
const hidden = breadcrumbs.slice(1, -lastPartsCount).map((part, idx) => ({
|
||||
part,
|
||||
originalIndex: idx + 1,
|
||||
}));
|
||||
|
||||
return {
|
||||
visibleBreadcrumbs: [...firstPart, ...lastParts],
|
||||
hiddenBreadcrumbs: hidden,
|
||||
needsBreadcrumbTruncation: true,
|
||||
};
|
||||
}, [breadcrumbs, maxVisibleBreadcrumbParts]);
|
||||
|
||||
const breadcrumbPathAtForIndex = useCallback(
|
||||
(index: number) =>
|
||||
breadcrumbPathAt(breadcrumbs, index, currentPath, isLocalSession),
|
||||
[breadcrumbs, currentPath, isLocalSession],
|
||||
);
|
||||
|
||||
const rootLabel = useMemo(
|
||||
() =>
|
||||
isLocalSession && isWindowsPath(currentPath)
|
||||
? getWindowsDrive(currentPath) ?? "C:"
|
||||
: "/",
|
||||
[currentPath, isLocalSession],
|
||||
);
|
||||
|
||||
const rootPath = useMemo(
|
||||
() => getRootPath(currentPath, isLocalSession),
|
||||
[currentPath, isLocalSession],
|
||||
);
|
||||
|
||||
return {
|
||||
isEditingPath,
|
||||
editingPathValue,
|
||||
setEditingPathValue,
|
||||
pathInputRef,
|
||||
handlePathDoubleClick,
|
||||
handlePathSubmit,
|
||||
handlePathKeyDown,
|
||||
breadcrumbs,
|
||||
visibleBreadcrumbs,
|
||||
hiddenBreadcrumbs,
|
||||
needsBreadcrumbTruncation,
|
||||
breadcrumbPathAtForIndex,
|
||||
rootLabel,
|
||||
rootPath,
|
||||
};
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user