Compare commits
403 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1,7 +1,19 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx tsc:*)"
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(npm run lint:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(gh pr view:*)",
|
||||
"Bash(gh pr list:*)",
|
||||
"Bash(gh api:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(gh issue view:*)",
|
||||
"Bash(npm run dev:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(sftp\\): bundle folder uploads and improve cancel/delete operations\n\n- Bundle folder uploads as single tasks showing aggregate progress\n- Add unique file transfer IDs for proper cancellation tracking\n- Fix cancel button to call cancelExternalUpload for external uploads\n- Improve backend cancel detection using cancelled flag instead of error message\n- Use SSH exec with rm -rf for fast folder deletion on remote servers\n- Add FolderUp icon for folder upload tasks in transfer queue\n- Add i18n key for upload cancelled message\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git push:*)",
|
||||
"Bash(gh pr create --title \"feat\\(sftp\\): bundle folder uploads and improve cancel/delete operations\" --body \"$\\(cat <<''EOF''\n## Summary\n\n- **Bundle folder uploads as single tasks** - When uploading a folder from computer, show it as one aggregated task with total progress instead of individual files\n- **Fix cancel upload** - Properly cancel external uploads by calling the correct cancel function and using unique file transfer IDs for backend tracking\n- **Fast folder deletion** - Use SSH exec with `rm -rf` command for remote folder deletion instead of slow recursive SFTP rmdir\n- **UI improvements** - Add FolderUp icon for folder upload tasks, add cancelled status toast message\n\n## Changes\n\n### Bundle folder uploads\n- Added `detectRootFolders` helper to group entries by root folder\n- Create single bundled task per folder with aggregate byte count\n- Track progress across all files in the bundle\n\n### Fix cancel upload\n- Each file now uses unique `fileTransferId` for backend cancellation tracking\n- Added `activeFileTransferIdsRef` to track all active uploads\n- Modified `cancelExternalUpload` to cancel all active file uploads\n- Backend now checks `uploadState.cancelled` flag instead of just error message\n- Frontend catch block checks `cancelUploadRef.current` to break out of loop\n\n### Fast folder deletion\n- Added `execSshCommand` helper function in sftpBridge.cjs\n- Uses `client.client` \\(underlying ssh2 Client\\) to execute `rm -rf` command\n- Falls back to SFTP rmdir if SSH exec fails\n\n## Test plan\n- [ ] Drag a folder from computer to SFTP pane - should show as single task with aggregate progress\n- [ ] Click cancel button during folder upload - should stop immediately without errors\n- [ ] Delete a large folder on remote server - should complete quickly using rm -rf\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\nEOF\n\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
104
.github/scripts/generate-release-note.js
vendored
Normal file
104
.github/scripts/generate-release-note.js
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
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}'
|
||||
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-x64.AppImage`,
|
||||
arm64: `Netcatty-${version}-linux-arm64.AppImage`
|
||||
},
|
||||
deb: {
|
||||
x64: `Netcatty-${version}-linux-x64.deb`,
|
||||
arm64: `Netcatty-${version}-linux-arm64.deb`
|
||||
},
|
||||
rpm: {
|
||||
x64: `Netcatty-${version}-linux-x64.rpm`,
|
||||
arm64: `Netcatty-${version}-linux-arm64.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 }}
|
||||
|
||||
42
.github/workflows/sync.yml
vendored
Normal file
42
.github/workflows/sync.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: Sync Upstream
|
||||
|
||||
env:
|
||||
UPSTREAM_URL: "https://github.com/binaricat/Netcatty.git"
|
||||
UPSTREAM_BRANCH: "main"
|
||||
TARGET_BRANCH: "main"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # Run daily at midnight
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Merge Upstream
|
||||
run: |
|
||||
echo "Adding upstream remote..."
|
||||
git remote add upstream ${{ env.UPSTREAM_URL }}
|
||||
git fetch upstream ${{ env.UPSTREAM_BRANCH }}
|
||||
|
||||
echo "Merging upstream/${{ env.UPSTREAM_BRANCH }} into ${{ env.TARGET_BRANCH }}..."
|
||||
# This will fail if there are conflicts, which is the desired behavior (notify user via failure)
|
||||
git merge upstream/${{ env.UPSTREAM_BRANCH }} --no-edit
|
||||
|
||||
echo "Pushing changes..."
|
||||
git push origin ${{ env.TARGET_BRANCH }}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,6 +22,7 @@ coverage
|
||||
/release
|
||||
/out
|
||||
*.asar
|
||||
/public/monaco
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
245
App.tsx
245
App.tsx
@@ -1,11 +1,15 @@
|
||||
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 { 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';
|
||||
@@ -17,12 +21,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();
|
||||
@@ -145,6 +155,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
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 +173,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
isHotkeyRecording,
|
||||
sessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
} = settings;
|
||||
|
||||
const {
|
||||
@@ -171,6 +188,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
knownHosts,
|
||||
shellHistory,
|
||||
connectionLogs,
|
||||
managedSources,
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
updateIdentities,
|
||||
@@ -178,6 +196,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
updateSnippetPackages,
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
addShellHistoryEntry,
|
||||
addConnectionLog,
|
||||
updateConnectionLog,
|
||||
@@ -229,6 +248,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
logViews,
|
||||
openLogView,
|
||||
closeLogView,
|
||||
copySession,
|
||||
} = useSessionState();
|
||||
|
||||
// isMacClient is used for window controls styling
|
||||
@@ -254,6 +274,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
},
|
||||
});
|
||||
|
||||
const { clearAndRemoveSource, clearAndRemoveSources, unmanageSource } = useManagedSourceSync({
|
||||
hosts,
|
||||
managedSources,
|
||||
onUpdateManagedSources: updateManagedSources,
|
||||
});
|
||||
|
||||
const handleSyncNowManual = useCallback(() => {
|
||||
return handleSyncNow({ trigger: 'manual' });
|
||||
}, [handleSyncNow]);
|
||||
@@ -280,6 +306,131 @@ 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,
|
||||
});
|
||||
|
||||
// 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;
|
||||
@@ -557,7 +708,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) => {
|
||||
@@ -608,6 +759,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({
|
||||
@@ -624,6 +794,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 });
|
||||
@@ -657,10 +845,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) => {
|
||||
@@ -737,6 +947,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
isMacClient={isMacClient}
|
||||
onCloseSession={closeSession}
|
||||
onRenameSession={startSessionRename}
|
||||
onCopySession={copySession}
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
onCloseWorkspace={closeWorkspace}
|
||||
onCloseLogView={closeLogView}
|
||||
@@ -761,11 +972,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
knownHosts={knownHosts}
|
||||
shellHistory={shellHistory}
|
||||
connectionLogs={connectionLogs}
|
||||
managedSources={managedSources}
|
||||
sessions={sessions}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
onConnectSerial={createSerialSession}
|
||||
onConnectSerial={handleConnectSerial}
|
||||
onDeleteHost={handleDeleteHost}
|
||||
onConnect={handleConnectToHost}
|
||||
onUpdateHosts={updateHosts}
|
||||
@@ -775,6 +987,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}
|
||||
@@ -941,6 +1157,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
<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">
|
||||
@@ -39,7 +40,7 @@
|
||||
|
||||
---
|
||||
|
||||
[](screenshots/main-window-dark.png)
|
||||
[](screenshots/vault_grid_view.png)
|
||||
|
||||
---
|
||||
|
||||
@@ -97,6 +98,8 @@
|
||||
|
||||
### 📁 SFTP
|
||||
- **デュアルペインファイルブラウザ** — ローカル ↔ リモート または リモート ↔ リモート
|
||||
- **Sudo 特権昇格** — sudo を使用して root 権限のファイルを閲覧および編集
|
||||
- **ドラッグ&ドロップ** アップロードおよびダウンロード
|
||||
- **ドラッグ&ドロップ**ファイル転送
|
||||
- **キュー管理**でバッチ転送
|
||||
- **進捗追跡**、転送速度表示
|
||||
@@ -138,15 +141,15 @@ Vault ビューはすべての SSH 接続を管理するコマンドセンター
|
||||
|
||||
**ダークモード**
|
||||
|
||||

|
||||

|
||||
|
||||
**ライトモード**
|
||||
**ネストされたフォルダと整理**
|
||||
|
||||

|
||||

|
||||
|
||||
**リストビュー**
|
||||
|
||||

|
||||

|
||||
|
||||
<a name="ターミナル"></a>
|
||||
## ターミナル
|
||||
@@ -155,18 +158,28 @@ WebGL アクセラレーション対応の xterm.js ベースのターミナル
|
||||
|
||||
**分割ウィンドウ**
|
||||
|
||||

|
||||
**ブロードキャストモード**
|
||||
|
||||
**テーマカスタマイズ**
|
||||
一度入力すれば、どこでも実行できます。複数のサーバーを同時にメンテナンスするのに最適です。
|
||||
|
||||

|
||||

|
||||
|
||||
**パフォーマンス情報とカスタマイズ**
|
||||
|
||||
接続の健全性を監視し、ターミナルのあらゆる側面をカスタマイズします。
|
||||
|
||||

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

|
||||

|
||||
|
||||
**転送キュー**
|
||||
|
||||

|
||||
|
||||
<a name="キーチェーン"></a>
|
||||
## キーチェーン
|
||||
@@ -188,6 +201,10 @@ WebGL アクセラレーション対応の xterm.js ベースのターミナル
|
||||
|
||||

|
||||
|
||||
**キー生成**
|
||||
|
||||

|
||||
|
||||
<a name="ポートフォワーディング"></a>
|
||||
## ポートフォワーディング
|
||||
|
||||
@@ -263,11 +280,11 @@ Netcatty は接続したホストの OS アイコンを自動的に検出・表
|
||||
|
||||
[GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) からお使いのプラットフォームに対応した最新版をダウンロードしてください。
|
||||
|
||||
| プラットフォーム | アーキテクチャ | ステータス |
|
||||
|------------------|----------------|------------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ サポート |
|
||||
| **macOS** | Intel | ✅ サポート |
|
||||
| **Windows** | x64 | ✅ サポート |
|
||||
| OS | サポート状況 |
|
||||
| :--- | :--- |
|
||||
| **macOS** | Universal (x64 / arm64) |
|
||||
| **Windows** | x64 / arm64 |
|
||||
| **Linux** | x64 / arm64 |
|
||||
|
||||
または [GitHub Releases](https://github.com/binaricat/Netcatty/releases) ですべてのリリースを参照してください。
|
||||
|
||||
@@ -365,6 +382,17 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
---
|
||||
|
||||
<a name="コントリビューター"></a>
|
||||
# コントリビューター
|
||||
|
||||
貢献してくれたすべての人々に感謝します!
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<a name="ライセンス"></a>
|
||||
# ライセンス
|
||||
|
||||
|
||||
66
README.md
66
README.md
@@ -5,7 +5,8 @@
|
||||
<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">
|
||||
@@ -16,7 +17,7 @@
|
||||
<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>
|
||||
@@ -39,7 +40,7 @@
|
||||
|
||||
---
|
||||
|
||||
[](screenshots/main-window-dark.png)
|
||||
[](screenshots/vault_grid_view.png)
|
||||
|
||||
---
|
||||
|
||||
@@ -67,7 +68,7 @@
|
||||
<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
|
||||
@@ -97,7 +98,8 @@
|
||||
|
||||
### 📁 SFTP
|
||||
- **Dual-pane file browser** — local ↔ remote or remote ↔ remote
|
||||
- **Drag & drop** file transfers
|
||||
- **Sudo Privilege Escalation** — Browse and edit root-owned files with sudo
|
||||
- **Drag & Drop** uploads and downloads
|
||||
- **Queue management** for batch transfers
|
||||
- **Progress tracking** with transfer speed
|
||||
|
||||
@@ -138,15 +140,15 @@ The Vault view is your command center for managing all SSH connections. Create h
|
||||
|
||||
**Dark Mode**
|
||||
|
||||

|
||||

|
||||
|
||||
**Light Mode**
|
||||
**Nested Folders & Organization**
|
||||
|
||||

|
||||

|
||||
|
||||
**List View**
|
||||
|
||||

|
||||

|
||||
|
||||
<a name="terminal"></a>
|
||||
## Terminal
|
||||
@@ -155,18 +157,28 @@ Powered by xterm.js with WebGL acceleration, the terminal delivers a smooth, res
|
||||
|
||||
**Split Windows**
|
||||
|
||||

|
||||
**Broadcast Mode**
|
||||
|
||||
**Theme Customization**
|
||||
Type once, execute everywhere. Great for maintaining multiple servers simultaneously.
|
||||
|
||||

|
||||

|
||||
|
||||
**Performance Info & Customization**
|
||||
|
||||
Monitor your connection health and customize every aspect of your terminal.
|
||||
|
||||

|
||||
|
||||
<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.
|
||||
|
||||

|
||||

|
||||
|
||||
**Transfer Queue**
|
||||
|
||||

|
||||
|
||||
<a name="keychain"></a>
|
||||
## Keychain
|
||||
@@ -188,6 +200,10 @@ The Keychain is your secure vault for SSH credentials. Generate new keys, import
|
||||
|
||||

|
||||
|
||||
**Key Generator**
|
||||
|
||||

|
||||
|
||||
<a name="port-forwarding"></a>
|
||||
## Port Forwarding
|
||||
|
||||
@@ -263,11 +279,11 @@ Netcatty automatically detects and displays OS icons for connected hosts:
|
||||
|
||||
Download the latest release for your platform from [GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest).
|
||||
|
||||
| Platform | Architecture | Status |
|
||||
|----------|--------------|--------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ Supported |
|
||||
| **macOS** | Intel | ✅ Supported |
|
||||
| **Windows** | x64 | ✅ Supported |
|
||||
| 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).
|
||||
|
||||
@@ -279,7 +295,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
|
||||
|
||||
@@ -329,6 +345,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)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -364,6 +381,17 @@ See [agents.md](agents.md) for architecture overview and coding conventions.
|
||||
|
||||
---
|
||||
|
||||
<a name="contributors"></a>
|
||||
# Contributors
|
||||
|
||||
Thanks to all the people who contribute!
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<a name="license"></a>
|
||||
# License
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
<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">
|
||||
@@ -39,7 +40,7 @@
|
||||
|
||||
---
|
||||
|
||||
[](screenshots/main-window-dark.png)
|
||||
[](screenshots/vault_grid_view.png)
|
||||
|
||||
---
|
||||
|
||||
@@ -97,6 +98,8 @@
|
||||
|
||||
### 📁 SFTP
|
||||
- **双窗格文件浏览器** —— 本地 ↔ 远程 或 远程 ↔ 远程
|
||||
- **Sudo 提权支持** —— 使用 sudo 浏览和编辑 root 权限文件
|
||||
- **拖放操作** —— 支持上传和下载
|
||||
- **拖放传输** 文件
|
||||
- **队列管理** 批量传输
|
||||
- **进度跟踪** 显示传输速度
|
||||
@@ -138,15 +141,15 @@ Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建
|
||||
|
||||
**深色模式**
|
||||
|
||||

|
||||

|
||||
|
||||
**浅色模式**
|
||||
**层级文件夹与分组**
|
||||
|
||||

|
||||

|
||||
|
||||
**列表视图**
|
||||
|
||||

|
||||

|
||||
|
||||
<a name="终端"></a>
|
||||
## 终端
|
||||
@@ -155,18 +158,28 @@ Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建
|
||||
|
||||
**分屏窗口**
|
||||
|
||||

|
||||
**广播模式**
|
||||
|
||||
**主题定制**
|
||||
一次输入,多处执行。非常适合同时维护这多台服务器。
|
||||
|
||||

|
||||

|
||||
|
||||
**性能信息与定制**
|
||||
|
||||
监控连接健康状况,并自定义终端的方方面面。
|
||||
|
||||

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

|
||||

|
||||
|
||||
**传输队列**
|
||||
|
||||

|
||||
|
||||
<a name="密钥管理"></a>
|
||||
## 密钥管理
|
||||
@@ -188,6 +201,10 @@ Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建
|
||||
|
||||

|
||||
|
||||
**密钥生成器**
|
||||
|
||||

|
||||
|
||||
<a name="端口转发"></a>
|
||||
## 端口转发
|
||||
|
||||
@@ -263,11 +280,11 @@ Netcatty 自动检测并显示已连接主机的操作系统图标:
|
||||
|
||||
从 [GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) 下载适合您平台的最新版本。
|
||||
|
||||
| 平台 | 架构 | 状态 |
|
||||
|------|------|------|
|
||||
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ 支持 |
|
||||
| **macOS** | Intel | ✅ 支持 |
|
||||
| **Windows** | x64 | ✅ 支持 |
|
||||
| 操作系统 | 支持情况 |
|
||||
| :--- | :--- |
|
||||
| **macOS** | Universal (x64 / arm64) |
|
||||
| **Windows** | x64 / arm64 |
|
||||
| **Linux** | x64 / arm64 |
|
||||
|
||||
或在 [GitHub Releases](https://github.com/binaricat/Netcatty/releases) 浏览所有版本。
|
||||
|
||||
@@ -365,6 +382,17 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
---
|
||||
|
||||
<a name="贡献者"></a>
|
||||
# 贡献者
|
||||
|
||||
感谢所有参与贡献的人!
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<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.
|
||||
|
||||
@@ -39,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}',
|
||||
@@ -61,6 +62,39 @@ 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 > 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 > Application
|
||||
'settings.application.checkUpdates': 'Check for updates',
|
||||
@@ -104,9 +138,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',
|
||||
@@ -179,6 +219,21 @@ const en: Messages = {
|
||||
'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',
|
||||
@@ -259,6 +314,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',
|
||||
@@ -266,6 +326,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',
|
||||
|
||||
@@ -274,10 +337,26 @@ 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 import
|
||||
'vault.import.title': 'Add data to your vault',
|
||||
@@ -294,6 +373,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...',
|
||||
@@ -372,17 +463,26 @@ const en: Messages = {
|
||||
'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',
|
||||
@@ -404,14 +504,21 @@ const en: Messages = {
|
||||
'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',
|
||||
@@ -419,6 +526,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...',
|
||||
@@ -432,11 +546,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',
|
||||
@@ -449,6 +568,106 @@ 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 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',
|
||||
@@ -487,6 +706,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',
|
||||
@@ -495,6 +720,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',
|
||||
@@ -505,16 +733,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',
|
||||
@@ -555,6 +787,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',
|
||||
@@ -589,7 +827,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',
|
||||
@@ -600,6 +838,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',
|
||||
@@ -617,6 +856,27 @@ const en: Messages = {
|
||||
'terminal.toolbar.focus': 'Focus',
|
||||
'terminal.toolbar.focusMode': 'Focus Mode',
|
||||
'terminal.toolbar.closeSession': 'Close session',
|
||||
'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.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)',
|
||||
@@ -647,6 +907,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',
|
||||
@@ -908,6 +1173,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 *',
|
||||
@@ -959,6 +1225,14 @@ const en: Messages = {
|
||||
'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',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': 'Serial',
|
||||
'serial.modal.title': 'Connect to Serial Port',
|
||||
@@ -968,11 +1242,12 @@ const en: Messages = {
|
||||
'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',
|
||||
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001 or COM1',
|
||||
'serial.type.hardware': 'Hardware',
|
||||
'serial.type.pseudo': 'Pseudo Terminal',
|
||||
'serial.type.custom': 'Custom',
|
||||
@@ -989,6 +1264,39 @@ const en: Messages = {
|
||||
'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',
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -27,6 +27,7 @@ const zhCN: Messages = {
|
||||
'sort.za': 'Z-a',
|
||||
'sort.newest': '从新到旧',
|
||||
'sort.oldest': '从旧到新',
|
||||
'sort.group': '按分组',
|
||||
'field.label': 'Label',
|
||||
'field.type': '类型',
|
||||
'auth.keyType': '类型 {type}',
|
||||
@@ -49,6 +50,39 @@ 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 > 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 > Application
|
||||
'settings.application.checkUpdates': '检查更新',
|
||||
@@ -91,6 +125,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': '新建主机',
|
||||
@@ -149,6 +185,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': '父级',
|
||||
@@ -156,6 +197,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} 个在线',
|
||||
|
||||
@@ -164,10 +208,26 @@ 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 import
|
||||
'vault.import.title': '添加数据到你的 Vault',
|
||||
@@ -182,6 +242,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': '搜索已知主机...',
|
||||
@@ -249,14 +321,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': '打开',
|
||||
@@ -278,14 +357,21 @@ const zhCN: Messages = {
|
||||
'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': '加载目录失败',
|
||||
@@ -293,6 +379,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': '搜索主机...',
|
||||
@@ -301,11 +394,13 @@ 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': '搜索主机或标签页',
|
||||
@@ -341,6 +436,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': '凭据',
|
||||
@@ -349,6 +450,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': '密钥 / 证书',
|
||||
@@ -359,12 +463,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': '配置代理',
|
||||
@@ -381,6 +489,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': '编辑主机',
|
||||
@@ -414,7 +528,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': '收藏',
|
||||
@@ -425,6 +539,7 @@ const zhCN: Messages = {
|
||||
'logView.customizeAppearance': '自定义外观',
|
||||
'logView.appearance': '外观',
|
||||
'logView.readOnly': '只读',
|
||||
'logView.export': '导出',
|
||||
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': '打开 SFTP',
|
||||
@@ -442,6 +557,27 @@ const zhCN: Messages = {
|
||||
'terminal.toolbar.focus': '聚焦',
|
||||
'terminal.toolbar.focusMode': '聚焦模式',
|
||||
'terminal.toolbar.closeSession': '关闭会话',
|
||||
'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.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)',
|
||||
@@ -473,6 +609,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': '字体',
|
||||
@@ -669,6 +810,8 @@ const zhCN: Messages = {
|
||||
'pf.deleteActive.title': '删除正在运行的端口转发?',
|
||||
'pf.deleteActive.desc': '端口转发规则 "{label}" 当前正在运行。删除前将先关闭转发连接。',
|
||||
'pf.deleteActive.confirm': '关闭并删除',
|
||||
'pf.form.autoStart': '自动启动',
|
||||
'pf.form.autoStartDesc': '应用启动时自动开启此规则',
|
||||
|
||||
// SFTP (pane + conflict)
|
||||
'sftp.pane.local': '本地',
|
||||
@@ -676,6 +819,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': '已存在',
|
||||
@@ -688,8 +834,112 @@ 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 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': '键盘',
|
||||
@@ -756,6 +1006,21 @@ const zhCN: Messages = {
|
||||
'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': '快捷键方案',
|
||||
@@ -897,6 +1162,7 @@ const zhCN: Messages = {
|
||||
'tabs.closeLogViewAria': '关闭日志视图',
|
||||
'tabs.logPrefix': '日志:',
|
||||
'tabs.logLocal': '本地',
|
||||
'tabs.copyTab': '复制标签页',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
'keychain.edit.keyLabelPlaceholder': '密钥 Label',
|
||||
'keychain.edit.privateKeyRequired': '私钥 *',
|
||||
@@ -948,6 +1214,14 @@ const zhCN: Messages = {
|
||||
'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': '代码包名称只能包含字母、数字、连字符和下划线',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': '串口',
|
||||
'serial.modal.title': '连接串口',
|
||||
@@ -957,11 +1231,12 @@ const zhCN: Messages = {
|
||||
'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',
|
||||
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
|
||||
'serial.type.hardware': '硬件',
|
||||
'serial.type.pseudo': '虚拟终端',
|
||||
'serial.type.custom': '自定义',
|
||||
@@ -978,6 +1253,39 @@ const zhCN: Messages = {
|
||||
'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': '跳过',
|
||||
};
|
||||
|
||||
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],
|
||||
);
|
||||
535
application/state/sftp/useSftpPaneActions.ts
Normal file
535
application/state/sftp/useSftpPaneActions.ts
Normal file
@@ -0,0 +1,535 @@
|
||||
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>;
|
||||
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 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,
|
||||
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,
|
||||
};
|
||||
};
|
||||
785
application/state/sftp/useSftpTransfers.ts
Normal file
785
application/state/sftp/useSftpTransfers.ts
Normal file
@@ -0,0 +1,785 @@
|
||||
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",
|
||||
) => Promise<void>;
|
||||
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>;
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
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,
|
||||
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",
|
||||
) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
} 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
|
||||
return;
|
||||
}
|
||||
|
||||
updateTask({
|
||||
status: "failed",
|
||||
error: err instanceof Error ? err.message : "Transfer failed",
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const startTransfer = useCallback(
|
||||
async (
|
||||
sourceFiles: { name: string; isDirectory: boolean }[],
|
||||
sourceSide: "left" | "right",
|
||||
targetSide: "left" | "right",
|
||||
) => {
|
||||
const 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 = sourcePane.connection.currentPath;
|
||||
const targetPath = targetPane.connection.currentPath;
|
||||
|
||||
const sourceSftpId = sourcePane.connection.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(sourcePane.connection.id);
|
||||
|
||||
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,
|
||||
sourcePath: joinPath(sourcePath, file.name),
|
||||
targetPath: joinPath(targetPath, file.name),
|
||||
sourceConnectionId: sourcePane.connection!.id,
|
||||
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]);
|
||||
|
||||
for (const task of newTasks) {
|
||||
await processTransfer(task, sourcePane, targetPane, targetSide);
|
||||
}
|
||||
},
|
||||
// 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,
|
||||
),
|
||||
);
|
||||
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 };
|
||||
};
|
||||
|
||||
|
||||
358
application/state/useManagedSourceSync.ts
Normal file
358
application/state/useManagedSourceSync.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
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]));
|
||||
|
||||
// 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 = prevHosts.filter((h) => h.managedSourceId === source.id);
|
||||
const currManaged = hosts.filter((h) => h.managedSourceId === 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]);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Host, PortForwardingRule } from "../../domain/models";
|
||||
import {
|
||||
STORAGE_KEY_PF_PREFER_FORM_MODE,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
clearReconnectTimer,
|
||||
getActiveConnection,
|
||||
getActiveRuleIds,
|
||||
startPortForward,
|
||||
@@ -51,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,
|
||||
@@ -74,6 +76,9 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE) ?? false;
|
||||
});
|
||||
|
||||
// Track if sync has been executed for this component instance
|
||||
const syncExecutedRef = useRef(false);
|
||||
|
||||
const setPreferFormMode = useCallback((prefer: boolean) => {
|
||||
setPreferFormModeState(prefer);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE, prefer);
|
||||
@@ -82,9 +87,12 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
// Load rules from storage on mount and sync with backend
|
||||
useEffect(() => {
|
||||
const loadAndSync = async () => {
|
||||
// First, sync with backend to get any active tunnels
|
||||
await syncWithBackend();
|
||||
|
||||
// Only sync once per component instance (prevents duplicate calls from React StrictMode)
|
||||
if (!syncExecutedRef.current) {
|
||||
syncExecutedRef.current = true;
|
||||
await syncWithBackend();
|
||||
}
|
||||
|
||||
const saved = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
@@ -212,11 +220,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],
|
||||
);
|
||||
@@ -226,6 +235,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);
|
||||
|
||||
@@ -72,6 +72,37 @@ export const useSessionState = () => {
|
||||
}, [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,
|
||||
@@ -516,6 +547,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 => {
|
||||
@@ -631,5 +687,7 @@ export const useSessionState = () => {
|
||||
logViews,
|
||||
openLogView,
|
||||
closeLogView,
|
||||
// Copy session
|
||||
copySession,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { SyncConfig, TerminalSettings, DEFAULT_TERMINAL_SETTINGS, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat } from '../../domain/models';
|
||||
import {
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_SYNC,
|
||||
@@ -16,11 +16,22 @@ 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_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
} 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 +43,18 @@ 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;
|
||||
|
||||
// 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 +82,14 @@ 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 applyThemeTokens = (
|
||||
theme: 'light' | 'dark',
|
||||
tokens: UiThemeTokens,
|
||||
@@ -97,13 +124,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 +155,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);
|
||||
@@ -146,13 +179,46 @@ 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;
|
||||
});
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
||||
// Helper to notify other windows about settings changes via IPC
|
||||
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
|
||||
@@ -213,6 +279,15 @@ 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();
|
||||
@@ -237,6 +312,11 @@ export const useSettingsState = () => {
|
||||
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);
|
||||
}
|
||||
@@ -323,6 +403,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) {
|
||||
@@ -371,11 +456,38 @@ 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);
|
||||
}
|
||||
}
|
||||
// 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]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
@@ -415,7 +527,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 +538,46 @@ 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]);
|
||||
|
||||
// Get merged key bindings (defaults + custom overrides)
|
||||
const keyBindings = useMemo((): KeyBinding[] => {
|
||||
return DEFAULT_KEY_BINDINGS.map(binding => {
|
||||
@@ -484,8 +636,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>(
|
||||
@@ -506,6 +658,8 @@ export const useSettingsState = () => {
|
||||
setAccentMode,
|
||||
customAccent,
|
||||
setCustomAccent,
|
||||
uiFontFamilyId,
|
||||
setUiFontFamilyId,
|
||||
syncConfig,
|
||||
updateSyncConfig,
|
||||
uiLanguage,
|
||||
@@ -532,5 +686,21 @@ export const useSettingsState = () => {
|
||||
setIsHotkeyRecording,
|
||||
customCSS,
|
||||
setCustomCSS,
|
||||
sftpDoubleClickBehavior,
|
||||
setSftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
setSftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
setSftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
setSftpUseCompressedUpload,
|
||||
availableFonts,
|
||||
// Session Logs
|
||||
sessionLogsEnabled,
|
||||
setSessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
setSessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
setSessionLogsFormat,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,75 @@ 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 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 +265,9 @@ export const useSftpBackend = () => {
|
||||
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
cancelSftpUpload,
|
||||
onTransferProgress,
|
||||
selectApplication,
|
||||
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
@@ -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,
|
||||
|
||||
@@ -116,6 +116,18 @@ export const useTerminalBackend = () => {
|
||||
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,
|
||||
@@ -131,6 +143,8 @@ export const useTerminalBackend = () => {
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
getServerStats,
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
closeSession,
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Identity,
|
||||
KeyCategory,
|
||||
KnownHost,
|
||||
ManagedSource,
|
||||
ShellHistoryEntry,
|
||||
Snippet,
|
||||
SSHKey,
|
||||
@@ -22,6 +23,7 @@ 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,
|
||||
@@ -95,6 +97,7 @@ export const useVaultState = () => {
|
||||
const [knownHosts, setKnownHosts] = useState<KnownHost[]>([]);
|
||||
const [shellHistory, setShellHistory] = useState<ShellHistoryEntry[]>([]);
|
||||
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
|
||||
const [managedSources, setManagedSources] = useState<ManagedSource[]>([]);
|
||||
|
||||
const updateHosts = useCallback((data: Host[]) => {
|
||||
const cleaned = data.map(sanitizeHost);
|
||||
@@ -132,6 +135,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 +148,7 @@ export const useVaultState = () => {
|
||||
updateSnippetPackages([]);
|
||||
updateCustomGroups([]);
|
||||
updateKnownHosts([]);
|
||||
updateManagedSources([]);
|
||||
localStorageAdapter.remove(STORAGE_KEY_LEGACY_KEYS);
|
||||
}, [
|
||||
updateHosts,
|
||||
@@ -149,6 +158,7 @@ export const useVaultState = () => {
|
||||
updateSnippetPackages,
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
]);
|
||||
|
||||
const addShellHistoryEntry = useCallback(
|
||||
@@ -339,6 +349,12 @@ export const useVaultState = () => {
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
|
||||
|
||||
// Load managed sources
|
||||
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
);
|
||||
if (savedManagedSources) setManagedSources(savedManagedSources);
|
||||
}, [updateHosts, updateSnippets]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -407,6 +423,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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -474,6 +496,7 @@ export const useVaultState = () => {
|
||||
knownHosts,
|
||||
shellHistory,
|
||||
connectionLogs,
|
||||
managedSources,
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
updateIdentities,
|
||||
@@ -481,6 +504,7 @@ export const useVaultState = () => {
|
||||
updateSnippetPackages,
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
addShellHistoryEntry,
|
||||
clearShellHistory,
|
||||
addConnectionLog,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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,6 +71,7 @@ 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)
|
||||
@@ -71,6 +86,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
availableKeys,
|
||||
identities,
|
||||
groups,
|
||||
managedSources = [],
|
||||
allTags = [],
|
||||
allHosts = [],
|
||||
defaultGroup,
|
||||
@@ -80,6 +96,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onCreateTag,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
const [form, setForm] = useState<Host>(
|
||||
() =>
|
||||
initialData ||
|
||||
@@ -92,7 +109,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
protocol: "ssh",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
agentForwarding: false,
|
||||
authMethod: "password",
|
||||
charset: "UTF-8",
|
||||
theme: "Flexoki Dark",
|
||||
@@ -112,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 || "");
|
||||
|
||||
@@ -137,6 +172,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
}
|
||||
setForm(updatedData);
|
||||
setGroupInputValue(initialData.group || "");
|
||||
// Reset password visibility when host changes for privacy
|
||||
setShowPassword(false);
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
@@ -217,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);
|
||||
};
|
||||
@@ -472,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} />
|
||||
@@ -480,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"
|
||||
/>
|
||||
|
||||
@@ -522,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"
|
||||
@@ -552,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>
|
||||
@@ -758,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 */}
|
||||
@@ -923,9 +1049,61 @@ 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.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
|
||||
@@ -1012,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}
|
||||
@@ -1020,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">
|
||||
@@ -1170,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")}
|
||||
@@ -1316,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
|
||||
|
||||
501
components/HostTreeView.tsx
Normal file
501
components/HostTreeView.tsx
Normal file
@@ -0,0 +1,501 @@
|
||||
import { ChevronRight, FileSymlink, Folder, FolderOpen, Monitor, Server, 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
node,
|
||||
depth,
|
||||
sortMode,
|
||||
expandedPaths,
|
||||
onToggle,
|
||||
onConnect,
|
||||
onEditHost,
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
onNewHost,
|
||||
onNewGroup,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
}) => {
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
))}
|
||||
</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;
|
||||
}
|
||||
|
||||
const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
host,
|
||||
depth,
|
||||
onConnect,
|
||||
onEditHost,
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
moveHostToGroup: _moveHostToGroup,
|
||||
}) => {
|
||||
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);
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className="flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg"
|
||||
style={{ paddingLeft }}
|
||||
draggable
|
||||
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
|
||||
onClick={() => onConnect(safeHost)}
|
||||
>
|
||||
<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,
|
||||
}) => {
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 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,
|
||||
@@ -1282,6 +1285,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) {
|
||||
@@ -843,6 +847,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";
|
||||
@@ -21,6 +21,42 @@ 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;
|
||||
@@ -52,12 +88,11 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
}) => {
|
||||
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);
|
||||
@@ -93,15 +128,16 @@ 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;
|
||||
|
||||
// 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 +163,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 +212,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"
|
||||
@@ -260,7 +273,15 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
</div>
|
||||
<div>
|
||||
{results.length > 0 ? (
|
||||
results.map(renderHostItem)
|
||||
results.map((host) => (
|
||||
<HostItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
isSelected={getItemIndex("host", host.id) === selectedIndex}
|
||||
onSelect={onSelect}
|
||||
onMouseEnter={() => setSelectedIndex(getItemIndex("host", host.id))}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-6 text-sm text-muted-foreground text-center">
|
||||
No recent connections
|
||||
@@ -289,7 +310,15 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
Hosts
|
||||
</span>
|
||||
</div>
|
||||
{results.map(renderHostItem)}
|
||||
{results.map((host) => (
|
||||
<HostItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
isSelected={getItemIndex("host", host.id) === selectedIndex}
|
||||
onSelect={onSelect}
|
||||
onMouseEnter={() => setSelectedIndex(getItemIndex("host", host.id))}
|
||||
/>
|
||||
))}
|
||||
</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,
|
||||
@@ -407,6 +410,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
availableKeys={availableKeys}
|
||||
identities={identities}
|
||||
groups={customGroups}
|
||||
managedSources={managedSources}
|
||||
allHosts={hosts}
|
||||
onSave={(host) => {
|
||||
onSaveHost(host);
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* Serial Port Connect Modal
|
||||
* Allows users to configure and connect to a serial port
|
||||
*/
|
||||
import { ChevronDown, ChevronUp, Cpu, RefreshCw, Usb } from 'lucide-react';
|
||||
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 { SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
|
||||
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';
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
|
||||
@@ -35,6 +36,7 @@ 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];
|
||||
@@ -47,6 +49,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onConnect,
|
||||
onSaveHost,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [ports, setPorts] = useState<SerialPort[]>([]);
|
||||
@@ -63,6 +66,10 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
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 () => {
|
||||
@@ -87,6 +94,14 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
}
|
||||
}, [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;
|
||||
|
||||
@@ -101,6 +116,26 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
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();
|
||||
};
|
||||
@@ -114,9 +149,17 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
}));
|
||||
}, [ports]);
|
||||
|
||||
// Validate: port path must start with /dev/
|
||||
const isPortValid = selectedPort.trim().startsWith('/dev/');
|
||||
const isBaudRateValid = BAUD_RATES.includes(baudRate);
|
||||
// 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 (
|
||||
@@ -171,18 +214,28 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
{/* Baud Rate */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
|
||||
<select
|
||||
id="baud-rate"
|
||||
value={baudRate}
|
||||
onChange={(e) => setBaudRate(parseInt(e.target.value, 10))}
|
||||
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"
|
||||
>
|
||||
{BAUD_RATES.map((rate) => (
|
||||
<option key={rate} value={rate}>
|
||||
{rate}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<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 */}
|
||||
@@ -236,6 +289,11 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isStopBits15 && (
|
||||
<p className="text-xs text-yellow-500">
|
||||
{t('serial.field.stopBits15Warning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -313,6 +371,40 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
</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>
|
||||
@@ -320,8 +412,12 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleConnect} disabled={!isValid}>
|
||||
<Cpu size={14} className="mr-2" />
|
||||
{t('common.connect')}
|
||||
{saveConfig ? (
|
||||
<Save size={14} className="mr-2" />
|
||||
) : (
|
||||
<Cpu size={14} className="mr-2" />
|
||||
)}
|
||||
{saveConfig ? t('serial.connectAndSave') : t('common.connect')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
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,26 @@ 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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import { useStoredViewMode } from '../application/state/useStoredViewMode';
|
||||
import { STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE } from '../infrastructure/config/storageKeys';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Host, ShellHistoryEntry, Snippet, SSHKey } from '../types';
|
||||
import { ManagedSource } from '../domain/models';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import SelectHostPanel from './SelectHostPanel';
|
||||
import { AsidePanel, AsidePanelContent } from './ui/aside-panel';
|
||||
@@ -30,6 +31,7 @@ interface SnippetsManagerProps {
|
||||
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
|
||||
// Props for inline host creation
|
||||
availableKeys?: SSHKey[];
|
||||
managedSources?: ManagedSource[];
|
||||
onSaveHost?: (host: Host) => void;
|
||||
onCreateGroup?: (groupPath: string) => void;
|
||||
}
|
||||
@@ -49,6 +51,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
onPackagesChange,
|
||||
onRunSnippet,
|
||||
availableKeys = [],
|
||||
managedSources = [],
|
||||
onSaveHost,
|
||||
onCreateGroup,
|
||||
}) => {
|
||||
@@ -67,6 +70,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(
|
||||
@@ -144,23 +153,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 +237,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 +314,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 +441,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 +530,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 +575,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) => {
|
||||
@@ -565,12 +792,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 +851,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>
|
||||
@@ -729,6 +957,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 +972,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>
|
||||
) : (
|
||||
|
||||
@@ -3,8 +3,9 @@ import { FitAddon } from "@xterm/addon-fit";
|
||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Maximize2, Radio } from "lucide-react";
|
||||
import { Cpu, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
|
||||
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -25,21 +26,73 @@ import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
|
||||
import SFTPModal from "./SFTPModal";
|
||||
import { Button } from "./ui/button";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
|
||||
import { toast } from "./ui/toast";
|
||||
import { TERMINAL_FONTS } from "../infrastructure/config/fonts";
|
||||
import { useAvailableFonts } from "../application/state/fontStore";
|
||||
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
|
||||
|
||||
import { TerminalConnectionDialog } from "./terminal/TerminalConnectionDialog";
|
||||
import { TerminalToolbar } from "./terminal/TerminalToolbar";
|
||||
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
|
||||
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
|
||||
import { createHighlightProcessor } from "./terminal/keywordHighlight";
|
||||
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
|
||||
import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
|
||||
import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextActions";
|
||||
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
|
||||
import { useServerStats } from "./terminal/hooks/useServerStats";
|
||||
import { extractDropEntries, getPathForFile, DropEntry } from "../lib/sftpFileUtils";
|
||||
|
||||
/**
|
||||
* Extract unique root paths from drop entries for local terminal path insertion.
|
||||
* For nested files, extracts the root folder path; for single files, uses the full path.
|
||||
* Paths with spaces are quoted.
|
||||
*/
|
||||
function extractRootPathsFromDropEntries(dropEntries: DropEntry[]): string[] {
|
||||
const paths: string[] = [];
|
||||
const seenPaths = new Set<string>();
|
||||
|
||||
for (const entry of dropEntries) {
|
||||
if (!entry.file) continue;
|
||||
|
||||
const fullPath = getPathForFile(entry.file);
|
||||
if (!fullPath) continue;
|
||||
|
||||
const pathParts = entry.relativePath.split('/');
|
||||
|
||||
if (pathParts.length > 1) {
|
||||
// Nested file in a folder - extract the root folder path
|
||||
const rootFolderName = pathParts[0];
|
||||
const separator = fullPath.includes('\\') ? '\\' : '/';
|
||||
|
||||
// Find the position of the root folder name in the full path
|
||||
const rootFolderIndex = fullPath.lastIndexOf(separator + rootFolderName + separator);
|
||||
const altRootFolderIndex = fullPath.lastIndexOf(separator + rootFolderName);
|
||||
const folderStartIndex = rootFolderIndex !== -1
|
||||
? rootFolderIndex + 1
|
||||
: (altRootFolderIndex !== -1 ? altRootFolderIndex + 1 : -1);
|
||||
|
||||
if (folderStartIndex !== -1) {
|
||||
const folderEndIndex = folderStartIndex + rootFolderName.length;
|
||||
const folderPath = fullPath.substring(0, folderEndIndex);
|
||||
|
||||
if (!seenPaths.has(folderPath)) {
|
||||
paths.push(folderPath.includes(' ') ? `"${folderPath}"` : folderPath);
|
||||
seenPaths.add(folderPath);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single file (not in a folder)
|
||||
if (!seenPaths.has(fullPath)) {
|
||||
paths.push(fullPath.includes(' ') ? `"${fullPath}"` : fullPath);
|
||||
seenPaths.add(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
interface TerminalProps {
|
||||
host: Host;
|
||||
@@ -87,6 +140,19 @@ interface TerminalProps {
|
||||
onBroadcastInput?: (data: string, sourceSessionId: string) => void;
|
||||
}
|
||||
|
||||
// Helper function to format network speed (bytes/sec) to human-readable format
|
||||
function formatNetSpeed(bytesPerSec: number): string {
|
||||
if (bytesPerSec < 1024) {
|
||||
return `${bytesPerSec}B/s`;
|
||||
} else if (bytesPerSec < 1024 * 1024) {
|
||||
return `${(bytesPerSec / 1024).toFixed(1)}K/s`;
|
||||
} else if (bytesPerSec < 1024 * 1024 * 1024) {
|
||||
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)}M/s`;
|
||||
} else {
|
||||
return `${(bytesPerSec / (1024 * 1024 * 1024)).toFixed(1)}G/s`;
|
||||
}
|
||||
}
|
||||
|
||||
const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
host,
|
||||
keys,
|
||||
@@ -127,8 +193,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onToggleBroadcast,
|
||||
onBroadcastInput,
|
||||
}) => {
|
||||
const CONNECTION_TIMEOUT = 12000;
|
||||
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
|
||||
const CONNECTION_TIMEOUT = 120000;
|
||||
const { t } = useI18n();
|
||||
const availableFonts = useAvailableFonts();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
@@ -146,12 +214,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const terminalSettingsRef = useRef(terminalSettings);
|
||||
terminalSettingsRef.current = terminalSettings;
|
||||
|
||||
const highlightProcessorRef = useRef<(text: string) => string>((t) => t);
|
||||
useEffect(() => {
|
||||
highlightProcessorRef.current = createHighlightProcessor(
|
||||
terminalSettings?.keywordHighlightRules ?? [],
|
||||
terminalSettings?.keywordHighlightEnabled ?? false,
|
||||
);
|
||||
if (xtermRuntimeRef.current) {
|
||||
xtermRuntimeRef.current.keywordHighlighter.setRules(
|
||||
terminalSettings?.keywordHighlightRules ?? [],
|
||||
terminalSettings?.keywordHighlightEnabled ?? false
|
||||
);
|
||||
}
|
||||
}, [terminalSettings?.keywordHighlightEnabled, terminalSettings?.keywordHighlightRules]);
|
||||
|
||||
const hotkeySchemeRef = useRef(hotkeyScheme);
|
||||
@@ -169,6 +238,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const { resizeSession } = terminalBackend;
|
||||
|
||||
|
||||
|
||||
const [isScriptsOpen, setIsScriptsOpen] = useState(false);
|
||||
const [status, setStatus] = useState<TerminalSession["status"]>("connecting");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -178,6 +249,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [timeLeft, setTimeLeft] = useState(CONNECTION_TIMEOUT / 1000);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [showSFTP, setShowSFTP] = useState(false);
|
||||
const [sftpInitialPath, setSftpInitialPath] = useState<string | undefined>(undefined);
|
||||
const [progressValue, setProgressValue] = useState(15);
|
||||
const [hasSelection, setHasSelection] = useState(false);
|
||||
|
||||
@@ -190,6 +262,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
currentHostLabel: string;
|
||||
} | null>(null);
|
||||
|
||||
// Drag and drop state
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const [pendingUploadEntries, setPendingUploadEntries] = useState<DropEntry[]>([]);
|
||||
|
||||
const terminalSearch = useTerminalSearch({ searchAddonRef, termRef });
|
||||
const {
|
||||
isSearchOpen,
|
||||
@@ -202,6 +279,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
handleCloseSearch,
|
||||
} = terminalSearch;
|
||||
|
||||
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
|
||||
const isLocalConnection = host.protocol === "local";
|
||||
const isSerialConnection = host.protocol === "serial";
|
||||
|
||||
// Server stats (CPU, Memory, Disk) for Linux servers
|
||||
const { stats: serverStats } = useServerStats({
|
||||
sessionId,
|
||||
enabled: terminalSettings?.showServerStats ?? true,
|
||||
refreshInterval: terminalSettings?.serverStatsRefreshInterval ?? 5,
|
||||
isLinux: host.os === 'linux',
|
||||
isConnected: status === 'connected',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) {
|
||||
lastToastedErrorRef.current = null;
|
||||
@@ -292,7 +382,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
disposeExitRef,
|
||||
fitAddonRef,
|
||||
serializeAddonRef,
|
||||
highlightProcessorRef,
|
||||
pendingAuthRef,
|
||||
updateStatus,
|
||||
setStatus,
|
||||
@@ -421,8 +510,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
useEffect(() => {
|
||||
if (status !== "connecting" || auth.needsAuth) return;
|
||||
|
||||
// Local terminal and serial connections don't need timeout/progress UI
|
||||
if (isLocalConnection || isSerialConnection) return;
|
||||
|
||||
// Only show SSH-specific scripted logs for SSH connections
|
||||
const isSSH = host.protocol !== "serial" && host.protocol !== "local" && host.protocol !== "telnet" && host.hostname !== "localhost";
|
||||
const isSSH = host.protocol !== "telnet";
|
||||
|
||||
let stepTimer: ReturnType<typeof setInterval> | undefined;
|
||||
if (isSSH) {
|
||||
@@ -519,7 +611,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
| 700
|
||||
| 800
|
||||
| 900;
|
||||
termRef.current.options.fontWeightBold = terminalSettings.fontWeightBold as
|
||||
const resolvedFontWeightBold = (() => {
|
||||
const fontFamily = termRef.current?.options.fontFamily || "";
|
||||
if (typeof document === "undefined" || !document.fonts?.check) {
|
||||
return terminalSettings.fontWeightBold;
|
||||
}
|
||||
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
|
||||
return document.fonts.check(weightSpec)
|
||||
? terminalSettings.fontWeightBold
|
||||
: terminalSettings.fontWeight;
|
||||
})();
|
||||
|
||||
termRef.current.options.fontWeightBold = resolvedFontWeightBold as
|
||||
| 100
|
||||
| 200
|
||||
| 300
|
||||
@@ -549,7 +652,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current.options.fontSize = effectiveFontSize;
|
||||
|
||||
const hostFontId = host.fontFamily || fontFamilyId || "menlo";
|
||||
const fontObj = TERMINAL_FONTS.find((f) => f.id === hostFontId) || TERMINAL_FONTS[0];
|
||||
const fontObj = availableFonts.find((f) => f.id === hostFontId) || availableFonts[0];
|
||||
termRef.current.options.fontFamily = fontObj.family;
|
||||
|
||||
termRef.current.options.theme = {
|
||||
@@ -559,7 +662,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
setTimeout(() => safeFit(), 50);
|
||||
}
|
||||
}, [host.fontSize, host.fontFamily, host.theme, fontFamilyId, fontSize, effectiveTheme]);
|
||||
}, [host.fontSize, host.fontFamily, host.theme, fontFamilyId, fontSize, effectiveTheme, availableFonts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && fitAddonRef.current) {
|
||||
@@ -595,6 +698,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
logger.warn("Fit after fonts ready failed", err);
|
||||
}
|
||||
|
||||
if (terminalSettings && termRef.current) {
|
||||
const fontFamily = termRef.current.options?.fontFamily || "";
|
||||
const effectiveFontSize = host.fontSize || fontSize;
|
||||
if (typeof document !== "undefined" && document.fonts?.check) {
|
||||
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
|
||||
const resolvedBold = document.fonts.check(weightSpec)
|
||||
? terminalSettings.fontWeightBold
|
||||
: terminalSettings.fontWeight;
|
||||
termRef.current.options.fontWeightBold = resolvedBold as
|
||||
| 100
|
||||
| 200
|
||||
| 300
|
||||
| 400
|
||||
| 500
|
||||
| 600
|
||||
| 700
|
||||
| 800
|
||||
| 900;
|
||||
}
|
||||
}
|
||||
|
||||
const id = sessionRef.current;
|
||||
if (id && term) {
|
||||
try {
|
||||
@@ -612,7 +736,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [host.id, sessionId, resizeSession]);
|
||||
}, [host.id, host.fontFamily, host.fontSize, fontFamilyId, fontSize, resizeSession, sessionId, terminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !fitAddonRef.current) return;
|
||||
@@ -729,6 +853,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current?.writeln("\r\n[No active SSH session]");
|
||||
};
|
||||
|
||||
const handleOpenSFTP = async () => {
|
||||
// If SFTP is already open, toggle it off
|
||||
if (showSFTP) {
|
||||
setShowSFTP(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to get the current working directory from the terminal session
|
||||
let initialPath: string | undefined = undefined;
|
||||
if (sessionRef.current) {
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
if (result.success && result.cwd) {
|
||||
initialPath = result.cwd;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail and open SFTP without initial path
|
||||
}
|
||||
}
|
||||
|
||||
// Use flushSync to ensure initialPath state is committed before opening SFTP modal
|
||||
// This prevents React's batching from causing the modal to open with stale/undefined initialPath
|
||||
flushSync(() => {
|
||||
setSftpInitialPath(initialPath);
|
||||
});
|
||||
setShowSFTP(true);
|
||||
};
|
||||
|
||||
const handleCancelConnect = () => {
|
||||
setIsCancelling(true);
|
||||
auth.setNeedsAuth(false);
|
||||
@@ -794,6 +946,95 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current++;
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
setIsDraggingOver(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current--;
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDraggingOver(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current = 0;
|
||||
setIsDraggingOver(false);
|
||||
|
||||
if (!e.dataTransfer.types.includes('Files')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only handle drops on connected terminals
|
||||
if (status !== 'connected') {
|
||||
toast.error(t("terminal.dragDrop.notConnected"), t("terminal.dragDrop.errorTitle"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const dropEntries = await extractDropEntries(e.dataTransfer);
|
||||
|
||||
if (dropEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLocalConnection) {
|
||||
// Local terminal: Insert absolute paths
|
||||
const paths = extractRootPathsFromDropEntries(dropEntries);
|
||||
|
||||
if (paths.length > 0 && termRef.current && sessionRef.current) {
|
||||
const pathsText = paths.join(' ');
|
||||
// Write the paths to the terminal
|
||||
terminalBackend.writeToSession(sessionRef.current, pathsText);
|
||||
termRef.current.focus();
|
||||
}
|
||||
} else {
|
||||
// Remote terminal: Trigger SFTP upload
|
||||
// Get current working directory for SFTP initial path
|
||||
let initialPath: string | undefined = undefined;
|
||||
if (sessionRef.current) {
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
if (result.success && result.cwd) {
|
||||
initialPath = result.cwd;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail and open SFTP without initial path
|
||||
}
|
||||
}
|
||||
|
||||
setPendingUploadEntries(dropEntries);
|
||||
// Use flushSync to ensure sftpInitialPath is updated synchronously
|
||||
// before setShowSFTP(true) triggers the modal open
|
||||
flushSync(() => {
|
||||
setSftpInitialPath(initialPath);
|
||||
});
|
||||
setShowSFTP(true);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to handle file drop", error);
|
||||
toast.error(t("terminal.dragDrop.errorMessage"), t("terminal.dragDrop.errorTitle"));
|
||||
}
|
||||
};
|
||||
|
||||
const renderControls = (opts?: { showClose?: boolean }) => (
|
||||
<TerminalToolbar
|
||||
status={status}
|
||||
@@ -807,7 +1048,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onUpdateTerminalFontSize={onUpdateTerminalFontSize}
|
||||
isScriptsOpen={isScriptsOpen}
|
||||
setIsScriptsOpen={setIsScriptsOpen}
|
||||
onOpenSFTP={() => setShowSFTP((v) => !v)}
|
||||
onOpenSFTP={handleOpenSFTP}
|
||||
onSnippetClick={handleSnippetClick}
|
||||
onUpdateHost={onUpdateHost}
|
||||
showClose={opts?.showClose}
|
||||
@@ -830,6 +1071,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<TerminalContextMenu
|
||||
hasSelection={hasSelection}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
rightClickBehavior={terminalSettings?.rightClickBehavior}
|
||||
onCopy={terminalContextActions.onCopy}
|
||||
onPaste={terminalContextActions.onPaste}
|
||||
@@ -840,7 +1082,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onSplitVertical={onSplitVertical}
|
||||
onClose={inWorkspace ? () => onCloseSession?.(sessionId) : undefined}
|
||||
>
|
||||
<div className="relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]">
|
||||
<div
|
||||
className="relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]"
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Drag and drop overlay */}
|
||||
{isDraggingOver && (
|
||||
<div className="absolute inset-0 z-50 bg-blue-600/20 backdrop-blur-sm border-4 border-dashed border-blue-400 pointer-events-none flex items-center justify-center">
|
||||
<div className="bg-background/90 backdrop-blur-md rounded-lg shadow-lg p-6 border border-border">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold mb-2">
|
||||
{isLocalConnection
|
||||
? t("terminal.dragDrop.localTitle")
|
||||
: t("terminal.dragDrop.remoteTitle")
|
||||
}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isLocalConnection
|
||||
? t("terminal.dragDrop.localMessage")
|
||||
: t("terminal.dragDrop.remoteMessage")
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0 border-b-[0.5px]"
|
||||
@@ -864,6 +1133,266 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/* Server Stats Display - Linux only */}
|
||||
{host.os === 'linux' && terminalSettings?.showServerStats && status === 'connected' && serverStats.lastUpdated && (
|
||||
<div className="flex items-center gap-2.5 ml-2 text-[10px] opacity-80 flex-nowrap overflow-hidden min-w-0">
|
||||
{/* CPU with HoverCard for per-core details */}
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
|
||||
title={t("terminal.serverStats.cpu")}
|
||||
>
|
||||
<Cpu size={10} className="flex-shrink-0" />
|
||||
<span>
|
||||
{serverStats.cpu !== null ? `${serverStats.cpu}%` : '--'}
|
||||
{serverStats.cpuCores !== null && ` (${serverStats.cpuCores}C)`}
|
||||
</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
className="w-auto p-3"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="text-xs space-y-2">
|
||||
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.cpuCores")}</div>
|
||||
{serverStats.cpuPerCore.length > 0 ? (
|
||||
<div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${Math.min(4, serverStats.cpuPerCore.length)}, 1fr)` }}>
|
||||
{serverStats.cpuPerCore.map((usage, index) => (
|
||||
<div key={index} className="flex flex-col items-center gap-1 min-w-[48px]">
|
||||
<div className="text-[10px] text-muted-foreground">Core {index}</div>
|
||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
usage >= 90 ? "bg-red-500" : usage >= 70 ? "bg-amber-500" : "bg-emerald-500"
|
||||
)}
|
||||
style={{ width: `${usage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn(
|
||||
"text-[11px] font-medium",
|
||||
usage >= 90 ? "text-red-400" : usage >= 70 ? "text-amber-400" : "text-emerald-400"
|
||||
)}>
|
||||
{usage}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
|
||||
)}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
{/* Memory with HoverCard for htop-style bar and top processes */}
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
|
||||
title={t("terminal.serverStats.memory")}
|
||||
>
|
||||
<MemoryStick size={10} className="flex-shrink-0" />
|
||||
<span>
|
||||
{serverStats.memUsed !== null && serverStats.memTotal !== null
|
||||
? `${(serverStats.memUsed / 1024).toFixed(1)}/${(serverStats.memTotal / 1024).toFixed(1)}G`
|
||||
: '--'}
|
||||
</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
className="w-auto p-3"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="text-xs space-y-3 min-w-[280px]">
|
||||
<div className="font-medium text-sm">{t("terminal.serverStats.memoryDetails")}</div>
|
||||
{/* htop-style memory bar */}
|
||||
{serverStats.memTotal !== null && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="w-full h-3 bg-muted rounded overflow-hidden flex">
|
||||
{/* Used (green) */}
|
||||
{serverStats.memUsed !== null && serverStats.memUsed > 0 && (
|
||||
<div
|
||||
className="h-full bg-emerald-500"
|
||||
style={{ width: `${(serverStats.memUsed / serverStats.memTotal) * 100}%` }}
|
||||
title={`${t("terminal.serverStats.memUsed")}: ${(serverStats.memUsed / 1024).toFixed(1)}G`}
|
||||
/>
|
||||
)}
|
||||
{/* Buffers (blue) */}
|
||||
{serverStats.memBuffers !== null && serverStats.memBuffers > 0 && (
|
||||
<div
|
||||
className="h-full bg-blue-500"
|
||||
style={{ width: `${(serverStats.memBuffers / serverStats.memTotal) * 100}%` }}
|
||||
title={`${t("terminal.serverStats.memBuffers")}: ${(serverStats.memBuffers / 1024).toFixed(1)}G`}
|
||||
/>
|
||||
)}
|
||||
{/* Cached (amber/orange) */}
|
||||
{serverStats.memCached !== null && serverStats.memCached > 0 && (
|
||||
<div
|
||||
className="h-full bg-amber-500"
|
||||
style={{ width: `${(serverStats.memCached / serverStats.memTotal) * 100}%` }}
|
||||
title={`${t("terminal.serverStats.memCached")}: ${(serverStats.memCached / 1024).toFixed(1)}G`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px]">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-sm bg-emerald-500" />
|
||||
<span>{t("terminal.serverStats.memUsed")}: {serverStats.memUsed !== null ? `${(serverStats.memUsed / 1024).toFixed(1)}G` : '--'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-sm bg-blue-500" />
|
||||
<span>{t("terminal.serverStats.memBuffers")}: {serverStats.memBuffers !== null ? `${(serverStats.memBuffers / 1024).toFixed(1)}G` : '--'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-sm bg-amber-500" />
|
||||
<span>{t("terminal.serverStats.memCached")}: {serverStats.memCached !== null ? `${(serverStats.memCached / 1024).toFixed(1)}G` : '--'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-sm bg-muted border border-border" />
|
||||
<span>{t("terminal.serverStats.memFree")}: {serverStats.memFree !== null ? `${(serverStats.memFree / 1024).toFixed(1)}G` : '--'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Top 10 processes */}
|
||||
{serverStats.topProcesses.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="font-medium text-[11px] text-muted-foreground">{t("terminal.serverStats.topProcesses")}</div>
|
||||
<div className="space-y-0.5 max-h-[150px] overflow-y-auto">
|
||||
{serverStats.topProcesses.map((proc, index) => (
|
||||
<div key={index} className="flex items-center gap-2 text-[10px]">
|
||||
<span className="w-[32px] text-right text-muted-foreground">{proc.memPercent.toFixed(1)}%</span>
|
||||
<div className="flex-1 h-1 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500 rounded-full"
|
||||
style={{ width: `${Math.min(100, proc.memPercent * 2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="flex-shrink-0 font-mono truncate max-w-[140px]" title={proc.command}>
|
||||
{proc.command.split('/').pop()?.split(' ')[0] || proc.command}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
{/* Disk - with HoverCard for disk details */}
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
|
||||
title={t("terminal.serverStats.disk")}
|
||||
>
|
||||
<HardDrive size={10} className="flex-shrink-0" />
|
||||
<span className={cn(
|
||||
serverStats.diskPercent !== null && serverStats.diskPercent >= 90 && "text-red-400",
|
||||
serverStats.diskPercent !== null && serverStats.diskPercent >= 80 && serverStats.diskPercent < 90 && "text-amber-400"
|
||||
)}>
|
||||
{serverStats.diskUsed !== null && serverStats.diskTotal !== null && serverStats.diskPercent !== null
|
||||
? `${serverStats.diskUsed}/${serverStats.diskTotal}G (${serverStats.diskPercent}%)`
|
||||
: serverStats.diskPercent !== null
|
||||
? `${serverStats.diskPercent}%`
|
||||
: '--'}
|
||||
</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
className="w-auto p-3"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="text-xs space-y-2">
|
||||
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.diskDetails")}</div>
|
||||
{serverStats.disks.length > 0 ? (
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||
{serverStats.disks.map((disk, index) => (
|
||||
<div key={index} className="flex flex-col gap-1 min-w-[180px]">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[120px]" title={disk.mountPoint}>
|
||||
{disk.mountPoint}
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-[11px] font-medium whitespace-nowrap",
|
||||
disk.percent >= 90 ? "text-red-400" : disk.percent >= 80 ? "text-amber-400" : "text-emerald-400"
|
||||
)}>
|
||||
{disk.used}/{disk.total}G ({disk.percent}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
disk.percent >= 90 ? "bg-red-500" : disk.percent >= 80 ? "bg-amber-500" : "bg-emerald-500"
|
||||
)}
|
||||
style={{ width: `${disk.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
|
||||
)}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
{/* Network - with HoverCard for per-interface details */}
|
||||
{serverStats.netInterfaces.length > 0 && (
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-1 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
|
||||
title={t("terminal.serverStats.network")}
|
||||
>
|
||||
<ArrowDownToLine size={9} className="flex-shrink-0 text-emerald-400" />
|
||||
<span>{formatNetSpeed(serverStats.netRxSpeed)}</span>
|
||||
<ArrowUpFromLine size={9} className="flex-shrink-0 text-sky-400" />
|
||||
<span>{formatNetSpeed(serverStats.netTxSpeed)}</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
className="w-auto p-3"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="text-xs space-y-2">
|
||||
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.networkDetails")}</div>
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||
{serverStats.netInterfaces.map((iface, index) => (
|
||||
<div key={index} className="flex items-center justify-between gap-4 min-w-[200px]">
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{iface.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-0.5 text-emerald-400">
|
||||
<ArrowDownToLine size={9} />
|
||||
{formatNetSpeed(iface.rxSpeed)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 text-sky-400">
|
||||
<ArrowUpFromLine size={9} />
|
||||
{formatNetSpeed(iface.txSpeed)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
{inWorkspace && onToggleBroadcast && (
|
||||
@@ -927,7 +1456,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
ref={containerRef}
|
||||
className="absolute inset-x-0 bottom-0"
|
||||
style={{
|
||||
top: isSearchOpen ? "64px" : "40px",
|
||||
top: isSearchOpen ? "64px" : "30px",
|
||||
paddingLeft: 6,
|
||||
backgroundColor: effectiveTheme.colors.background,
|
||||
}}
|
||||
@@ -945,7 +1474,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status !== "connected" && !needsHostKeyVerification && (
|
||||
{/* Connection dialog: skip for local/serial during connecting phase, but show on error */}
|
||||
{status !== "connected" && !needsHostKeyVerification && !(
|
||||
(isLocalConnection || isSerialConnection) && status === "connecting"
|
||||
) && (
|
||||
<TerminalConnectionDialog
|
||||
host={host}
|
||||
status={status}
|
||||
@@ -1046,10 +1578,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
keySource: resolvedAuth.key?.source,
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
sftpSudo: host.sftpSudo,
|
||||
};
|
||||
})()}
|
||||
open={showSFTP && status === "connected"}
|
||||
onClose={() => setShowSFTP(false)}
|
||||
onClose={() => {
|
||||
setShowSFTP(false);
|
||||
setPendingUploadEntries([]);
|
||||
}}
|
||||
initialPath={sftpInitialPath}
|
||||
initialEntriesToUpload={pendingUploadEntries}
|
||||
/>
|
||||
</div>
|
||||
</TerminalContextMenu>
|
||||
|
||||
390
components/TextEditorModal.tsx
Normal file
390
components/TextEditorModal.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* TextEditorModal - Modal for editing text files in SFTP with syntax highlighting
|
||||
*/
|
||||
import {
|
||||
CloudUpload,
|
||||
Loader2,
|
||||
Search,
|
||||
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 { 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>;
|
||||
}
|
||||
|
||||
// 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,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
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());
|
||||
|
||||
// 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 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);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 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>
|
||||
|
||||
{/* 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={{
|
||||
minimap: { enabled: true },
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
roundedSelection: false,
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
wordWrap: '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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -84,7 +84,6 @@ export const ImportKeyPanel: React.FC<ImportKeyPanelProps> = ({
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pem,.key,.pub,.ppk,*"
|
||||
className="hidden"
|
||||
onChange={handleFileImport}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
304
components/settings/tabs/SettingsSystemTab.tsx
Normal file
304
components/settings/tabs/SettingsSystemTab.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* Settings System Tab - System information, temp file management, and session logs
|
||||
*/
|
||||
import { FileText, FolderOpen, HardDrive, RefreshCw, Trash2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { SessionLogFormat } from "../../../domain/models";
|
||||
import { TabsContent } from "../../ui/tabs";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Toggle, Select, SettingRow } from "../settings-ui";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
sessionLogsEnabled,
|
||||
setSessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
setSessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
setSessionLogsFormat,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
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 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 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]);
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsSystemTab;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { AlertCircle, 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,6 +90,7 @@ export default function SettingsTerminalTab(props: {
|
||||
setTerminalFontSize,
|
||||
terminalSettings,
|
||||
updateTerminalSetting,
|
||||
availableFonts,
|
||||
} = props;
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -97,6 +98,12 @@ export default function SettingsTerminalTab(props: {
|
||||
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(() => {
|
||||
@@ -182,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">
|
||||
@@ -199,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>
|
||||
|
||||
@@ -578,6 +587,84 @@ export default function SettingsTerminalTab(props: {
|
||||
</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>
|
||||
);
|
||||
339
components/sftp-modal/SftpModalHeader.tsx
Normal file
339
components/sftp-modal/SftpModalHeader.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
import React 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,
|
||||
}) => (
|
||||
<>
|
||||
<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={300}>
|
||||
<div className="px-4 py-2 border-b border-border/60 flex items-center gap-2 flex-shrink-0 bg-muted/30">
|
||||
<Tooltip>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
210
components/sftp-modal/SftpModalUploadTasks.tsx
Normal file
210
components/sftp-modal/SftpModalUploadTasks.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import React from "react";
|
||||
import { Loader2, Upload, X, XCircle } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface UploadTask {
|
||||
id: string;
|
||||
fileName: string;
|
||||
totalBytes: number;
|
||||
transferredBytes: number;
|
||||
progress: number;
|
||||
speed: number;
|
||||
status: "pending" | "uploading" | "completed" | "failed" | "cancelled";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SftpModalUploadTasksProps {
|
||||
tasks: UploadTask[];
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
onCancel?: () => void;
|
||||
onDismiss?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ tasks, t, onCancel, onDismiss }) => {
|
||||
if (tasks.length === 0) return null;
|
||||
|
||||
// Helper function to get localized display name for compressed uploads
|
||||
const getDisplayName = (task: UploadTask) => {
|
||||
// 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" && (
|
||||
<Loader2 size={14} className="animate-spin text-primary" />
|
||||
)}
|
||||
{task.status === "pending" && (
|
||||
<Upload size={14} className="text-muted-foreground animate-pulse" />
|
||||
)}
|
||||
{task.status === "completed" && (
|
||||
<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.speed > 0 && (
|
||||
<span className="text-[10px] text-primary font-mono shrink-0">
|
||||
{formatSpeed(task.speed)}
|
||||
</span>
|
||||
)}
|
||||
{task.status === "uploading" && remainingStr && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
{remainingStr}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(task.status === "uploading" || 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.progress}%`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-mono shrink-0 w-8 text-right">
|
||||
{task.status === "uploading" ? `${Math.round(task.progress)}%` : "..."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{task.status === "uploading" && 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("sftp.upload.completed")} - {formatBytes(task.totalBytes)}
|
||||
</div>
|
||||
)}
|
||||
{task.status === "cancelled" && (
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5">
|
||||
{t("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 === "pending") && onCancel && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||
onClick={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,
|
||||
};
|
||||
};
|
||||
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,
|
||||
};
|
||||
};
|
||||
189
components/sftp-modal/hooks/useSftpModalPermissions.ts
Normal file
189
components/sftp-modal/hooks/useSftpModalPermissions.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
interface UseSftpModalPermissionsParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => 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;
|
||||
}
|
||||
|
||||
interface PermissionsState {
|
||||
owner: { read: boolean; write: boolean; execute: boolean };
|
||||
group: { read: boolean; write: boolean; execute: boolean };
|
||||
others: { read: boolean; write: boolean; execute: boolean };
|
||||
}
|
||||
|
||||
interface UseSftpModalPermissionsResult {
|
||||
showPermissionsDialog: boolean;
|
||||
setShowPermissionsDialog: (open: boolean) => void;
|
||||
permissionsTarget: RemoteFile | null;
|
||||
permissions: PermissionsState;
|
||||
isChangingPermissions: boolean;
|
||||
openPermissionsDialog: (file: RemoteFile) => Promise<void>;
|
||||
togglePermission: (role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => void;
|
||||
getOctalPermissions: () => string;
|
||||
getSymbolicPermissions: () => string;
|
||||
handleSavePermissions: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpModalPermissions = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
chmodSftp,
|
||||
statSftp,
|
||||
t,
|
||||
}: UseSftpModalPermissionsParams): UseSftpModalPermissionsResult => {
|
||||
const [showPermissionsDialog, setShowPermissionsDialog] = useState(false);
|
||||
const [permissionsTarget, setPermissionsTarget] = useState<RemoteFile | null>(null);
|
||||
const [permissions, setPermissions] = useState<PermissionsState>({
|
||||
owner: { read: false, write: false, execute: false },
|
||||
group: { read: false, write: false, execute: false },
|
||||
others: { read: false, write: false, execute: false },
|
||||
});
|
||||
const [isChangingPermissions, setIsChangingPermissions] = useState(false);
|
||||
|
||||
const parsePermissions = useCallback((perms: string | undefined) => {
|
||||
const defaultPerms = {
|
||||
owner: { read: false, write: false, execute: false },
|
||||
group: { read: false, write: false, execute: false },
|
||||
others: { read: false, write: false, execute: false },
|
||||
};
|
||||
if (!perms) return defaultPerms;
|
||||
|
||||
if (/^[0-7]{3,4}$/.test(perms)) {
|
||||
const octal = perms.length === 4 ? perms.slice(1) : perms;
|
||||
const ownerBits = parseInt(octal[0], 10);
|
||||
const groupBits = parseInt(octal[1], 10);
|
||||
const othersBits = parseInt(octal[2], 10);
|
||||
return {
|
||||
owner: {
|
||||
read: (ownerBits & 4) !== 0,
|
||||
write: (ownerBits & 2) !== 0,
|
||||
execute: (ownerBits & 1) !== 0,
|
||||
},
|
||||
group: {
|
||||
read: (groupBits & 4) !== 0,
|
||||
write: (groupBits & 2) !== 0,
|
||||
execute: (groupBits & 1) !== 0,
|
||||
},
|
||||
others: {
|
||||
read: (othersBits & 4) !== 0,
|
||||
write: (othersBits & 2) !== 0,
|
||||
execute: (othersBits & 1) !== 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const pStr = perms.length === 10 ? perms.slice(1) : perms;
|
||||
if (pStr.length >= 9) {
|
||||
return {
|
||||
owner: {
|
||||
read: pStr[0] === "r",
|
||||
write: pStr[1] === "w",
|
||||
execute: pStr[2] === "x" || pStr[2] === "s",
|
||||
},
|
||||
group: {
|
||||
read: pStr[3] === "r",
|
||||
write: pStr[4] === "w",
|
||||
execute: pStr[5] === "x" || pStr[5] === "s",
|
||||
},
|
||||
others: {
|
||||
read: pStr[6] === "r",
|
||||
write: pStr[7] === "w",
|
||||
execute: pStr[8] === "x" || pStr[8] === "t",
|
||||
},
|
||||
};
|
||||
}
|
||||
return defaultPerms;
|
||||
}, []);
|
||||
|
||||
const openPermissionsDialog = useCallback(async (file: RemoteFile) => {
|
||||
if (isLocalSession) {
|
||||
toast.error("Permissions not available for local files", "SFTP");
|
||||
return;
|
||||
}
|
||||
setPermissionsTarget(file);
|
||||
|
||||
let permsStr = file.permissions;
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, file.name);
|
||||
const stat = await statSftp(await ensureSftp(), fullPath);
|
||||
if (stat.permissions) {
|
||||
permsStr = stat.permissions;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to fetch file permissions:", e);
|
||||
}
|
||||
|
||||
setPermissions(parsePermissions(permsStr));
|
||||
setShowPermissionsDialog(true);
|
||||
}, [currentPath, ensureSftp, isLocalSession, joinPath, parsePermissions, statSftp]);
|
||||
|
||||
const togglePermission = useCallback(
|
||||
(role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => {
|
||||
setPermissions((prev) => ({
|
||||
...prev,
|
||||
[role]: { ...prev[role], [perm]: !prev[role][perm] },
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getOctalPermissions = useCallback(() => {
|
||||
const getNum = (p: { read: boolean; write: boolean; execute: boolean }) =>
|
||||
(p.read ? 4 : 0) + (p.write ? 2 : 0) + (p.execute ? 1 : 0);
|
||||
return `${getNum(permissions.owner)}${getNum(permissions.group)}${getNum(permissions.others)}`;
|
||||
}, [permissions]);
|
||||
|
||||
const getSymbolicPermissions = useCallback(() => {
|
||||
const getSym = (p: { read: boolean; write: boolean; execute: boolean }) =>
|
||||
`${p.read ? "r" : "-"}${p.write ? "w" : "-"}${p.execute ? "x" : "-"}`;
|
||||
return (
|
||||
getSym(permissions.owner) +
|
||||
getSym(permissions.group) +
|
||||
getSym(permissions.others)
|
||||
);
|
||||
}, [permissions]);
|
||||
|
||||
const handleSavePermissions = useCallback(async () => {
|
||||
if (!permissionsTarget || isChangingPermissions) return;
|
||||
setIsChangingPermissions(true);
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, permissionsTarget.name);
|
||||
await chmodSftp(await ensureSftp(), fullPath, getOctalPermissions());
|
||||
setShowPermissionsDialog(false);
|
||||
setPermissionsTarget(null);
|
||||
await loadFiles(currentPath, { force: true });
|
||||
toast.success(t("sftp.permissions.success"), "SFTP");
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.permissions.failed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setIsChangingPermissions(false);
|
||||
}
|
||||
}, [chmodSftp, currentPath, ensureSftp, getOctalPermissions, isChangingPermissions, joinPath, loadFiles, permissionsTarget, t]);
|
||||
|
||||
return {
|
||||
showPermissionsDialog,
|
||||
setShowPermissionsDialog,
|
||||
permissionsTarget,
|
||||
permissions,
|
||||
isChangingPermissions,
|
||||
openPermissionsDialog,
|
||||
togglePermission,
|
||||
getOctalPermissions,
|
||||
getSymbolicPermissions,
|
||||
handleSavePermissions,
|
||||
};
|
||||
};
|
||||
85
components/sftp-modal/hooks/useSftpModalRename.ts
Normal file
85
components/sftp-modal/hooks/useSftpModalRename.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
interface UseSftpModalRenameParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
renameSftp: (sftpId: string, oldPath: string, newPath: string) => Promise<void>;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
interface UseSftpModalRenameResult {
|
||||
showRenameDialog: boolean;
|
||||
setShowRenameDialog: (open: boolean) => void;
|
||||
renameTarget: RemoteFile | null;
|
||||
renameName: string;
|
||||
setRenameName: (value: string) => void;
|
||||
isRenaming: boolean;
|
||||
openRenameDialog: (file: RemoteFile) => void;
|
||||
handleRename: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpModalRename = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
renameSftp,
|
||||
t,
|
||||
}: UseSftpModalRenameParams): UseSftpModalRenameResult => {
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false);
|
||||
const [renameTarget, setRenameTarget] = useState<RemoteFile | null>(null);
|
||||
const [renameName, setRenameName] = useState("");
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
|
||||
const openRenameDialog = useCallback((file: RemoteFile) => {
|
||||
setRenameTarget(file);
|
||||
setRenameName(file.name);
|
||||
setShowRenameDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleRename = useCallback(async () => {
|
||||
if (!renameTarget || !renameName.trim() || isRenaming) return;
|
||||
if (renameName.trim() === renameTarget.name) {
|
||||
setShowRenameDialog(false);
|
||||
return;
|
||||
}
|
||||
setIsRenaming(true);
|
||||
try {
|
||||
const oldPath = joinPath(currentPath, renameTarget.name);
|
||||
const newPath = joinPath(currentPath, renameName.trim());
|
||||
if (isLocalSession) {
|
||||
toast.error("Local rename not implemented", "SFTP");
|
||||
} else {
|
||||
await renameSftp(await ensureSftp(), oldPath, newPath);
|
||||
}
|
||||
setShowRenameDialog(false);
|
||||
setRenameTarget(null);
|
||||
setRenameName("");
|
||||
await loadFiles(currentPath, { force: true });
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.renameFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setIsRenaming(false);
|
||||
}
|
||||
}, [currentPath, ensureSftp, isLocalSession, joinPath, loadFiles, renameName, renameSftp, renameTarget, t, isRenaming]);
|
||||
|
||||
return {
|
||||
showRenameDialog,
|
||||
setShowRenameDialog,
|
||||
renameTarget,
|
||||
renameName,
|
||||
setRenameName,
|
||||
isRenaming,
|
||||
openRenameDialog,
|
||||
handleRename,
|
||||
};
|
||||
};
|
||||
99
components/sftp-modal/hooks/useSftpModalSelection.ts
Normal file
99
components/sftp-modal/hooks/useSftpModalSelection.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { useCallback, useRef } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
|
||||
interface UseSftpModalSelectionParams {
|
||||
files: RemoteFile[];
|
||||
setSelectedFiles: (value: Set<string> | ((prev: Set<string>) => Set<string>)) => void;
|
||||
currentPath: string;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
onNavigate: (path: string) => void;
|
||||
onOpenFile: (file: RemoteFile) => void;
|
||||
onNavigateUp: () => void;
|
||||
}
|
||||
|
||||
interface UseSftpModalSelectionResult {
|
||||
handleFileClick: (file: RemoteFile, index: number, e: React.MouseEvent) => void;
|
||||
handleFileDoubleClick: (file: RemoteFile) => void;
|
||||
}
|
||||
|
||||
export const useSftpModalSelection = ({
|
||||
files,
|
||||
setSelectedFiles,
|
||||
currentPath,
|
||||
joinPath,
|
||||
onNavigate,
|
||||
onOpenFile,
|
||||
onNavigateUp,
|
||||
}: UseSftpModalSelectionParams): UseSftpModalSelectionResult => {
|
||||
const lastSelectedIndexRef = useRef<number | null>(null);
|
||||
|
||||
const handleFileClick = useCallback(
|
||||
(file: RemoteFile, index: number, e: React.MouseEvent) => {
|
||||
if (file.name === "..") return;
|
||||
|
||||
if (file.type === "directory") {
|
||||
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
|
||||
const start = Math.min(lastSelectedIndexRef.current, index);
|
||||
const end = Math.max(lastSelectedIndexRef.current, index);
|
||||
const newSelection = new Set<string>();
|
||||
for (let i = start; i <= end; i++) {
|
||||
if (files[i] && files[i].type !== "directory") {
|
||||
newSelection.add(files[i].name);
|
||||
}
|
||||
}
|
||||
setSelectedFiles(newSelection);
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
setSelectedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
|
||||
const start = Math.min(lastSelectedIndexRef.current, index);
|
||||
const end = Math.max(lastSelectedIndexRef.current, index);
|
||||
const newSelection = new Set<string>();
|
||||
for (let i = start; i <= end; i++) {
|
||||
if (files[i] && files[i].type !== "directory") {
|
||||
newSelection.add(files[i].name);
|
||||
}
|
||||
}
|
||||
setSelectedFiles(newSelection);
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
setSelectedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(file.name)) {
|
||||
next.delete(file.name);
|
||||
} else {
|
||||
next.add(file.name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
lastSelectedIndexRef.current = index;
|
||||
} else {
|
||||
setSelectedFiles(new Set([file.name]));
|
||||
lastSelectedIndexRef.current = index;
|
||||
}
|
||||
},
|
||||
[files, setSelectedFiles],
|
||||
);
|
||||
|
||||
const handleFileDoubleClick = useCallback(
|
||||
(file: RemoteFile) => {
|
||||
if (file.name === "..") {
|
||||
onNavigateUp();
|
||||
return;
|
||||
}
|
||||
if (file.type === "directory" || (file.type === "symlink" && file.linkTarget === "directory")) {
|
||||
onNavigate(joinPath(currentPath, file.name));
|
||||
} else {
|
||||
onOpenFile(file);
|
||||
}
|
||||
},
|
||||
[currentPath, joinPath, onNavigate, onNavigateUp, onOpenFile],
|
||||
);
|
||||
|
||||
return { handleFileClick, handleFileDoubleClick };
|
||||
};
|
||||
392
components/sftp-modal/hooks/useSftpModalSession.ts
Normal file
392
components/sftp-modal/hooks/useSftpModalSession.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { Host, RemoteFile } from "../../../types";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
interface UseSftpModalSessionParams {
|
||||
open: boolean;
|
||||
host: Host;
|
||||
credentials: {
|
||||
username?: string;
|
||||
hostname: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
passphrase?: string;
|
||||
publicKey?: string;
|
||||
keyId?: string;
|
||||
keySource?: "generated" | "imported";
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
sftpSudo?: boolean;
|
||||
};
|
||||
initialPath?: string;
|
||||
isLocalSession: boolean;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
openSftp: (params: {
|
||||
sessionId: string;
|
||||
hostname: string;
|
||||
username: string;
|
||||
port: number;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
passphrase?: string;
|
||||
publicKey?: string;
|
||||
keyId?: string;
|
||||
keySource?: "generated" | "imported";
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
sudo?: boolean;
|
||||
}) => Promise<string>;
|
||||
closeSftp: (sftpId: string) => Promise<void>;
|
||||
listSftp: (sftpId: string, path: string) => Promise<RemoteFile[]>;
|
||||
listLocalDir: (path: string) => Promise<RemoteFile[]>;
|
||||
getHomeDir: () => Promise<string | null>;
|
||||
onClearSelection: () => void;
|
||||
}
|
||||
|
||||
interface UseSftpModalSessionResult {
|
||||
currentPath: string;
|
||||
setCurrentPath: (path: string) => void;
|
||||
files: RemoteFile[];
|
||||
setFiles: (files: RemoteFile[]) => void;
|
||||
loading: boolean;
|
||||
setLoading: (loading: boolean) => void;
|
||||
reconnecting: boolean;
|
||||
ensureSftp: () => Promise<string>;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
closeSftpSession: () => Promise<void>;
|
||||
localHomeRef: React.MutableRefObject<string | null>;
|
||||
}
|
||||
|
||||
export const useSftpModalSession = ({
|
||||
open,
|
||||
host,
|
||||
credentials,
|
||||
initialPath,
|
||||
isLocalSession,
|
||||
t,
|
||||
openSftp,
|
||||
closeSftp,
|
||||
listSftp,
|
||||
listLocalDir,
|
||||
getHomeDir,
|
||||
onClearSelection,
|
||||
}: UseSftpModalSessionParams): UseSftpModalSessionResult => {
|
||||
const [currentPath, setCurrentPath] = useState("/");
|
||||
const [files, setFiles] = useState<RemoteFile[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
const sftpIdRef = useRef<string | null>(null);
|
||||
const initializedRef = useRef(false);
|
||||
const lastInitialPathRef = useRef<string | undefined>(undefined);
|
||||
const localHomeRef = useRef<string | null>(null);
|
||||
|
||||
const reconnectingRef = useRef(false);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const MAX_RECONNECT_ATTEMPTS = 3;
|
||||
|
||||
const DIR_CACHE_TTL_MS = 10_000;
|
||||
const dirCacheRef = useRef<
|
||||
Map<string, { files: RemoteFile[]; timestamp: number }>
|
||||
>(new Map());
|
||||
const loadSeqRef = useRef(0);
|
||||
|
||||
const ensureSftp = useCallback(async () => {
|
||||
if (isLocalSession) throw new Error("Local session does not use SFTP");
|
||||
if (sftpIdRef.current) return sftpIdRef.current;
|
||||
const sftpId = await openSftp({
|
||||
sessionId: `sftp-modal-${host.id}`,
|
||||
hostname: credentials.hostname,
|
||||
username: credentials.username || "root",
|
||||
port: credentials.port || 22,
|
||||
password: credentials.password,
|
||||
privateKey: credentials.privateKey,
|
||||
certificate: credentials.certificate,
|
||||
passphrase: credentials.passphrase,
|
||||
publicKey: credentials.publicKey,
|
||||
keyId: credentials.keyId,
|
||||
keySource: credentials.keySource,
|
||||
proxy: credentials.proxy,
|
||||
jumpHosts: credentials.jumpHosts,
|
||||
sudo: credentials.sftpSudo,
|
||||
});
|
||||
sftpIdRef.current = sftpId;
|
||||
return sftpId;
|
||||
}, [
|
||||
isLocalSession,
|
||||
host.id,
|
||||
credentials.hostname,
|
||||
credentials.username,
|
||||
credentials.port,
|
||||
credentials.password,
|
||||
credentials.privateKey,
|
||||
credentials.certificate,
|
||||
credentials.passphrase,
|
||||
credentials.publicKey,
|
||||
credentials.keyId,
|
||||
credentials.keySource,
|
||||
credentials.proxy,
|
||||
credentials.jumpHosts,
|
||||
credentials.sftpSudo,
|
||||
openSftp,
|
||||
]);
|
||||
|
||||
const closeSftpSession = useCallback(async () => {
|
||||
if (!isLocalSession && sftpIdRef.current) {
|
||||
try {
|
||||
await closeSftp(sftpIdRef.current);
|
||||
} catch {
|
||||
// Silently ignore close errors - connection may already be closed
|
||||
}
|
||||
}
|
||||
sftpIdRef.current = null;
|
||||
}, [closeSftp, isLocalSession]);
|
||||
|
||||
const isSessionError = useCallback((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") ||
|
||||
msg.includes("write after end") ||
|
||||
msg.includes("no response") ||
|
||||
msg.includes("client disconnected")
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleSessionError = useCallback(async () => {
|
||||
if (reconnectingRef.current) return;
|
||||
reconnectingRef.current = true;
|
||||
setReconnecting(true);
|
||||
reconnectAttemptsRef.current = 0;
|
||||
|
||||
while (reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) {
|
||||
try {
|
||||
reconnectAttemptsRef.current += 1;
|
||||
if (sftpIdRef.current) {
|
||||
try {
|
||||
await closeSftp(sftpIdRef.current);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
sftpIdRef.current = null;
|
||||
}
|
||||
await ensureSftp();
|
||||
reconnectingRef.current = false;
|
||||
setReconnecting(false);
|
||||
return;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`[SFTP] Reconnect attempt ${reconnectAttemptsRef.current} failed`,
|
||||
err,
|
||||
);
|
||||
if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
|
||||
reconnectingRef.current = false;
|
||||
setReconnecting(false);
|
||||
toast.error(t("sftp.error.reconnectFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
}, [closeSftp, ensureSftp, t]);
|
||||
|
||||
const loadFiles = useCallback(
|
||||
async (path: string, options?: { force?: boolean }) => {
|
||||
const requestId = ++loadSeqRef.current;
|
||||
setLoading(true);
|
||||
onClearSelection();
|
||||
|
||||
try {
|
||||
if (isLocalSession) {
|
||||
const list = await listLocalDir(path);
|
||||
if (requestId === loadSeqRef.current) {
|
||||
setFiles(list);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = `${host.id}::${path}`;
|
||||
const cached = dirCacheRef.current.get(cacheKey);
|
||||
const isFresh =
|
||||
cached && Date.now() - cached.timestamp < DIR_CACHE_TTL_MS;
|
||||
if (cached && isFresh && !options?.force) {
|
||||
setFiles(cached.files);
|
||||
return;
|
||||
}
|
||||
|
||||
const sftpId = await ensureSftp();
|
||||
const list = await listSftp(sftpId, path);
|
||||
if (requestId !== loadSeqRef.current) return;
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(cacheKey, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (e) {
|
||||
if (!isLocalSession && isSessionError(e) && files.length > 0) {
|
||||
logger.info("[SFTP] Session lost, attempting to reconnect...");
|
||||
handleSessionError();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error("Failed to load files", e);
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
setFiles([]);
|
||||
} finally {
|
||||
if (loadSeqRef.current === requestId) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t, isSessionError, handleSessionError, files.length, onClearSelection],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const cacheKey = `${host.id}::${currentPath}`;
|
||||
const cached = dirCacheRef.current.get(cacheKey);
|
||||
const isFresh = cached && Date.now() - cached.timestamp < DIR_CACHE_TTL_MS;
|
||||
if (!isFresh) {
|
||||
setFiles([]);
|
||||
onClearSelection();
|
||||
}
|
||||
}, [currentPath, host.id, onClearSelection, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (!initializedRef.current || lastInitialPathRef.current !== initialPath) {
|
||||
initializedRef.current = true;
|
||||
lastInitialPathRef.current = initialPath;
|
||||
onClearSelection();
|
||||
setLoading(true);
|
||||
|
||||
if (isLocalSession) {
|
||||
(async () => {
|
||||
const homePath = await getHomeDir();
|
||||
localHomeRef.current = homePath ?? null;
|
||||
const startPath = initialPath || homePath || "/";
|
||||
try {
|
||||
const list = await listLocalDir(startPath);
|
||||
setCurrentPath(startPath);
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::${startPath}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const homePath = await getHomeDir();
|
||||
localHomeRef.current = homePath ?? null;
|
||||
if (initialPath) {
|
||||
try {
|
||||
const sftpId = await ensureSftp();
|
||||
const list = await listSftp(sftpId, initialPath);
|
||||
setCurrentPath(initialPath);
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::${initialPath}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
} catch {
|
||||
logger.warn(
|
||||
`[SFTP] Initial path ${initialPath} not accessible, falling back to home`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const sftpId = await ensureSftp();
|
||||
const list = await listSftp(sftpId, homePath || "/");
|
||||
setCurrentPath(homePath || "/");
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::${homePath || "/"}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setLoading(false);
|
||||
} catch {
|
||||
logger.warn(`[SFTP] Home ${homePath} not accessible, using /`);
|
||||
try {
|
||||
const sftpId = await ensureSftp();
|
||||
const list = await listSftp(sftpId, "/");
|
||||
setCurrentPath("/");
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::/`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("[SFTP] Failed to load root directory", e);
|
||||
toast.error(t("sftp.error.loadFailed"), "SFTP");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
void loadFiles(currentPath);
|
||||
} else {
|
||||
loadSeqRef.current += 1;
|
||||
void closeSftpSession();
|
||||
initializedRef.current = false;
|
||||
}
|
||||
}, [
|
||||
closeSftpSession,
|
||||
currentPath,
|
||||
ensureSftp,
|
||||
getHomeDir,
|
||||
host.id,
|
||||
initialPath,
|
||||
isLocalSession,
|
||||
listLocalDir,
|
||||
listSftp,
|
||||
loadFiles,
|
||||
onClearSelection,
|
||||
open,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
void closeSftpSession();
|
||||
};
|
||||
}, [closeSftpSession]);
|
||||
|
||||
return {
|
||||
currentPath,
|
||||
setCurrentPath,
|
||||
files,
|
||||
setFiles,
|
||||
loading,
|
||||
setLoading,
|
||||
reconnecting,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
closeSftpSession,
|
||||
localHomeRef,
|
||||
};
|
||||
};
|
||||
76
components/sftp-modal/hooks/useSftpModalSorting.ts
Normal file
76
components/sftp-modal/hooks/useSftpModalSorting.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
|
||||
export type SortField = "name" | "size" | "modified";
|
||||
export type SortOrder = "asc" | "desc";
|
||||
|
||||
interface UseSftpModalSortingResult {
|
||||
sortField: SortField;
|
||||
sortOrder: SortOrder;
|
||||
columnWidths: { name: number; size: number; modified: number; actions: number };
|
||||
handleSort: (field: SortField) => void;
|
||||
handleResizeStart: (field: string, e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export const useSftpModalSorting = (): UseSftpModalSortingResult => {
|
||||
const [sortField, setSortField] = useState<SortField>("name");
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc");
|
||||
const [columnWidths, setColumnWidths] = useState({
|
||||
name: 45,
|
||||
size: 15,
|
||||
modified: 25,
|
||||
actions: 15,
|
||||
});
|
||||
|
||||
const resizingRef = useRef<{
|
||||
field: string;
|
||||
startX: number;
|
||||
startWidth: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const handleResizeMove = useCallback((e: MouseEvent) => {
|
||||
if (!resizingRef.current) return;
|
||||
const diff = e.clientX - resizingRef.current.startX;
|
||||
const newWidth = Math.max(
|
||||
10,
|
||||
Math.min(60, resizingRef.current.startWidth + diff / 5),
|
||||
);
|
||||
setColumnWidths((prev) => ({
|
||||
...prev,
|
||||
[resizingRef.current!.field]: newWidth,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
resizingRef.current = null;
|
||||
document.removeEventListener("mousemove", handleResizeMove);
|
||||
document.removeEventListener("mouseup", handleResizeEnd);
|
||||
}, [handleResizeMove]);
|
||||
|
||||
const handleResizeStart = (field: string, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
resizingRef.current = {
|
||||
field,
|
||||
startX: e.clientX,
|
||||
startWidth: columnWidths[field as keyof typeof columnWidths],
|
||||
};
|
||||
document.addEventListener("mousemove", handleResizeMove);
|
||||
document.addEventListener("mouseup", handleResizeEnd);
|
||||
};
|
||||
|
||||
return {
|
||||
sortField,
|
||||
sortOrder,
|
||||
columnWidths,
|
||||
handleSort,
|
||||
handleResizeStart,
|
||||
};
|
||||
};
|
||||
87
components/sftp-modal/hooks/useSftpModalTextEditor.ts
Normal file
87
components/sftp-modal/hooks/useSftpModalTextEditor.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
interface UseSftpModalTextEditorParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
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>;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
interface UseSftpModalTextEditorResult {
|
||||
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>;
|
||||
}
|
||||
|
||||
export const useSftpModalTextEditor = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
readLocalFile,
|
||||
readSftp,
|
||||
writeLocalFile,
|
||||
writeSftp,
|
||||
t,
|
||||
}: UseSftpModalTextEditorParams): UseSftpModalTextEditorResult => {
|
||||
const [showTextEditor, setShowTextEditor] = useState(false);
|
||||
const [textEditorTarget, setTextEditorTarget] = useState<RemoteFile | null>(null);
|
||||
const [textEditorContent, setTextEditorContent] = useState("");
|
||||
const [loadingTextContent, setLoadingTextContent] = useState(false);
|
||||
|
||||
const handleEditFile = useCallback(async (file: RemoteFile) => {
|
||||
try {
|
||||
setLoadingTextContent(true);
|
||||
setTextEditorTarget(file);
|
||||
const fullPath = joinPath(currentPath, file.name);
|
||||
const content = isLocalSession
|
||||
? await readLocalFile(fullPath).then((buf) => new TextDecoder().decode(buf))
|
||||
: await readSftp(await ensureSftp(), fullPath);
|
||||
setTextEditorContent(content);
|
||||
setShowTextEditor(true);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setLoadingTextContent(false);
|
||||
}
|
||||
}, [currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, readSftp, t]);
|
||||
|
||||
const handleSaveTextFile = useCallback(async (content: string) => {
|
||||
if (!textEditorTarget) return;
|
||||
const fullPath = joinPath(currentPath, textEditorTarget.name);
|
||||
if (isLocalSession) {
|
||||
const encoder = new TextEncoder();
|
||||
await writeLocalFile(fullPath, encoder.encode(content).buffer);
|
||||
} else {
|
||||
await writeSftp(await ensureSftp(), fullPath, content);
|
||||
}
|
||||
}, [currentPath, ensureSftp, isLocalSession, joinPath, textEditorTarget, writeLocalFile, writeSftp]);
|
||||
|
||||
return {
|
||||
showTextEditor,
|
||||
setShowTextEditor,
|
||||
textEditorTarget,
|
||||
setTextEditorTarget,
|
||||
textEditorContent,
|
||||
setTextEditorContent,
|
||||
loadingTextContent,
|
||||
handleEditFile,
|
||||
handleSaveTextFile,
|
||||
};
|
||||
};
|
||||
630
components/sftp-modal/hooks/useSftpModalTransfers.ts
Normal file
630
components/sftp-modal/hooks/useSftpModalTransfers.ts
Normal file
@@ -0,0 +1,630 @@
|
||||
import React, { useCallback, useState, useRef, useMemo } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
import {
|
||||
UploadController,
|
||||
uploadFromDataTransfer,
|
||||
uploadFromFileList,
|
||||
uploadEntriesDirect,
|
||||
UploadBridge,
|
||||
UploadCallbacks,
|
||||
UploadTaskInfo,
|
||||
UploadProgress,
|
||||
} from "../../../lib/uploadService";
|
||||
import { DropEntry } from "../../../lib/sftpFileUtils";
|
||||
|
||||
interface UploadTask {
|
||||
id: string;
|
||||
fileName: string;
|
||||
status: "pending" | "uploading" | "completed" | "failed" | "cancelled";
|
||||
progress: number;
|
||||
totalBytes: number;
|
||||
transferredBytes: number;
|
||||
speed: number;
|
||||
startTime: number;
|
||||
error?: string;
|
||||
isDirectory?: boolean;
|
||||
fileCount?: number;
|
||||
completedCount?: number;
|
||||
}
|
||||
|
||||
interface UseSftpModalTransfersParams {
|
||||
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>;
|
||||
writeSftpBinaryWithProgress: (
|
||||
sftpId: string,
|
||||
path: string,
|
||||
data: ArrayBuffer,
|
||||
taskId: string,
|
||||
onProgress: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete: () => void,
|
||||
onError: (error: string) => void,
|
||||
) => Promise<{ success: boolean; transferId: string; cancelled?: boolean }>;
|
||||
writeSftpBinary: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
|
||||
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
|
||||
mkdirLocal: (path: string) => Promise<void>;
|
||||
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
|
||||
cancelSftpUpload?: (taskId: string) => Promise<unknown>;
|
||||
startStreamTransfer?: (
|
||||
options: {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
sourceType: 'local' | 'sftp';
|
||||
targetType: 'local' | 'sftp';
|
||||
sourceSftpId?: string;
|
||||
targetSftpId?: string;
|
||||
totalBytes?: number;
|
||||
},
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void
|
||||
) => Promise<{ transferId: string; totalBytes?: number; error?: string }>;
|
||||
cancelTransfer?: (transferId: string) => Promise<void>;
|
||||
setLoading: (loading: boolean) => void;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
useCompressedUpload?: boolean; // Enable compressed folder uploads
|
||||
}
|
||||
|
||||
interface UseSftpModalTransfersResult {
|
||||
uploading: boolean;
|
||||
uploadTasks: UploadTask[];
|
||||
dragActive: boolean;
|
||||
handleDownload: (file: RemoteFile) => Promise<void>;
|
||||
handleUploadMultiple: (fileList: FileList) => Promise<void>;
|
||||
handleUploadFromDrop: (dataTransfer: DataTransfer) => Promise<void>;
|
||||
handleUploadEntries: (entries: DropEntry[]) => Promise<void>;
|
||||
handleFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
handleFolderSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
handleDrag: (e: React.DragEvent) => void;
|
||||
handleDrop: (e: React.DragEvent) => void;
|
||||
cancelUpload: () => Promise<void>;
|
||||
dismissTask: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export const useSftpModalTransfers = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
readLocalFile,
|
||||
readSftp,
|
||||
writeLocalFile,
|
||||
writeSftpBinaryWithProgress,
|
||||
writeSftpBinary,
|
||||
mkdirLocal,
|
||||
mkdirSftp,
|
||||
cancelSftpUpload,
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
setLoading,
|
||||
t,
|
||||
useCompressedUpload = false,
|
||||
}: UseSftpModalTransfersParams): UseSftpModalTransfersResult => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
|
||||
// Upload controller for cancellation support
|
||||
const uploadControllerRef = useRef<UploadController | null>(null);
|
||||
|
||||
// Cached SFTP ID to avoid multiple calls to ensureSftp
|
||||
const cachedSftpIdRef = useRef<string | null>(null);
|
||||
|
||||
// Track cancelled transfer IDs to detect cancellation in bridge wrapper
|
||||
const cancelledTransferIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Create upload bridge that adapts the modal's functions to the service interface
|
||||
const createUploadBridge = useMemo((): UploadBridge => {
|
||||
return {
|
||||
writeLocalFile,
|
||||
mkdirLocal,
|
||||
mkdirSftp: async (sftpId: string, path: string) => {
|
||||
await mkdirSftp(sftpId, path);
|
||||
},
|
||||
writeSftpBinary: async (sftpId: string, path: string, data: ArrayBuffer) => {
|
||||
await writeSftpBinary(sftpId, path, data);
|
||||
},
|
||||
writeSftpBinaryWithProgress: async (
|
||||
sftpId: string,
|
||||
path: string,
|
||||
data: ArrayBuffer,
|
||||
taskId: string,
|
||||
onProgress: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void
|
||||
) => {
|
||||
try {
|
||||
const result = await writeSftpBinaryWithProgress(
|
||||
sftpId,
|
||||
path,
|
||||
data,
|
||||
taskId,
|
||||
onProgress,
|
||||
onComplete || (() => { }),
|
||||
onError || (() => { })
|
||||
);
|
||||
|
||||
// Check if this transfer was cancelled
|
||||
const wasCancelled = cancelledTransferIdsRef.current.has(taskId);
|
||||
if (wasCancelled) {
|
||||
cancelledTransferIdsRef.current.delete(taskId);
|
||||
}
|
||||
return { success: result.success, transferId: result.transferId, cancelled: wasCancelled || result.cancelled };
|
||||
} catch (error) {
|
||||
// Check if this was a user-initiated cancellation
|
||||
const wasCancelled = cancelledTransferIdsRef.current.has(taskId);
|
||||
if (wasCancelled) {
|
||||
cancelledTransferIdsRef.current.delete(taskId);
|
||||
return { success: false, transferId: taskId, cancelled: true };
|
||||
}
|
||||
// Real error - propagate it by re-throwing
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
cancelSftpUpload,
|
||||
startStreamTransfer: startStreamTransfer ? async (
|
||||
options,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onError
|
||||
) => {
|
||||
try {
|
||||
const result = await startStreamTransfer(options, onProgress, onComplete, onError);
|
||||
const wasCancelled = cancelledTransferIdsRef.current.has(options.transferId);
|
||||
if (wasCancelled) {
|
||||
cancelledTransferIdsRef.current.delete(options.transferId);
|
||||
}
|
||||
// Handle case where result might be undefined (bridge not available)
|
||||
if (!result) {
|
||||
return { transferId: options.transferId, error: 'Stream transfer not available' };
|
||||
}
|
||||
return { ...result, cancelled: wasCancelled };
|
||||
} catch (error) {
|
||||
const wasCancelled = cancelledTransferIdsRef.current.has(options.transferId);
|
||||
if (wasCancelled) {
|
||||
cancelledTransferIdsRef.current.delete(options.transferId);
|
||||
return { transferId: options.transferId, cancelled: true };
|
||||
}
|
||||
return { transferId: options.transferId, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
} : undefined,
|
||||
cancelTransfer,
|
||||
};
|
||||
}, [writeLocalFile, mkdirLocal, mkdirSftp, writeSftpBinary, writeSftpBinaryWithProgress, cancelSftpUpload, startStreamTransfer, cancelTransfer]);
|
||||
|
||||
// Create upload callbacks
|
||||
const createUploadCallbacks = useCallback((): UploadCallbacks => {
|
||||
return {
|
||||
onScanningStart: (taskId: string) => {
|
||||
const scanningTask: UploadTask = {
|
||||
id: taskId,
|
||||
fileName: t("sftp.upload.scanning"),
|
||||
status: "pending",
|
||||
progress: 0,
|
||||
totalBytes: 0,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: true,
|
||||
};
|
||||
setUploadTasks(prev => [...prev, scanningTask]);
|
||||
},
|
||||
onScanningEnd: (taskId: string) => {
|
||||
setUploadTasks(prev => prev.filter(t => t.id !== taskId));
|
||||
},
|
||||
onTaskCreated: (task: UploadTaskInfo) => {
|
||||
const uploadTask: UploadTask = {
|
||||
id: task.id,
|
||||
fileName: task.displayName,
|
||||
status: "pending",
|
||||
progress: 0,
|
||||
totalBytes: task.totalBytes,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: task.isDirectory,
|
||||
};
|
||||
setUploadTasks(prev => [...prev, uploadTask]);
|
||||
},
|
||||
onTaskProgress: (taskId: string, progress: UploadProgress) => {
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task => {
|
||||
if (task.id !== taskId) return task;
|
||||
|
||||
// Don't update progress if task is already completed, failed, or cancelled
|
||||
if (task.status === "completed" || task.status === "failed" || task.status === "cancelled") {
|
||||
return task;
|
||||
}
|
||||
|
||||
return {
|
||||
...task,
|
||||
status: "uploading" as const,
|
||||
progress: progress.percent,
|
||||
transferredBytes: progress.transferred,
|
||||
speed: progress.speed,
|
||||
};
|
||||
})
|
||||
);
|
||||
},
|
||||
onTaskCompleted: (taskId: string, totalBytes: number) => {
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === taskId
|
||||
? {
|
||||
...task,
|
||||
status: "completed" as const,
|
||||
progress: 100,
|
||||
transferredBytes: totalBytes,
|
||||
speed: 0,
|
||||
}
|
||||
: task
|
||||
)
|
||||
);
|
||||
},
|
||||
onTaskFailed: (taskId: string, error: string) => {
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === taskId
|
||||
? {
|
||||
...task,
|
||||
status: "failed" as const,
|
||||
speed: 0,
|
||||
error,
|
||||
}
|
||||
: task
|
||||
)
|
||||
);
|
||||
},
|
||||
onTaskCancelled: (taskId: string) => {
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === taskId
|
||||
? {
|
||||
...task,
|
||||
status: "cancelled" as const,
|
||||
speed: 0,
|
||||
}
|
||||
: task
|
||||
)
|
||||
);
|
||||
},
|
||||
onTaskNameUpdate: (taskId: string, newName: string) => {
|
||||
// Parse the phase format: "folderName|phase"
|
||||
let displayName = newName;
|
||||
if (newName.includes('|')) {
|
||||
const [folderName, phase] = newName.split('|');
|
||||
const phaseLabel = phase === 'compressing' ? t('sftp.upload.phase.compressing')
|
||||
: phase === 'extracting' ? t('sftp.upload.phase.extracting')
|
||||
: phase === 'uploading' ? t('sftp.upload.phase.uploading')
|
||||
: t('sftp.upload.phase.compressed');
|
||||
displayName = `${folderName} (${phaseLabel})`;
|
||||
}
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === taskId
|
||||
? {
|
||||
...task,
|
||||
fileName: displayName,
|
||||
}
|
||||
: task
|
||||
)
|
||||
);
|
||||
},
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
// Helper function to perform upload with compression setting from user preference
|
||||
const performUpload = useCallback(async (
|
||||
files: FileList | File[],
|
||||
useCompressed: boolean
|
||||
): Promise<void> => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
|
||||
// Get SFTP ID for remote sessions
|
||||
let sftpId: string | null = null;
|
||||
if (!isLocalSession) {
|
||||
sftpId = await ensureSftp();
|
||||
cachedSftpIdRef.current = sftpId;
|
||||
}
|
||||
|
||||
// Create controller for cancellation
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
|
||||
const callbacks = createUploadCallbacks();
|
||||
|
||||
try {
|
||||
await uploadFromFileList(
|
||||
files,
|
||||
{
|
||||
targetPath: currentPath,
|
||||
sftpId,
|
||||
isLocal: isLocalSession,
|
||||
bridge: createUploadBridge,
|
||||
joinPath,
|
||||
callbacks,
|
||||
useCompressedUpload: useCompressed,
|
||||
},
|
||||
controller
|
||||
);
|
||||
|
||||
await loadFiles(currentPath, { force: true });
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
||||
"SFTP"
|
||||
);
|
||||
} finally {
|
||||
// Upload process is complete - clear uploading state and controller
|
||||
setUploading(false);
|
||||
uploadControllerRef.current = null;
|
||||
cachedSftpIdRef.current = null;
|
||||
}
|
||||
}, [currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t]);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (file: RemoteFile) => {
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, file.name);
|
||||
setLoading(true);
|
||||
const content = isLocalSession
|
||||
? await readLocalFile(fullPath)
|
||||
: await readSftp(await ensureSftp(), fullPath);
|
||||
const blob = new Blob([content], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.downloadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, readSftp, setLoading, t],
|
||||
);
|
||||
|
||||
|
||||
|
||||
const handleUploadMultiple = useCallback(
|
||||
async (fileList: FileList) => {
|
||||
if (fileList.length === 0) return;
|
||||
|
||||
// Use compressed upload if enabled in settings (auto-fallback is handled in uploadService)
|
||||
await performUpload(fileList, useCompressedUpload);
|
||||
},
|
||||
[performUpload, useCompressedUpload],
|
||||
);
|
||||
|
||||
const handleUploadFromDrop = useCallback(
|
||||
async (dataTransfer: DataTransfer) => {
|
||||
setUploading(true);
|
||||
|
||||
// Get SFTP ID for remote sessions
|
||||
let sftpId: string | null = null;
|
||||
if (!isLocalSession) {
|
||||
sftpId = await ensureSftp();
|
||||
cachedSftpIdRef.current = sftpId;
|
||||
}
|
||||
|
||||
// Create controller for cancellation
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
|
||||
const callbacks = createUploadCallbacks();
|
||||
|
||||
try {
|
||||
await uploadFromDataTransfer(
|
||||
dataTransfer,
|
||||
{
|
||||
targetPath: currentPath,
|
||||
sftpId,
|
||||
isLocal: isLocalSession,
|
||||
bridge: createUploadBridge,
|
||||
joinPath,
|
||||
callbacks,
|
||||
useCompressedUpload,
|
||||
},
|
||||
controller
|
||||
);
|
||||
|
||||
await loadFiles(currentPath, { force: true });
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
||||
"SFTP"
|
||||
);
|
||||
} finally {
|
||||
// Upload process is complete - clear uploading state and controller
|
||||
setUploading(false);
|
||||
uploadControllerRef.current = null;
|
||||
cachedSftpIdRef.current = null;
|
||||
}
|
||||
},
|
||||
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t, useCompressedUpload],
|
||||
);
|
||||
|
||||
// Handle upload from DropEntry array (used for drag-and-drop to terminal)
|
||||
const handleUploadEntries = useCallback(
|
||||
async (entries: DropEntry[]) => {
|
||||
if (entries.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
|
||||
// Get SFTP ID for remote sessions
|
||||
let sftpId: string | null = null;
|
||||
if (!isLocalSession) {
|
||||
sftpId = await ensureSftp();
|
||||
cachedSftpIdRef.current = sftpId;
|
||||
}
|
||||
|
||||
// Create controller for cancellation
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
|
||||
const callbacks = createUploadCallbacks();
|
||||
|
||||
try {
|
||||
await uploadEntriesDirect(
|
||||
entries,
|
||||
{
|
||||
targetPath: currentPath,
|
||||
sftpId,
|
||||
isLocal: isLocalSession,
|
||||
bridge: createUploadBridge,
|
||||
joinPath,
|
||||
callbacks,
|
||||
useCompressedUpload,
|
||||
},
|
||||
controller
|
||||
);
|
||||
|
||||
await loadFiles(currentPath, { force: true });
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
||||
"SFTP"
|
||||
);
|
||||
} finally {
|
||||
// Upload process is complete - clear uploading state and controller
|
||||
setUploading(false);
|
||||
uploadControllerRef.current = null;
|
||||
cachedSftpIdRef.current = null;
|
||||
}
|
||||
},
|
||||
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t, useCompressedUpload],
|
||||
);
|
||||
|
||||
// Handle upload from File array (used by file input after copying files)
|
||||
const handleUploadFromFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
// Use compressed upload if enabled in settings (auto-fallback is handled in uploadService)
|
||||
await performUpload(files, useCompressedUpload);
|
||||
},
|
||||
[performUpload, useCompressedUpload],
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
// Copy the files before clearing the input, because clearing the input
|
||||
// will also clear the FileList reference
|
||||
const files = Array.from(e.target.files);
|
||||
// Clear input first to allow selecting the same files again
|
||||
e.target.value = "";
|
||||
// Now start the upload with the copied files
|
||||
void handleUploadFromFiles(files);
|
||||
} else {
|
||||
e.target.value = "";
|
||||
}
|
||||
},
|
||||
[handleUploadFromFiles],
|
||||
);
|
||||
|
||||
const handleFolderSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
// Copy the files before clearing the input, because clearing the input
|
||||
// will also clear the FileList reference
|
||||
const files = Array.from(e.target.files);
|
||||
// Clear input first to allow selecting the same folder again
|
||||
e.target.value = "";
|
||||
// Now start the upload with the copied files
|
||||
void handleUploadFromFiles(files);
|
||||
} else {
|
||||
e.target.value = "";
|
||||
}
|
||||
},
|
||||
[handleUploadFromFiles],
|
||||
);
|
||||
|
||||
const handleDrag = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
|
||||
void handleUploadFromDrop(e.dataTransfer);
|
||||
}
|
||||
},
|
||||
[handleUploadFromDrop],
|
||||
);
|
||||
|
||||
const cancelUpload = useCallback(async () => {
|
||||
const controller = uploadControllerRef.current;
|
||||
if (controller) {
|
||||
// Mark all active transfer IDs as cancelled before calling cancel
|
||||
const activeIds = controller.getActiveTransferIds();
|
||||
for (const id of activeIds) {
|
||||
cancelledTransferIdsRef.current.add(id);
|
||||
}
|
||||
await controller.cancel();
|
||||
}
|
||||
|
||||
// Always clear all uploading/pending tasks immediately, even without controller
|
||||
setUploadTasks(prev => {
|
||||
const hasActiveTasks = prev.some(t => t.status === "uploading" || t.status === "pending");
|
||||
if (!hasActiveTasks) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return prev.map(task =>
|
||||
task.status === "uploading" || task.status === "pending"
|
||||
? { ...task, status: "cancelled" as const, speed: 0 }
|
||||
: task
|
||||
);
|
||||
});
|
||||
|
||||
// Also reset uploading state
|
||||
setUploading(false);
|
||||
}, []);
|
||||
|
||||
const dismissTask = useCallback((taskId: string) => {
|
||||
setUploadTasks(prev => prev.filter(t => t.id !== taskId));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
uploading,
|
||||
uploadTasks,
|
||||
dragActive,
|
||||
handleDownload,
|
||||
handleUploadMultiple,
|
||||
handleUploadFromDrop,
|
||||
handleUploadEntries,
|
||||
handleFileSelect,
|
||||
handleFolderSelect,
|
||||
handleDrag,
|
||||
handleDrop,
|
||||
cancelUpload,
|
||||
dismissTask,
|
||||
};
|
||||
};
|
||||
123
components/sftp-modal/hooks/useSftpModalVirtualList.ts
Normal file
123
components/sftp-modal/hooks/useSftpModalVirtualList.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
|
||||
interface UseSftpModalVirtualListParams {
|
||||
open: boolean;
|
||||
sortedFiles: RemoteFile[];
|
||||
}
|
||||
|
||||
interface UseSftpModalVirtualListResult {
|
||||
fileListRef: React.RefObject<HTMLDivElement>;
|
||||
rowHeight: number;
|
||||
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
|
||||
shouldVirtualize: boolean;
|
||||
totalHeight: number;
|
||||
visibleRows: { file: RemoteFile; index: number; top: number }[];
|
||||
}
|
||||
|
||||
export const useSftpModalVirtualList = ({
|
||||
open,
|
||||
sortedFiles,
|
||||
}: UseSftpModalVirtualListParams): UseSftpModalVirtualListResult => {
|
||||
const fileListRef = useRef<HTMLDivElement>(null);
|
||||
const scrollFrameRef = useRef<number | null>(null);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
const [rowHeight, setRowHeight] = useState(40);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = fileListRef.current;
|
||||
if (!container || !open) return;
|
||||
const update = () => setViewportHeight(container.clientHeight);
|
||||
update();
|
||||
const raf = window.requestAnimationFrame(update);
|
||||
const resizeObserver = new ResizeObserver(update);
|
||||
resizeObserver.observe(container);
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
window.cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [open, sortedFiles.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = fileListRef.current;
|
||||
if (!container || !open || sortedFiles.length === 0) return;
|
||||
const raf = window.requestAnimationFrame(() => {
|
||||
const rowElement = container.querySelector(
|
||||
'[data-sftp-modal-row="true"]',
|
||||
) as HTMLElement | null;
|
||||
if (!rowElement) return;
|
||||
const nextHeight = Math.round(rowElement.getBoundingClientRect().height);
|
||||
if (nextHeight && Math.abs(nextHeight - rowHeight) > 1) {
|
||||
setRowHeight(nextHeight);
|
||||
}
|
||||
});
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [open, rowHeight, sortedFiles.length]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(scrollFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFileListScroll = useCallback(
|
||||
(e: React.UIEvent<HTMLDivElement>) => {
|
||||
const nextTop = e.currentTarget.scrollTop;
|
||||
if (scrollFrameRef.current !== null) return;
|
||||
scrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||
scrollFrameRef.current = null;
|
||||
setScrollTop(nextTop);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { shouldVirtualize, totalHeight, visibleRows } = useMemo(() => {
|
||||
const overscan = 6;
|
||||
const canVirtualize = open && viewportHeight > 0 && rowHeight > 0;
|
||||
const shouldVirtualizeLocal = canVirtualize && sortedFiles.length > 50;
|
||||
const totalHeightLocal = shouldVirtualizeLocal
|
||||
? sortedFiles.length * rowHeight
|
||||
: 0;
|
||||
const startIndex = shouldVirtualizeLocal
|
||||
? Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)
|
||||
: 0;
|
||||
const endIndex = shouldVirtualizeLocal
|
||||
? Math.min(
|
||||
sortedFiles.length - 1,
|
||||
Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan,
|
||||
)
|
||||
: sortedFiles.length - 1;
|
||||
const visibleRowsLocal = shouldVirtualizeLocal
|
||||
? sortedFiles
|
||||
.slice(startIndex, endIndex + 1)
|
||||
.map((file, idx) => ({
|
||||
file,
|
||||
index: startIndex + idx,
|
||||
top: (startIndex + idx) * rowHeight,
|
||||
}))
|
||||
: sortedFiles.map((file, index) => ({
|
||||
file,
|
||||
index,
|
||||
top: 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
shouldVirtualize: shouldVirtualizeLocal,
|
||||
totalHeight: totalHeightLocal,
|
||||
visibleRows: visibleRowsLocal,
|
||||
};
|
||||
}, [open, rowHeight, scrollTop, sortedFiles, viewportHeight]);
|
||||
|
||||
return {
|
||||
fileListRef,
|
||||
rowHeight,
|
||||
handleFileListScroll,
|
||||
shouldVirtualize,
|
||||
totalHeight,
|
||||
visibleRows,
|
||||
};
|
||||
};
|
||||
83
components/sftp-modal/pathUtils.ts
Normal file
83
components/sftp-modal/pathUtils.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export const isWindowsPath = (path: string): boolean => /^[A-Za-z]:/.test(path);
|
||||
|
||||
export 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 joinPath = (base: string, name: string, isLocalSession: boolean): string => {
|
||||
if (isLocalSession && isWindowsPath(base)) {
|
||||
const normalizedBase = normalizeWindowsRoot(base).replace(/[\\/]+$/, "");
|
||||
return `${normalizedBase}\\${name}`;
|
||||
}
|
||||
if (base === "/") return `/${name}`;
|
||||
return `${base}/${name}`;
|
||||
};
|
||||
|
||||
export const isRootPath = (path: string, isLocalSession: boolean): boolean => {
|
||||
if (isLocalSession && isWindowsPath(path)) {
|
||||
return /^[A-Za-z]:\\?$/.test(path.replace(/\//g, "\\"));
|
||||
}
|
||||
return path === "/";
|
||||
};
|
||||
|
||||
export const getParentPath = (path: string, isLocalSession: boolean): string => {
|
||||
if (isLocalSession && isWindowsPath(path)) {
|
||||
const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, "");
|
||||
const drive = normalized.slice(0, 2);
|
||||
if (/^[A-Za-z]:$/.test(normalized) || /^[A-Za-z]:\\$/.test(normalized)) {
|
||||
return `${drive}\\`;
|
||||
}
|
||||
const rest = normalized.slice(2).replace(/^[\\]+/, "");
|
||||
const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
|
||||
if (parts.length <= 1) return `${drive}\\`;
|
||||
parts.pop();
|
||||
return `${drive}\\${parts.join("\\")}`;
|
||||
}
|
||||
if (path === "/") return "/";
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
parts.pop();
|
||||
return parts.length ? `/${parts.join("/")}` : "/";
|
||||
};
|
||||
|
||||
export const getRootPath = (path: string, isLocalSession: boolean): string => {
|
||||
if (isLocalSession && isWindowsPath(path)) {
|
||||
const drive = path.replace(/\//g, "\\").slice(0, 2);
|
||||
return `${drive}\\`;
|
||||
}
|
||||
return "/";
|
||||
};
|
||||
|
||||
export const getWindowsDrive = (path: string): string | null => {
|
||||
if (!isWindowsPath(path)) return null;
|
||||
const normalized = path.replace(/\//g, "\\");
|
||||
return /^[A-Za-z]:/.test(normalized) ? normalized.slice(0, 2) : null;
|
||||
};
|
||||
|
||||
export const getBreadcrumbs = (path: string, isLocalSession: boolean): string[] => {
|
||||
if (isLocalSession && isWindowsPath(path)) {
|
||||
const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, "");
|
||||
const rest = normalized.slice(2).replace(/^[\\]+/, "");
|
||||
const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
|
||||
return parts;
|
||||
}
|
||||
return path === "/" ? [] : path.split("/").filter(Boolean);
|
||||
};
|
||||
|
||||
export const breadcrumbPathAt = (
|
||||
breadcrumbs: string[],
|
||||
idx: number,
|
||||
currentPath: string,
|
||||
isLocalSession: boolean,
|
||||
): string => {
|
||||
if (isLocalSession) {
|
||||
const drive = getWindowsDrive(currentPath);
|
||||
if (drive) {
|
||||
const rest = breadcrumbs.slice(0, idx + 1).join("\\");
|
||||
return rest ? `${drive}\\${rest}` : `${drive}\\`;
|
||||
}
|
||||
}
|
||||
return "/" + breadcrumbs.slice(0, idx + 1).join("/");
|
||||
};
|
||||
16
components/sftp-modal/utils.ts
Normal file
16
components/sftp-modal/utils.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const formatBytes = (bytes: number | string): string => {
|
||||
const numBytes = typeof bytes === "string" ? parseInt(bytes, 10) : bytes;
|
||||
if (isNaN(numBytes) || numBytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(numBytes) / Math.log(1024));
|
||||
const size = numBytes / Math.pow(1024, i);
|
||||
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
};
|
||||
|
||||
export const formatDate = (dateStr: string | number | undefined): string => {
|
||||
if (!dateStr) return "--";
|
||||
const date = typeof dateStr === "number" ? new Date(dateStr) : new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return String(dateStr);
|
||||
const pad = (value: number) => value.toString().padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
};
|
||||
@@ -31,7 +31,12 @@ const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({
|
||||
// For Windows, first part might be drive letter like "C:"
|
||||
const buildPath = (index: number) => {
|
||||
if (isWindowsPath) {
|
||||
return parts.slice(0, index + 1).join('\\');
|
||||
const builtPath = parts.slice(0, index + 1).join('\\');
|
||||
// If this is just a drive letter (e.g., "C:"), add trailing backslash
|
||||
if (/^[A-Za-z]:$/.test(builtPath)) {
|
||||
return builtPath + '\\';
|
||||
}
|
||||
return builtPath;
|
||||
}
|
||||
return '/' + parts.slice(0, index + 1).join('/');
|
||||
};
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*/
|
||||
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import React,{ useState } from 'react';
|
||||
import React, { memo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Button } from '../ui/button';
|
||||
import { Dialog,DialogContent,DialogDescription,DialogFooter,DialogHeader,DialogTitle } from '../ui/dialog';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
|
||||
interface ConflictItem {
|
||||
transferId: string;
|
||||
@@ -25,7 +25,7 @@ interface SftpConflictDialogProps {
|
||||
formatFileSize: (size: number) => string;
|
||||
}
|
||||
|
||||
export const SftpConflictDialog: React.FC<SftpConflictDialogProps> = ({ conflicts, onResolve, formatFileSize }) => {
|
||||
const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts, onResolve, formatFileSize }) => {
|
||||
const { t } = useI18n();
|
||||
const [applyToAll, setApplyToAll] = useState(false);
|
||||
const conflict = conflicts[0]; // Handle first conflict
|
||||
@@ -135,3 +135,6 @@ export const SftpConflictDialog: React.FC<SftpConflictDialogProps> = ({ conflict
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const SftpConflictDialog = memo(SftpConflictDialogInner);
|
||||
SftpConflictDialog.displayName = 'SftpConflictDialog';
|
||||
|
||||
175
components/sftp/SftpContext.tsx
Normal file
175
components/sftp/SftpContext.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* SftpContext - Provides stable callback references to SFTP components
|
||||
*
|
||||
* This context eliminates props drilling of callback functions through
|
||||
* the component tree, significantly reducing re-renders caused by
|
||||
* callback reference changes.
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useMemo, useSyncExternalStore } from "react";
|
||||
import { Host, SftpFileEntry, SftpFilenameEncoding } from "../../types";
|
||||
|
||||
// Types for the context
|
||||
export interface SftpPaneCallbacks {
|
||||
onConnect: (host: Host | "local") => void;
|
||||
onDisconnect: () => void;
|
||||
onNavigateTo: (path: string) => void;
|
||||
onNavigateUp: () => void;
|
||||
onRefresh: () => void;
|
||||
onSetFilenameEncoding: (encoding: SftpFilenameEncoding) => void;
|
||||
onOpenEntry: (entry: SftpFileEntry) => void;
|
||||
onToggleSelection: (fileName: string, multiSelect: boolean) => void;
|
||||
onRangeSelect: (fileNames: string[]) => void;
|
||||
onClearSelection: () => void;
|
||||
onSetFilter: (filter: string) => void;
|
||||
onCreateDirectory: (name: string) => Promise<void>;
|
||||
onCreateFile: (name: string) => Promise<void>;
|
||||
onDeleteFiles: (fileNames: string[]) => Promise<void>;
|
||||
onRenameFile: (oldName: string, newName: string) => Promise<void>;
|
||||
onCopyToOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
|
||||
onReceiveFromOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
|
||||
onEditPermissions?: (file: SftpFileEntry) => void;
|
||||
// File operations
|
||||
onEditFile?: (entry: SftpFileEntry) => void;
|
||||
onOpenFile?: (entry: SftpFileEntry) => void;
|
||||
onOpenFileWith?: (entry: SftpFileEntry) => void; // Always show opener dialog
|
||||
onDownloadFile?: (entry: SftpFileEntry) => void; // Download to local filesystem
|
||||
// External file upload (supports folders via DataTransfer)
|
||||
onUploadExternalFiles?: (dataTransfer: DataTransfer) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface SftpDragCallbacks {
|
||||
onDragStart: (files: { name: string; isDirectory: boolean }[], side: "left" | "right") => void;
|
||||
onDragEnd: () => void;
|
||||
}
|
||||
|
||||
// Store for activeTabId - allows subscription without re-rendering parent
|
||||
type ActiveTabStore = {
|
||||
left: string | null;
|
||||
right: string | null;
|
||||
};
|
||||
|
||||
type ActiveTabListener = () => void;
|
||||
|
||||
let activeTabState: ActiveTabStore = { left: null, right: null };
|
||||
const activeTabListeners = new Set<ActiveTabListener>();
|
||||
|
||||
export const activeTabStore = {
|
||||
getSnapshot: () => activeTabState,
|
||||
getLeftActiveTabId: () => activeTabState.left,
|
||||
getRightActiveTabId: () => activeTabState.right,
|
||||
setActiveTabId: (side: "left" | "right", tabId: string | null) => {
|
||||
if (activeTabState[side] !== tabId) {
|
||||
activeTabState = { ...activeTabState, [side]: tabId };
|
||||
activeTabListeners.forEach((listener) => listener());
|
||||
}
|
||||
},
|
||||
subscribe: (listener: ActiveTabListener) => {
|
||||
activeTabListeners.add(listener);
|
||||
return () => activeTabListeners.delete(listener);
|
||||
},
|
||||
};
|
||||
|
||||
// Hook to subscribe to active tab changes for a specific side
|
||||
export const useActiveTabId = (side: "left" | "right"): string | null => {
|
||||
return useSyncExternalStore(
|
||||
activeTabStore.subscribe,
|
||||
() => (side === "left" ? activeTabStore.getLeftActiveTabId() : activeTabStore.getRightActiveTabId()),
|
||||
() => (side === "left" ? activeTabStore.getLeftActiveTabId() : activeTabStore.getRightActiveTabId()),
|
||||
);
|
||||
};
|
||||
|
||||
// Hook to check if a specific pane is active (for CSS control)
|
||||
export const useIsPaneActive = (side: "left" | "right", paneId: string): boolean => {
|
||||
const activeTabId = useActiveTabId(side);
|
||||
return activeTabId === paneId || (activeTabId === null && paneId !== null);
|
||||
};
|
||||
|
||||
export interface SftpContextValue {
|
||||
// Hosts list for connection picker
|
||||
hosts: Host[];
|
||||
|
||||
// Drag state (shared between panes)
|
||||
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
|
||||
dragCallbacks: SftpDragCallbacks;
|
||||
|
||||
// Callbacks for each side
|
||||
leftCallbacks: SftpPaneCallbacks;
|
||||
rightCallbacks: SftpPaneCallbacks;
|
||||
|
||||
// Settings
|
||||
showHiddenFiles: boolean;
|
||||
}
|
||||
|
||||
const SftpContext = createContext<SftpContextValue | null>(null);
|
||||
|
||||
export const useSftpContext = () => {
|
||||
const context = useContext(SftpContext);
|
||||
if (!context) {
|
||||
throw new Error("useSftpContext must be used within SftpContextProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Hook to get callbacks for a specific side
|
||||
export const useSftpPaneCallbacks = (side: "left" | "right"): SftpPaneCallbacks => {
|
||||
const context = useSftpContext();
|
||||
return side === "left" ? context.leftCallbacks : context.rightCallbacks;
|
||||
};
|
||||
|
||||
// Hook to get drag-related values
|
||||
export const useSftpDrag = () => {
|
||||
const context = useSftpContext();
|
||||
return {
|
||||
draggedFiles: context.draggedFiles,
|
||||
...context.dragCallbacks,
|
||||
};
|
||||
};
|
||||
|
||||
// Hook to get hosts
|
||||
export const useSftpHosts = () => {
|
||||
const context = useSftpContext();
|
||||
return context.hosts;
|
||||
};
|
||||
|
||||
// Hook to get showHiddenFiles setting
|
||||
export const useSftpShowHiddenFiles = (): boolean => {
|
||||
const context = useSftpContext();
|
||||
return context.showHiddenFiles;
|
||||
};
|
||||
|
||||
interface SftpContextProviderProps {
|
||||
hosts: Host[];
|
||||
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
|
||||
dragCallbacks: SftpDragCallbacks;
|
||||
leftCallbacks: SftpPaneCallbacks;
|
||||
rightCallbacks: SftpPaneCallbacks;
|
||||
showHiddenFiles: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
|
||||
hosts,
|
||||
draggedFiles,
|
||||
dragCallbacks,
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
showHiddenFiles,
|
||||
children,
|
||||
}) => {
|
||||
// Memoize the context value to prevent unnecessary re-renders
|
||||
// Note: The callbacks objects should be stable (created with useMemo in parent)
|
||||
const value = useMemo<SftpContextValue>(
|
||||
() => ({
|
||||
hosts,
|
||||
draggedFiles,
|
||||
dragCallbacks,
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
showHiddenFiles,
|
||||
}),
|
||||
[hosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks, showHiddenFiles],
|
||||
);
|
||||
|
||||
return <SftpContext.Provider value={value}>{children}</SftpContext.Provider>;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user