Compare commits
365 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec9be922cb | ||
|
|
6e961b0efd | ||
|
|
d3fe2f9f53 | ||
|
|
88760b763e | ||
|
|
6dfe543ab5 | ||
|
|
c000996cb4 | ||
|
|
f70b604996 | ||
|
|
b973382f9f | ||
|
|
eeb300295d | ||
|
|
be36ccd167 | ||
|
|
71b13a77a3 | ||
|
|
808d021ebe | ||
|
|
d03117733d | ||
|
|
1816c3d0df | ||
|
|
b192ee1764 | ||
|
|
0b9cb86c4e | ||
|
|
bcd44f0177 | ||
|
|
d8d29d1709 | ||
|
|
0820569166 | ||
|
|
545506ac86 | ||
|
|
29fca33ffd | ||
|
|
216ea7f177 | ||
|
|
b280caded2 | ||
|
|
2d4f260f0b | ||
|
|
e69bc53aa4 | ||
|
|
a55da77471 | ||
|
|
33d3a86d83 | ||
|
|
f73c060351 | ||
|
|
304ebf1e3b | ||
|
|
2788dbdff5 | ||
|
|
84fe0134c9 | ||
|
|
06dc7400f2 | ||
|
|
d1a59ed40c | ||
|
|
f90aa81b2c | ||
|
|
950819746e | ||
|
|
4a3a4b9d9b | ||
|
|
726ff82a9e | ||
|
|
7e8682d10d | ||
|
|
b2447b06d2 | ||
|
|
ed8a6a6cf2 | ||
|
|
f0f5803a6d | ||
|
|
f53bc05cb3 | ||
|
|
3136100514 | ||
|
|
847df7a023 | ||
|
|
150724fc7c | ||
|
|
8949394756 | ||
|
|
7f3214e088 | ||
|
|
eaab7d72cb | ||
|
|
63a7c06037 | ||
|
|
72887c35b5 | ||
|
|
4373a8ce14 | ||
|
|
007fe47310 | ||
|
|
9109fc2f6e | ||
|
|
961f79d3d8 | ||
|
|
494fc27454 | ||
|
|
a85324c9fb | ||
|
|
860739bb97 | ||
|
|
a6494bfb78 | ||
|
|
1fa11c2c2d | ||
|
|
35b8990a9c | ||
|
|
67536c9424 | ||
|
|
4dbbb96e4d | ||
|
|
5cb8b348b3 | ||
|
|
06efcfe384 | ||
|
|
4877c934fa | ||
|
|
c542520dee | ||
|
|
0b61d10953 | ||
|
|
347361bc7b | ||
|
|
746c336ee1 | ||
|
|
6373762399 | ||
|
|
27b8d4a410 | ||
|
|
27773c58db | ||
|
|
ecb48e89a5 | ||
|
|
d609d8edb3 | ||
|
|
5f91fbbab8 | ||
|
|
89c3c7f83a | ||
|
|
ee391bcc32 | ||
|
|
26fd5023f5 | ||
|
|
49543abcff | ||
|
|
6bab971de8 | ||
|
|
392a57f95b | ||
|
|
85e3e8b26f | ||
|
|
9747498833 | ||
|
|
520e2c3f9d | ||
|
|
cb5333e336 | ||
|
|
d3153148c8 | ||
|
|
899cb109b4 | ||
|
|
d031bf355d | ||
|
|
489b7711f5 | ||
|
|
65877fd912 | ||
|
|
117ec260b6 | ||
|
|
c76ff7ac9a | ||
|
|
17da21b1cd | ||
|
|
733e36a728 | ||
|
|
35174246cc | ||
|
|
ab13670eaa | ||
|
|
4f3e39e378 | ||
|
|
2281d1df68 | ||
|
|
e570185e2f | ||
|
|
12884165b5 | ||
|
|
11f82defc3 | ||
|
|
ac9175b770 | ||
|
|
71c6f68934 | ||
|
|
01bee794ee | ||
|
|
29dc01306d | ||
|
|
0dcfd1489b | ||
|
|
72f61141c4 | ||
|
|
37150ea379 | ||
|
|
5706af3f33 | ||
|
|
6871c82ab8 | ||
|
|
b90ff692eb | ||
|
|
ce71725dba | ||
|
|
fb5c4aaa58 | ||
|
|
45c059ae53 | ||
|
|
1d67eb40c4 | ||
|
|
c6e3989a1b | ||
|
|
ace081414f | ||
|
|
049a609bca | ||
|
|
44409e6d32 | ||
|
|
5246489ef9 | ||
|
|
83d0d917ad | ||
|
|
73557d0af1 | ||
|
|
aa67455c8c | ||
|
|
c7d2482996 | ||
|
|
d2391f5472 | ||
|
|
9be84c71f5 | ||
|
|
effb98b91a | ||
|
|
77fd7a42a8 | ||
|
|
a86a5e6839 | ||
|
|
7ed4940e18 | ||
|
|
410d1ef097 | ||
|
|
c386ee2e2e | ||
|
|
4c08888b60 | ||
|
|
2ea4c88680 | ||
|
|
0ba75f9af0 | ||
|
|
4610348b0d | ||
|
|
8d11b71bc1 | ||
|
|
6683001032 | ||
|
|
3b313ff933 | ||
|
|
eaa27461fa | ||
|
|
20b65366be | ||
|
|
b8c08ba3ca | ||
|
|
981c5de90d | ||
|
|
0097d65a6e | ||
|
|
e4aa03c474 | ||
|
|
b94386236c | ||
|
|
0883585704 | ||
|
|
5b38f4663d | ||
|
|
a6a6dd1aac | ||
|
|
506c60ea44 | ||
|
|
9d9b24fe7b | ||
|
|
584b9859ef | ||
|
|
b005065949 | ||
|
|
a4fdb6758d | ||
|
|
a2b5c9d067 | ||
|
|
a451fd8811 | ||
|
|
49cef792a8 | ||
|
|
62511ceb21 | ||
|
|
00cbb05d71 | ||
|
|
3497614165 | ||
|
|
b652b836a7 | ||
|
|
cd604107ee | ||
|
|
adc4c25dc9 | ||
|
|
eaaf0265f8 | ||
|
|
f4d833497d | ||
|
|
75871717a9 | ||
|
|
f6619c28ed | ||
|
|
ca77315257 | ||
|
|
3ab681e63b | ||
|
|
2ee7781b82 | ||
|
|
95780a29dc | ||
|
|
060c35f66a | ||
|
|
ee5d3827d5 | ||
|
|
f06333b95e | ||
|
|
a07c644ec8 | ||
|
|
1d5c40c665 | ||
|
|
ab0c4ede7e | ||
|
|
cf86c166cf | ||
|
|
686a707fef | ||
|
|
159a5eccd2 | ||
|
|
8a6e915dd7 | ||
|
|
474a8bae87 | ||
|
|
6c2e902007 | ||
|
|
0e61262bc0 | ||
|
|
200d710cc9 | ||
|
|
a7873fc457 | ||
|
|
1286975a4b | ||
|
|
2933e108bc | ||
|
|
8278bfde0f | ||
|
|
d0b941eabf | ||
|
|
a98821acb7 | ||
|
|
4edc28113e | ||
|
|
adc712e121 | ||
|
|
81d1b4602d | ||
|
|
540aabb676 | ||
|
|
8d014193ca | ||
|
|
892c6da44d | ||
|
|
0ff6273882 | ||
|
|
92556d824e | ||
|
|
f3676734a7 | ||
|
|
3d1db751ca | ||
|
|
35f531bb55 | ||
|
|
71ff9953bd | ||
|
|
72635eeaeb | ||
|
|
ec17abb507 | ||
|
|
fe7f760a47 | ||
|
|
ab70a406c9 | ||
|
|
7e73da5557 | ||
|
|
97474acb89 | ||
|
|
f59c83be2a | ||
|
|
cba1803230 | ||
|
|
e50a087a07 | ||
|
|
5839c00b67 | ||
|
|
f5cb590e0c | ||
|
|
237b4404dc | ||
|
|
1c10076866 | ||
|
|
eb80b8f60c | ||
|
|
f38515d383 | ||
|
|
64a1b8de3e | ||
|
|
c1eb19a739 | ||
|
|
7342b4a872 | ||
|
|
db682d7857 | ||
|
|
c6491b71c9 | ||
|
|
8667d0d535 | ||
|
|
2bcb081486 | ||
|
|
fefda0015e | ||
|
|
5fc5471685 | ||
|
|
4601372ce6 | ||
|
|
6491ab38bc | ||
|
|
6476bc95df | ||
|
|
7ef1059f7b | ||
|
|
fd78fc7baa | ||
|
|
5787a6ac6a | ||
|
|
787760d02c | ||
|
|
1b2c3e30a2 | ||
|
|
ae7495baf9 | ||
|
|
2bcea8386f | ||
|
|
be7d29f45e | ||
|
|
4a762097ee | ||
|
|
c91cf1d2f8 | ||
|
|
0a43220057 | ||
|
|
288ea06c04 | ||
|
|
9ca7e39748 | ||
|
|
1cbbb61afa | ||
|
|
cf352502f8 | ||
|
|
72d270580f | ||
|
|
f0cfcbc560 | ||
|
|
f8262a64ab | ||
|
|
a24e27586a | ||
|
|
ca24d3861c | ||
|
|
eb3b99b164 | ||
|
|
681f4cb3df | ||
|
|
6fae312981 | ||
|
|
ed199eae8c | ||
|
|
e38af76bfd | ||
|
|
1726917db0 | ||
|
|
1712762305 | ||
|
|
5d75f1acd4 | ||
|
|
18b77f9a87 | ||
|
|
ade95c1cab | ||
|
|
7e8893003a | ||
|
|
f42cd8cdd1 | ||
|
|
2d34e162c0 | ||
|
|
cdee9c7867 | ||
|
|
45de960618 | ||
|
|
2669fc57c4 | ||
|
|
10ede85ae3 | ||
|
|
21ccc7906b | ||
|
|
28d9a8e4db | ||
|
|
090ab82bde | ||
|
|
157c73536b | ||
|
|
d74f47c38f | ||
|
|
f6cf915792 | ||
|
|
9d3b0056a5 | ||
|
|
ce16bd449f | ||
|
|
e645c5ee53 | ||
|
|
07ac90b110 | ||
|
|
e8faecc37a | ||
|
|
166633414a | ||
|
|
9ecefc6959 | ||
|
|
afcc33b7fb | ||
|
|
4c2702b7ff | ||
|
|
fdcd8547d3 | ||
|
|
16ae3ff2ed | ||
|
|
80e6e3c4c1 | ||
|
|
b58120998f | ||
|
|
c671943d49 | ||
|
|
664fe90c10 | ||
|
|
2215d52b09 | ||
|
|
c9059a4f29 | ||
|
|
4445bf578c | ||
|
|
f719350507 | ||
|
|
cfaee48553 | ||
|
|
1f05fe3efa | ||
|
|
e9c3b82c16 | ||
|
|
83fce70b20 | ||
|
|
d36c8bcbea | ||
|
|
5346752994 | ||
|
|
d267c4b6fc | ||
|
|
1a1da02e92 | ||
|
|
1adcffa7a8 | ||
|
|
7a2bedc4f4 | ||
|
|
5e753334ed | ||
|
|
a488bc466b | ||
|
|
2748cd5363 | ||
|
|
033165561d | ||
|
|
8e514f1008 | ||
|
|
0acd39603f | ||
|
|
4bdb0bbbf7 | ||
|
|
6b2c58f8f0 | ||
|
|
c0199c43cf | ||
|
|
7940b9a0a7 | ||
|
|
920914e3ee | ||
|
|
b5feb888d2 | ||
|
|
62d19974c9 | ||
|
|
932bb5032d | ||
|
|
3020d422fe | ||
|
|
bb526601bb | ||
|
|
d349c31cd6 | ||
|
|
8313cf780d | ||
|
|
29c0cc30a4 | ||
|
|
ee80048ece | ||
|
|
ec5dfcf1fa | ||
|
|
ab3b2c2055 | ||
|
|
ca8691d53d | ||
|
|
dddaf1a1cd | ||
|
|
d2469f93c8 | ||
|
|
521c9141e9 | ||
|
|
d068cc99c8 | ||
|
|
a89130d732 | ||
|
|
dcea13172d | ||
|
|
0c420d99ed | ||
|
|
a3ffefa067 | ||
|
|
80b6485d02 | ||
|
|
884d643150 | ||
|
|
96fde364df | ||
|
|
920d829299 | ||
|
|
4f7d3bccc8 | ||
|
|
a3eb6ab26c | ||
|
|
c402c45e1d | ||
|
|
d4978f267a | ||
|
|
41ee8e7160 | ||
|
|
3d9f153fe9 | ||
|
|
4332537da0 | ||
|
|
4284b9a9dc | ||
|
|
3afe6ed489 | ||
|
|
1d06e48966 | ||
|
|
3ce2481753 | ||
|
|
ce61c28162 | ||
|
|
55463441f0 | ||
|
|
c17833ae31 | ||
|
|
56bab12b5c | ||
|
|
d1482e47d6 | ||
|
|
4ee3c63768 | ||
|
|
2b067a9aae | ||
|
|
2d4a3a5602 | ||
|
|
6c57ce7b28 | ||
|
|
6a2bd0a6a1 | ||
|
|
0c4900c73d | ||
|
|
3174e9ad27 | ||
|
|
f517c85d07 | ||
|
|
0b9e3c430d | ||
|
|
1c526e6965 | ||
|
|
70ff5299b6 | ||
|
|
3ef53faef5 |
12
.github/scripts/generate-release-note.js
vendored
@@ -46,6 +46,10 @@ const tag = (process.env.GITHUB_REF_NAME && /^v\d+\.\d+\.\d+/.test(process.env.G
|
||||
const baseUrl = `https://github.com/${repo}/releases/download/${tag}`;
|
||||
|
||||
// Filename patterns based on electron-builder.config.cjs artifactName: '${productName}-${version}-${os}-${arch}.${ext}'
|
||||
// Note: electron-builder uses different arch names for Linux packages:
|
||||
// - AppImage: x64 -> x86_64, arm64 -> arm64
|
||||
// - deb: x64 -> amd64, arm64 -> arm64
|
||||
// - rpm: x64 -> x86_64, arm64 -> aarch64
|
||||
const files = {
|
||||
mac: {
|
||||
arm64: `Netcatty-${version}-mac-arm64.dmg`,
|
||||
@@ -57,16 +61,16 @@ const files = {
|
||||
},
|
||||
linux: {
|
||||
appimage: {
|
||||
x64: `Netcatty-${version}-linux-x64.AppImage`,
|
||||
x64: `Netcatty-${version}-linux-x86_64.AppImage`,
|
||||
arm64: `Netcatty-${version}-linux-arm64.AppImage`
|
||||
},
|
||||
deb: {
|
||||
x64: `Netcatty-${version}-linux-x64.deb`,
|
||||
x64: `Netcatty-${version}-linux-amd64.deb`,
|
||||
arm64: `Netcatty-${version}-linux-arm64.deb`
|
||||
},
|
||||
rpm: {
|
||||
x64: `Netcatty-${version}-linux-x64.rpm`,
|
||||
arm64: `Netcatty-${version}-linux-arm64.rpm`
|
||||
x64: `Netcatty-${version}-linux-x86_64.rpm`,
|
||||
arm64: `Netcatty-${version}-linux-aarch64.rpm`
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
156
.github/workflows/build.yml
vendored
@@ -13,12 +13,18 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build-${{ matrix.os }}
|
||||
name: build-${{ matrix.name }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, windows-latest, ubuntu-latest]
|
||||
include:
|
||||
- name: macos
|
||||
os: macos-latest
|
||||
pack_script: pack:mac
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
pack_script: pack:win
|
||||
env:
|
||||
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
|
||||
@@ -31,7 +37,7 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install deps
|
||||
@@ -50,43 +56,148 @@ jobs:
|
||||
echo "Setting version to ${VERSION}"
|
||||
npm pkg set version="${VERSION}"
|
||||
|
||||
- name: Build package (macOS)
|
||||
if: matrix.os == 'macos-latest'
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:mac
|
||||
|
||||
- name: Build package (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
- name: Build package
|
||||
env:
|
||||
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
|
||||
# macOS code signing & notarization (only for macOS builds)
|
||||
CSC_LINK: ${{ matrix.name == 'macos' && secrets.MAC_CSC_LINK || '' }}
|
||||
CSC_KEY_PASSWORD: ${{ matrix.name == 'macos' && secrets.MAC_CSC_KEY_PASSWORD || '' }}
|
||||
APPLE_ID: ${{ matrix.name == 'macos' && secrets.APPLE_ID || '' }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ matrix.name == 'macos' && secrets.APPLE_APP_SPECIFIC_PASSWORD || '' }}
|
||||
APPLE_TEAM_ID: ${{ matrix.name == 'macos' && secrets.APPLE_TEAM_ID || '' }}
|
||||
run: npm run ${{ matrix.pack_script }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: netcatty-${{ matrix.os }}
|
||||
name: netcatty-${{ matrix.name }}
|
||||
path: |
|
||||
release/*.dmg
|
||||
release/*.zip
|
||||
release/*.exe
|
||||
release/*.msi
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
release/*.tar.gz
|
||||
release/*.yml
|
||||
release/*.blockmap
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Linux x64 — pin to ubuntu-22.04 for broader glibc compatibility.
|
||||
# ubuntu-latest (24.04) links native modules against glibc 2.39 which
|
||||
# can cause dlopen failures on some distros. 22.04 uses glibc 2.35,
|
||||
# compatible with most current Linux distributions including Arch.
|
||||
# See #264.
|
||||
build-linux-x64:
|
||||
name: build-linux-x64
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
|
||||
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Set version
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
else
|
||||
VERSION="${GITHUB_SHA:0:7}"
|
||||
fi
|
||||
echo "Setting version to ${VERSION}"
|
||||
npm pkg set version="${VERSION}"
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:linux-x64
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: netcatty-linux-x64
|
||||
path: |
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
release/*.yml
|
||||
release/*.blockmap
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Dedicated job for Linux ARM64 — builds inside Debian Bullseye (GLIBC 2.31)
|
||||
# to ensure compatibility with older distros like UOS/Deepin (GLIBC 2.28).
|
||||
# Key: GLIBC < 2.34 avoids the libpthread-merge symbol requirement.
|
||||
build-linux-arm64:
|
||||
name: build-linux-arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
container:
|
||||
image: debian:bullseye
|
||||
env:
|
||||
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
|
||||
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y curl build-essential python3 git libfuse2 file rpm
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
apt-get install -y nodejs
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Set version
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
else
|
||||
VERSION="${GITHUB_SHA:0:7}"
|
||||
fi
|
||||
echo "Setting version to ${VERSION}"
|
||||
npm pkg set version="${VERSION}"
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
npm_config_arch: arm64
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:linux-arm64
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: netcatty-linux-arm64
|
||||
path: |
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
release/*.yml
|
||||
release/*.blockmap
|
||||
if-no-files-found: ignore
|
||||
|
||||
release:
|
||||
name: release
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
needs: [build, build-linux-x64, build-linux-arm64]
|
||||
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -116,10 +227,13 @@ jobs:
|
||||
body_path: release_notes.md
|
||||
files: |
|
||||
artifacts/*.dmg
|
||||
artifacts/*.zip
|
||||
artifacts/*.exe
|
||||
artifacts/*.AppImage
|
||||
artifacts/*.deb
|
||||
artifacts/*.rpm
|
||||
artifacts/*.yml
|
||||
artifacts/*.blockmap
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: false
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
|
||||
42
.github/workflows/sync.yml
vendored
@@ -1,42 +0,0 @@
|
||||
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 }}
|
||||
27
.gitignore
vendored
@@ -17,7 +17,8 @@ dist-ssr
|
||||
*.tsbuildinfo
|
||||
coverage
|
||||
/.vite
|
||||
/build
|
||||
/build/*
|
||||
!/build/icons
|
||||
/electron/native/**/build
|
||||
/release
|
||||
/out
|
||||
@@ -34,5 +35,25 @@ coverage
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Claude Code local settings
|
||||
/.claude/settings.local.json
|
||||
# Claude Code
|
||||
/.claude/
|
||||
/CLAUDE.md
|
||||
|
||||
# AI / Superpowers generated docs (local only)
|
||||
/docs/superpowers/
|
||||
|
||||
# Dev-only electron-updater test config (not for production)
|
||||
/dev-app-update.yml
|
||||
|
||||
# Test suite (local only, not committed)
|
||||
/tests/
|
||||
/vitest.config.ts
|
||||
|
||||
# Serena MCP project config (local only)
|
||||
/.serena/
|
||||
|
||||
# Windows VS Build environment scripts (local dev only)
|
||||
Directory.Build.props
|
||||
Directory.Build.targets
|
||||
build_with_vs.bat
|
||||
build_with_vs2022.bat
|
||||
|
||||
348
App.tsx
@@ -14,6 +14,8 @@ import { initializeUIFonts } from './application/state/uiFontStore';
|
||||
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
|
||||
import { matchesKeyBinding } from './domain/models';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { applySyncPayload } from './domain/syncPayload';
|
||||
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
|
||||
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
|
||||
import { TopTabs } from './components/TopTabs';
|
||||
import { Button } from './components/ui/button';
|
||||
@@ -166,8 +168,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
|
||||
|
||||
const {
|
||||
theme,
|
||||
setTheme,
|
||||
resolvedTheme,
|
||||
setTerminalThemeId,
|
||||
currentTerminalTheme,
|
||||
terminalFontFamilyId,
|
||||
@@ -178,6 +180,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
isHotkeyRecording,
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
sessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
@@ -261,7 +269,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const isMacClient = typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
|
||||
|
||||
// Get port forwarding rules and import function
|
||||
const { rules: portForwardingRules, importRules: importPortForwardingRules } = usePortForwardingState();
|
||||
const { rules: portForwardingRules, importRules: importPortForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
|
||||
|
||||
const portForwardingRulesForSync = useMemo(
|
||||
() =>
|
||||
@@ -281,20 +289,14 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
identities,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
portForwardingRules: portForwardingRulesForSync,
|
||||
knownHosts,
|
||||
onApplyPayload: (payload) => {
|
||||
importDataFromString(JSON.stringify({
|
||||
hosts: payload.hosts,
|
||||
keys: payload.keys,
|
||||
identities: payload.identities,
|
||||
snippets: payload.snippets,
|
||||
customGroups: payload.customGroups,
|
||||
}));
|
||||
|
||||
if (payload.portForwardingRules) {
|
||||
importPortForwardingRules(payload.portForwardingRules);
|
||||
}
|
||||
applySyncPayload(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -309,10 +311,15 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, [handleSyncNow]);
|
||||
|
||||
// Update check hook - checks for new versions on startup
|
||||
const { updateState, openReleasePage, dismissUpdate } = useUpdateCheck();
|
||||
const { updateState, dismissUpdate, openReleasePage, installUpdate } = useUpdateCheck();
|
||||
|
||||
// Show toast notification when update is available
|
||||
// Window controls - must be before update toast effect which uses openSettingsWindow
|
||||
const { openSettingsWindow } = useWindowControls();
|
||||
|
||||
// Show toast notification when update is available (only when auto-download is idle)
|
||||
useEffect(() => {
|
||||
// Skip "update available" toast if auto-download has already started or completed
|
||||
if (updateState.autoDownloadStatus !== 'idle') return;
|
||||
if (updateState.hasUpdate && updateState.latestRelease) {
|
||||
const version = updateState.latestRelease.version;
|
||||
toast.info(
|
||||
@@ -321,18 +328,55 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
title: t('update.available.title'),
|
||||
duration: 8000, // Show longer for update notifications
|
||||
onClick: () => {
|
||||
openReleasePage();
|
||||
void openSettingsWindow();
|
||||
// Dismiss the update so the toast doesn't re-fire on every render.
|
||||
// On unsupported platforms (where autoDownloadStatus stays 'idle')
|
||||
// this is the only way to suppress the notification for this version.
|
||||
// On supported platforms this toast only shows before auto-download
|
||||
// starts, and the Settings window's own useUpdateCheck will pick up
|
||||
// the download state via IPC events independently of the dismiss.
|
||||
dismissUpdate();
|
||||
},
|
||||
actionLabel: t('update.downloadNow'),
|
||||
actionLabel: t('update.viewInSettings'),
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [updateState.hasUpdate, updateState.latestRelease, t, openReleasePage, dismissUpdate]);
|
||||
}, [updateState.hasUpdate, updateState.latestRelease, updateState.autoDownloadStatus, t, openSettingsWindow, dismissUpdate]);
|
||||
|
||||
// Track previous autoDownloadStatus so toast effects fire only on actual transitions,
|
||||
// not when unrelated deps (openReleasePage, installUpdate) change their reference.
|
||||
const prevAutoDownloadStatusRef = useRef(updateState.autoDownloadStatus);
|
||||
useEffect(() => {
|
||||
const prev = prevAutoDownloadStatusRef.current;
|
||||
prevAutoDownloadStatusRef.current = updateState.autoDownloadStatus;
|
||||
if (prev === updateState.autoDownloadStatus) return;
|
||||
|
||||
if (updateState.autoDownloadStatus === 'ready') {
|
||||
const version = updateState.latestRelease?.version ?? '';
|
||||
toast.info(
|
||||
t('update.readyToInstall.message', { version }),
|
||||
{
|
||||
title: t('update.readyToInstall.title'),
|
||||
duration: 0,
|
||||
actionLabel: t('update.restartNow'),
|
||||
onClick: () => installUpdate(),
|
||||
}
|
||||
);
|
||||
} else if (updateState.autoDownloadStatus === 'error') {
|
||||
toast.error(
|
||||
t('update.downloadFailed.message'),
|
||||
{
|
||||
title: t('update.downloadFailed.title'),
|
||||
actionLabel: t('update.openReleases'),
|
||||
onClick: () => openReleasePage(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openReleasePage]);
|
||||
|
||||
// Memoize keys for port forwarding to prevent unnecessary re-renders
|
||||
const portForwardingKeys = useMemo(
|
||||
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
|
||||
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase, })),
|
||||
[keys]
|
||||
);
|
||||
|
||||
@@ -342,6 +386,150 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
keys: portForwardingKeys,
|
||||
});
|
||||
|
||||
// Sync tray menu data + handle tray actions
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.updateTrayMenuData) return;
|
||||
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
|
||||
const sessionsForTray = sessions.map((s) => {
|
||||
const ws = s.workspaceId ? workspaces.find((w) => w.id === s.workspaceId) : undefined;
|
||||
return {
|
||||
id: s.id,
|
||||
label: s.hostname,
|
||||
hostLabel: s.hostLabel,
|
||||
status: s.status,
|
||||
workspaceId: s.workspaceId,
|
||||
workspaceTitle: ws?.title,
|
||||
};
|
||||
});
|
||||
|
||||
void bridge.updateTrayMenuData({
|
||||
sessions: sessionsForTray,
|
||||
portForwardRules: portForwardingRules,
|
||||
});
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [sessions, portForwardingRules, workspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onTrayFocusSession || !bridge?.onTrayTogglePortForward) return;
|
||||
|
||||
const unsubscribeFocus = bridge.onTrayFocusSession((sessionId) => {
|
||||
// Find the session to check if it belongs to a workspace
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (session?.workspaceId) {
|
||||
// Session is in a workspace - navigate to workspace and focus the session
|
||||
setActiveTabId(session.workspaceId);
|
||||
setWorkspaceFocusedSession(session.workspaceId, sessionId);
|
||||
} else {
|
||||
// Standalone session or session not found - just set tab
|
||||
setActiveTabId(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
const unsubscribeToggle = bridge.onTrayTogglePortForward((ruleId, start) => {
|
||||
const rule = portForwardingRules.find((r) => r.id === ruleId);
|
||||
if (!rule) return;
|
||||
const host = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
|
||||
if (!host) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
|
||||
const keysForPf = keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase }));
|
||||
if (start) {
|
||||
void startTunnel(rule, host, keysForPf, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
} else {
|
||||
void stopTunnel(ruleId);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeFocus?.();
|
||||
unsubscribeToggle?.();
|
||||
};
|
||||
}, [hosts, keys, portForwardingRules, sessions, setActiveTabId, setWorkspaceFocusedSession, startTunnel, stopTunnel, t]);
|
||||
|
||||
// Tray panel actions (from main process)
|
||||
useEffect(() => {
|
||||
const handlerJump = (sessionId: string) => {
|
||||
// Find the session to check if it belongs to a workspace
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (session?.workspaceId) {
|
||||
// Session is in a workspace - navigate to workspace and focus the session
|
||||
setActiveTabId(session.workspaceId);
|
||||
setWorkspaceFocusedSession(session.workspaceId, sessionId);
|
||||
} else {
|
||||
// Standalone session or session not found - just set tab
|
||||
setActiveTabId(sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
const handlerConnect = (hostId: string) => {
|
||||
const host = hosts.find((h) => h.id === hostId);
|
||||
if (!host) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
|
||||
const { username, hostname: localHost } = systemInfoRef.current;
|
||||
if (host.protocol === 'serial') {
|
||||
const portName = host.hostname.split('/').pop() || host.hostname;
|
||||
const sessionId = connectToHost(host);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: host.hostname,
|
||||
username,
|
||||
protocol: 'serial',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
|
||||
const resolvedAuth = resolveHostAuth({ host, keys, identities });
|
||||
const sessionId = connectToHost(host);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: resolvedAuth.username || 'root',
|
||||
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
};
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onTrayPanelJumpToSession || !bridge?.onTrayPanelConnectToHost) return;
|
||||
|
||||
const unsubscribeJump = bridge.onTrayPanelJumpToSession(handlerJump);
|
||||
const unsubscribeConnect = bridge.onTrayPanelConnectToHost(handlerConnect);
|
||||
return () => {
|
||||
unsubscribeJump?.();
|
||||
unsubscribeConnect?.();
|
||||
};
|
||||
}, [addConnectionLog, connectToHost, hosts, identities, keys, sessions, setActiveTabId, setWorkspaceFocusedSession, t]);
|
||||
|
||||
// Keyboard-interactive authentication (2FA/MFA) event listener
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -649,11 +837,15 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Note: xterm terminal handles its own key interception via attachCustomKeyEventHandler
|
||||
const target = e.target as HTMLElement;
|
||||
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
|
||||
const isMonacoElement =
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
|
||||
const isXtermInput =
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
|
||||
|
||||
if (isFormElement && !isXtermInput && e.key !== 'Escape') {
|
||||
// Monaco is not always contentEditable/input, so treat it as an editor surface.
|
||||
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -677,6 +869,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const keyStr = isMac ? binding.mac : binding.pc;
|
||||
if (matchesKeyBinding(e, keyStr, isMac)) {
|
||||
if (HOTKEY_DEBUG) console.log('[Hotkeys] Matched binding:', binding.action, keyStr);
|
||||
// SFTP shortcuts are handled by SFTP-specific hooks.
|
||||
if (binding.category === 'sftp') {
|
||||
continue;
|
||||
}
|
||||
// Terminal-specific actions should be handled by the terminal
|
||||
// Don't handle them at app level
|
||||
const terminalActions = ['copy', 'paste', 'selectAll', 'clearBuffer', 'searchTerminal'];
|
||||
@@ -766,7 +962,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Wrapper to create local terminal with logging
|
||||
const handleCreateLocalTerminal = useCallback(() => {
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
const sessionId = createLocalTerminal();
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: '',
|
||||
hostLabel: 'Local Terminal',
|
||||
hostname: 'localhost',
|
||||
@@ -777,7 +975,6 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
localHostname: hostname,
|
||||
saved: false,
|
||||
});
|
||||
createLocalTerminal();
|
||||
}, [addConnectionLog, createLocalTerminal]);
|
||||
|
||||
// Wrapper to connect to host with logging
|
||||
@@ -787,7 +984,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Handle serial hosts separately
|
||||
if (host.protocol === 'serial') {
|
||||
const portName = host.hostname.split('/').pop() || host.hostname;
|
||||
const sessionId = connectToHost(host);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: host.hostname,
|
||||
@@ -798,13 +997,14 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
connectToHost(host);
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
|
||||
const resolvedAuth = resolveHostAuth({ host, keys, identities });
|
||||
const sessionId = connectToHost(host);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
@@ -815,14 +1015,15 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
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;
|
||||
const sessionId = createSerialSession(config);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: '',
|
||||
hostLabel: `Serial: ${portName}`,
|
||||
hostname: config.path,
|
||||
@@ -833,32 +1034,23 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
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 });
|
||||
// Find the connection log for this session
|
||||
const session = sessions.find(s => s.id === sessionId);
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Session', session);
|
||||
if (!session) {
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] No session found');
|
||||
return;
|
||||
}
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] All logs:', connectionLogs.map(l => ({ id: l.id, sessionId: l.sessionId, hostname: l.hostname, endTime: l.endTime, hasTerminalData: !!l.terminalData })));
|
||||
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Looking for logs with hostname:', session.hostname);
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] All logs:', connectionLogs.map(l => ({ id: l.id, hostname: l.hostname, endTime: l.endTime, hasTerminalData: !!l.terminalData })));
|
||||
|
||||
// Find the most recent log matching this session's hostname and doesn't have terminalData yet
|
||||
// For local terminal, hostname is 'localhost'
|
||||
// Sort by startTime descending to find the most recent matching log
|
||||
// Prefer the persisted sessionId because the session may already have been
|
||||
// removed from state by the time the terminal unmount cleanup runs.
|
||||
const matchingLog = connectionLogs
|
||||
.filter(log =>
|
||||
log.hostname === session.hostname &&
|
||||
!log.endTime &&
|
||||
!log.terminalData
|
||||
)
|
||||
.filter((log) => {
|
||||
if (log.endTime || log.terminalData) return false;
|
||||
if (log.sessionId) return log.sessionId === sessionId;
|
||||
return !!session && log.hostname === session.hostname;
|
||||
})
|
||||
.sort((a, b) => b.startTime - a.startTime)[0];
|
||||
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Matching log', matchingLog);
|
||||
@@ -938,14 +1130,15 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, [protocolSelectHost, handleConnectToHost]);
|
||||
|
||||
const handleToggleTheme = useCallback(() => {
|
||||
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
|
||||
}, [setTheme]);
|
||||
// Toggle based on the actual rendered theme so clicking always produces a visible change,
|
||||
// even when the stored preference is 'system'.
|
||||
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
||||
}, [resolvedTheme, setTheme]);
|
||||
|
||||
const handleOpenQuickSwitcher = useCallback(() => {
|
||||
setIsQuickSwitcherOpen(true);
|
||||
}, []);
|
||||
|
||||
const { openSettingsWindow } = useWindowControls();
|
||||
|
||||
const handleOpenSettings = useCallback(() => {
|
||||
void (async () => {
|
||||
@@ -954,14 +1147,67 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
})();
|
||||
}, [openSettingsWindow, t]);
|
||||
|
||||
const hasShownCredentialProtectionWarningRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasShownCredentialProtectionWarningRef.current) return;
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const available = await getCredentialProtectionAvailability();
|
||||
if (cancelled || available !== false) return;
|
||||
hasShownCredentialProtectionWarningRef.current = true;
|
||||
|
||||
toast.warning(t('credentials.protectionUnavailable.message'), {
|
||||
title: t('credentials.protectionUnavailable.title'),
|
||||
actionLabel: t('credentials.protectionUnavailable.action'),
|
||||
duration: 10000,
|
||||
onClick: handleOpenSettings,
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [handleOpenSettings, t]);
|
||||
|
||||
const handleEndSessionDrag = useCallback(() => {
|
||||
setDraggingSessionId(null);
|
||||
}, [setDraggingSessionId]);
|
||||
|
||||
const handleRootContextMenu = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const editableSelector =
|
||||
"input, textarea, [contenteditable], .monaco-editor, .monaco-diff-editor, .monaco-inputbox, .monaco-menu-container";
|
||||
|
||||
const nativeEvent = e.nativeEvent;
|
||||
const path = typeof nativeEvent.composedPath === "function" ? nativeEvent.composedPath() : [];
|
||||
const allowFromPath = path.some(
|
||||
(node) => node instanceof Element && !!node.closest(editableSelector),
|
||||
);
|
||||
|
||||
const target = e.target;
|
||||
const targetElement =
|
||||
target instanceof Element
|
||||
? target
|
||||
: target instanceof Node
|
||||
? target.parentElement
|
||||
: null;
|
||||
const allowFromTarget = !!targetElement?.closest(editableSelector);
|
||||
|
||||
const allowNativeContextMenu = allowFromPath || allowFromTarget;
|
||||
|
||||
if (allowNativeContextMenu) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={(e) => e.preventDefault()}>
|
||||
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
|
||||
<TopTabs
|
||||
theme={theme}
|
||||
theme={resolvedTheme}
|
||||
hosts={hosts}
|
||||
sessions={sessions}
|
||||
orphanSessions={orphanSessions}
|
||||
workspaces={workspaces}
|
||||
@@ -998,6 +1244,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
connectionLogs={connectionLogs}
|
||||
managedSources={managedSources}
|
||||
sessions={sessions}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
@@ -1026,13 +1274,14 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
/>
|
||||
</VaultViewContainer>
|
||||
|
||||
<SftpViewMount hosts={hosts} keys={keys} identities={identities} />
|
||||
<SftpViewMount hosts={hosts} keys={keys} identities={identities} updateHosts={updateHosts} />
|
||||
|
||||
<TerminalLayerMount
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
knownHosts={knownHosts}
|
||||
@@ -1065,6 +1314,13 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onSplitSession={splitSession}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
updateHosts={updateHosts}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
sftpShowHiddenFiles={sftpShowHiddenFiles}
|
||||
sftpUseCompressedUpload={sftpUseCompressedUpload}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
/>
|
||||
|
||||
{/* Log Views - readonly terminal replays */}
|
||||
|
||||
32
CHANGELOG.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased] - 2026-03-11
|
||||
|
||||
### 功能
|
||||
- 修复自动更新 IPC 事件仅发送到单个窗口的问题,改为广播所有窗口(主窗口 + 设置窗口均可收到)
|
||||
- 统一手动检查更新与自动更新的状态机,消除三套并行状态
|
||||
- 手动"检查更新"通过 GitHub API 检测版本,发现更新后异步触发 electron-updater 下载
|
||||
- 设置窗口中点击"检查更新"后,下载进度可实时反映在 UI 中
|
||||
- 应用启动后 5 秒自动触发 `electron-updater` 检查更新,无需用户手动点击
|
||||
- 发现新版本后自动开始下载(`autoDownload=true`)
|
||||
- 下载完成后弹出持久 toast 通知,用户点击"立即重启"即可安装
|
||||
- 下载失败时弹出错误 toast,提供"打开 Releases"降级入口
|
||||
- Settings > System 进度条实时展示自动下载进度,由 `useUpdateCheck` 统一驱动
|
||||
- Linux deb/rpm/snap 等不支持 electron-updater 的平台自动跳过,保持原有 GitHub API 通知行为
|
||||
|
||||
### 设计原理
|
||||
- `broadcastToAllWindows` 替换 `getSenderWindow` 单点发送,保证所有窗口都能收到 IPC 事件
|
||||
- `manualCheckStatus` 字段追踪手动检查 UI 状态(idle/checking/available/up-to-date/error),与 `autoDownloadStatus` 在 UI 层按优先级渲染
|
||||
- `SettingsSystemTab` 不再持有本地 update state,单向接收 `useUpdateCheck` 统一数据
|
||||
- 将原有两套独立系统(GitHub API 通知 + electron-updater 手动下载)合并为统一状态机:`useUpdateCheck` 作为唯一事实来源,同时驱动 `App.tsx` toast 和 `SettingsSystemTab` 进度条
|
||||
- 全局持久化 IPC 监听器在 `autoUpdateBridge.init()` 时一次性注册,避免每次手动下载请求重复注册/清理监听器
|
||||
- `autoInstallOnAppQuit=false`,不做静默安装,由用户主动触发重启
|
||||
|
||||
### 接口变更(SettingsSystemTabProps)
|
||||
- 移除:`autoDownloadStatus`、`downloadPercent`
|
||||
- 新增:`updateState`(完整 UpdateState)、`checkNow`、`installUpdate`、`openReleasePage`
|
||||
|
||||
### 注意事项
|
||||
- `checkNow` 语义:使用 GitHub API(`performCheck`)检测是否有新版本,若发现更新且 electron-updater 尚未开始下载,则异步触发 `bridge.checkForUpdate()` 启动自动下载流程
|
||||
- 此功能仅对打包后的应用(Windows NSIS、macOS dmg/zip、Linux AppImage)生效,dev 模式需配合 `forceDevUpdateConfig=true` + `dev-app-update.yml` 测试(见 `.gitignore`)
|
||||
- `hasUpdate` 旧 toast 在 `autoDownloadStatus !== 'idle'` 时自动抑制,避免与新 toast 重复
|
||||
260
README.ja-JP.md
@@ -11,13 +11,13 @@
|
||||
|
||||
<p align="center">
|
||||
Electron、React、xterm.js で構築された機能豊富な SSH ワークスペース。<br/>
|
||||
ホスト管理、分割ターミナル、SFTP、ポートフォワーディング、クラウド同期 — すべてが一つに。
|
||||
分割ターミナル、Vault ビュー、SFTP ワークフロー、カスタムテーマ、キーワードハイライト — すべてが一つに。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
|
||||
|
||||
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
|
||||
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge&logo=electron"></a>
|
||||
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
|
||||
</p>
|
||||
@@ -40,22 +40,20 @@
|
||||
|
||||
---
|
||||
|
||||
[](screenshots/vault_grid_view.png)
|
||||
[](screenshots/main-window-dark.png)
|
||||
|
||||
---
|
||||
|
||||
# 目次 <!-- omit in toc -->
|
||||
|
||||
- [Netcatty とは](#netcatty-とは)
|
||||
- [なぜ Netcatty](#なぜ-netcatty)
|
||||
- [機能](#機能)
|
||||
- [デモ](#デモ)
|
||||
- [スクリーンショット](#スクリーンショット)
|
||||
- [ホスト管理](#ホスト管理)
|
||||
- [ターミナル](#ターミナル)
|
||||
- [SFTP](#sftp)
|
||||
- [キーチェーン](#キーチェーン)
|
||||
- [ポートフォワーディング](#ポートフォワーディング)
|
||||
- [クラウド同期](#クラウド同期)
|
||||
- [テーマとカスタマイズ](#テーマとカスタマイズ)
|
||||
- [メインウィンドウ](#メインウィンドウ)
|
||||
- [Vault ビュー](#vault-ビュー)
|
||||
- [分割ターミナル](#分割ターミナル)
|
||||
- [対応ディストリビューション](#対応ディストリビューション)
|
||||
- [はじめに](#はじめに)
|
||||
- [ビルドとパッケージ](#ビルドとパッケージ)
|
||||
@@ -71,190 +69,119 @@
|
||||
**Netcatty** は、複数のリモートサーバーを効率的に管理する必要がある開発者、システム管理者、DevOps エンジニア向けに設計された、モダンなクロスプラットフォーム SSH クライアントおよびターミナルマネージャーです。
|
||||
|
||||
- **Netcatty は** PuTTY、Termius、SecureCRT、macOS Terminal.app の代替となる SSH 接続ツール
|
||||
- **Netcatty は** デュアルペインファイルブラウザを備えた強力な SFTP クライアント
|
||||
- **Netcatty は** 強力な SFTP クライアント(ドラッグ&ドロップ + 内蔵エディタ)
|
||||
- **Netcatty は** 分割ペイン、タブ、セッション管理を備えたターミナルワークスペース
|
||||
- **Netcatty は** シェルの代替ではありません — SSH/Telnet またはローカルターミナル経由でリモートシェルに接続します
|
||||
- **Netcatty は** シェルの代替ではありません — SSH/Telnet/Mosh やローカル/シリアル経由でシェルに接続します(環境により異なります)
|
||||
|
||||
---
|
||||
|
||||
<a name="なぜ-netcatty"></a>
|
||||
# なぜ Netcatty
|
||||
|
||||
複数サーバーを日常的に扱うなら、Netcatty は「スピード」と「流れ」を重視した作りになっています:
|
||||
|
||||
- **ワークスペース中心** — 分割ペインで複数セッションを並行操作
|
||||
- **Vault の見やすさ** — グリッド/リスト/ツリーで状況に合わせて切り替え
|
||||
- **SFTP の作業感** — ドラッグ&ドロップと内蔵エディタでサクッと編集
|
||||
|
||||
---
|
||||
|
||||
<a name="機能"></a>
|
||||
# 機能
|
||||
|
||||
### 🖥️ ターミナルとセッション
|
||||
- **xterm.js ベースのターミナル**、GPU アクセラレーションレンダリング対応
|
||||
### 🗂️ Vault
|
||||
- **複数ビュー** — グリッド / リスト / ツリー
|
||||
- **高速検索** — ホストやグループを素早く見つける
|
||||
|
||||
### 🖥️ ターミナルワークスペース
|
||||
- **分割ペイン** — 水平・垂直分割でマルチタスク
|
||||
- **タブ管理** — ドラッグ&ドロップで並べ替え可能な複数セッション
|
||||
- **セッション永続化** — 再起動後もセッションを復元
|
||||
- **ブロードキャストモード** — 一度の入力で複数のターミナルに送信
|
||||
- **セッション管理** — 複数の接続を並行して扱う
|
||||
|
||||
### 🔐 SSH クライアント
|
||||
- **SSH2 プロトコル**、完全な認証サポート
|
||||
- **パスワード&キー認証**
|
||||
- **SSH 証明書**サポート
|
||||
- **ジャンプホスト / 踏み台サーバー** — 複数ホストを経由した接続
|
||||
- **プロキシサポート** — HTTP CONNECT および SOCKS5 プロキシ
|
||||
- **エージェント転送** — OpenSSH Agent および Pageant 対応
|
||||
- **環境変数** — ホストごとにカスタム環境変数を設定
|
||||
### 📁 SFTP + 内蔵エディタ
|
||||
- **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード
|
||||
- **その場で編集** — 内蔵エディタで小さな修正を素早く
|
||||
|
||||
### 📁 SFTP
|
||||
- **デュアルペインファイルブラウザ** — ローカル ↔ リモート または リモート ↔ リモート
|
||||
- **Sudo 特権昇格** — sudo を使用して root 権限のファイルを閲覧および編集
|
||||
- **ドラッグ&ドロップ** アップロードおよびダウンロード
|
||||
- **ドラッグ&ドロップ**ファイル転送
|
||||
- **キュー管理**でバッチ転送
|
||||
- **進捗追跡**、転送速度表示
|
||||
### 🎨 パーソナライズ
|
||||
- **カスタムテーマ** — UI の見た目を好みに調整
|
||||
- **キーワードハイライト** — ターミナル出力の強調表示ルールをカスタマイズ
|
||||
|
||||
### 🔑 キーチェーン
|
||||
- **SSH キー生成** — RSA、ECDSA、ED25519
|
||||
- **既存キーのインポート** — PEM、OpenSSH 形式
|
||||
- **SSH 証明書**サポート
|
||||
- **アイデンティティ管理** — 再利用可能なユーザー名+認証方式の組み合わせ
|
||||
- **公開鍵をエクスポート**してリモートホストへ
|
||||
---
|
||||
|
||||
### 🔌 ポートフォワーディング
|
||||
- **ローカルフォワーディング** — リモートサービスをローカルに公開
|
||||
- **リモートフォワーディング** — ローカルサービスをリモートに公開
|
||||
- **ダイナミックフォワーディング** — SOCKS5 プロキシ
|
||||
- **ビジュアルトンネル管理**
|
||||
<a name="デモ"></a>
|
||||
# デモ
|
||||
|
||||
### ☁️ クラウド同期
|
||||
- **エンドツーエンド暗号化同期** — デバイスを離れる前にデータを暗号化
|
||||
- **複数のプロバイダー** — GitHub Gist、S3 互換ストレージ、WebDAV、Google Drive、OneDrive
|
||||
- **ホスト、キー、スニペット、設定を同期**
|
||||
GIF で機能をさっと確認できます(素材は `screenshots/gifs/`):
|
||||
|
||||
### 🎨 テーマとカスタマイズ
|
||||
- **ライト&ダークモード**
|
||||
- **カスタムアクセントカラー**
|
||||
- **50以上のターミナル配色**
|
||||
- **フォントカスタマイズ** — JetBrains Mono、Fira Code など
|
||||
- **多言語対応** — English、简体中文 など
|
||||
### Vault ビュー:グリッド / リスト / ツリー
|
||||
状況に合わせて見え方を切り替え。グリッドで全体像、リストで密度、ツリーで階層を扱えます。
|
||||
|
||||

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

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

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

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

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

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

|
||||
|
||||

|
||||

|
||||
|
||||
**ネストされたフォルダと整理**
|
||||
<a name="vault-ビュー"></a>
|
||||
## Vault ビュー
|
||||
|
||||

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

|
||||
|
||||

|
||||

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

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

|
||||
|
||||
**分割ウィンドウ**
|
||||
<a name="分割ターミナル"></a>
|
||||
## 分割ターミナル
|
||||
|
||||
**ブロードキャストモード**
|
||||
分割ペインで複数のサーバー/タスクを同時に扱えます(例:デプロイ + ログ + 監視)。
|
||||
|
||||
一度入力すれば、どこでも実行できます。複数のサーバーを同時にメンテナンスするのに最適です。
|
||||
|
||||

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

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

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

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

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

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

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

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

|
||||

|
||||
|
||||
---
|
||||
|
||||
<a name="対応ディストリビューション"></a>
|
||||
# 対応ディストリビューション
|
||||
|
||||
Netcatty は接続したホストの OS アイコンを自動的に検出・表示します:
|
||||
Netcatty は接続したホストの OS を検出し、ホスト一覧でアイコンとして表示します:
|
||||
|
||||
<p align="center">
|
||||
<img src="public/distro/ubuntu.svg" width="48" alt="Ubuntu" title="Ubuntu">
|
||||
@@ -288,11 +215,7 @@ Netcatty は接続したホストの OS アイコンを自動的に検出・表
|
||||
|
||||
または [GitHub Releases](https://github.com/binaricat/Netcatty/releases) ですべてのリリースを参照してください。
|
||||
|
||||
> **⚠️ macOS ユーザーへ:** アプリはコード署名されていないため、macOS Gatekeeper によってブロックされます。ダウンロード後、以下のコマンドを実行して隔離属性を削除してください:
|
||||
> ```bash
|
||||
> xattr -cr /Applications/Netcatty.app
|
||||
> ```
|
||||
> または、アプリを右クリック → 開く → ダイアログで「開く」をクリックしてください。
|
||||
> **macOS ユーザーへ:** 現在のリリースはコード署名と notarization が行われている想定です。Gatekeeper の警告が出る場合は、GitHub Releases から最新版の公式ビルドを取得しているか確認してください。
|
||||
|
||||
### 前提条件
|
||||
- Node.js 18+ と npm
|
||||
@@ -346,7 +269,7 @@ npm run pack
|
||||
# 特定のプラットフォーム用にパッケージ
|
||||
npm run pack:mac # macOS (DMG + ZIP)
|
||||
npm run pack:win # Windows (NSIS インストーラー)
|
||||
npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
npm run pack:linux # Linux (AppImage + DEB + RPM)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -356,7 +279,7 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
| カテゴリ | テクノロジー |
|
||||
|--------|------------|
|
||||
| フレームワーク | Electron 39 |
|
||||
| フレームワーク | Electron 40 |
|
||||
| フロントエンド | React 19, TypeScript |
|
||||
| ビルドツール | Vite 7 |
|
||||
| ターミナル | xterm.js 5 |
|
||||
@@ -382,17 +305,6 @@ 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>
|
||||
# ライセンス
|
||||
|
||||
|
||||
247
README.md
@@ -11,7 +11,7 @@
|
||||
|
||||
<p align="center">
|
||||
A beautiful, feature-rich SSH workspace built with Electron, React, and xterm.js.<br/>
|
||||
Host management, split terminals, SFTP, port forwarding, and cloud sync — all in one.
|
||||
Split terminals, Vault views, SFTP workflows, custom themes, and keyword highlighting — all in one.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -40,22 +40,20 @@
|
||||
|
||||
---
|
||||
|
||||
[](screenshots/vault_grid_view.png)
|
||||
[](screenshots/main-window-dark.png)
|
||||
|
||||
---
|
||||
|
||||
# Contents <!-- omit in toc -->
|
||||
|
||||
- [What is Netcatty](#what-is-netcatty)
|
||||
- [Why Netcatty](#why-netcatty)
|
||||
- [Features](#features)
|
||||
- [Demos](#demos)
|
||||
- [Screenshots](#screenshots)
|
||||
- [Host Management](#host-management)
|
||||
- [Terminal](#terminal)
|
||||
- [SFTP](#sftp)
|
||||
- [Keychain](#keychain)
|
||||
- [Port Forwarding](#port-forwarding)
|
||||
- [Cloud Sync](#cloud-sync)
|
||||
- [Themes & Customization](#themes--customization)
|
||||
- [Main Window](#main-window)
|
||||
- [Vault Views](#vault-views)
|
||||
- [Split Terminals](#split-terminals)
|
||||
- [Supported Distros](#supported-distros)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Build & Package](#build--package)
|
||||
@@ -73,180 +71,111 @@
|
||||
- **Netcatty is** an alternative to PuTTY, Termius, SecureCRT, and macOS Terminal.app for SSH connections
|
||||
- **Netcatty is** a powerful SFTP client with dual-pane file browser
|
||||
- **Netcatty is** a terminal workspace with split panes, tabs, and session management
|
||||
- **Netcatty is not** a shell replacement — it connects to remote shells via SSH/Telnet or local terminals
|
||||
- **Netcatty supports** SSH, local terminal, Telnet, Mosh, and Serial connections (when available)
|
||||
- **Netcatty is not** a shell replacement — it connects to shells via SSH/Telnet/Mosh or local/serial sessions
|
||||
|
||||
---
|
||||
|
||||
<a name="why-netcatty"></a>
|
||||
# Why Netcatty
|
||||
|
||||
If you regularly work with a fleet of servers, Netcatty is built for speed and flow:
|
||||
|
||||
- **Workspace-first** — split panes + tabs + session restore for “always-on” workflows
|
||||
- **Vault organization** — grid/list/tree views with fast search and drag-friendly workflows
|
||||
- **Serious SFTP** — built-in editor + drag & drop + smooth file operations
|
||||
|
||||
---
|
||||
|
||||
<a name="features"></a>
|
||||
# Features
|
||||
|
||||
### 🖥️ Terminal & Sessions
|
||||
- **xterm.js-based terminal** with GPU-accelerated rendering
|
||||
### 🗂️ Vault
|
||||
- **Multiple views** — grid / list / tree
|
||||
- **Fast search** — locate hosts and groups quickly
|
||||
|
||||
### 🖥️ Terminal Workspaces
|
||||
- **Split panes** — horizontal and vertical splits for multi-tasking
|
||||
- **Tab management** — multiple sessions with drag-to-reorder
|
||||
- **Session persistence** — restore sessions on restart
|
||||
- **Broadcast mode** — type once, send to multiple terminals
|
||||
- **Session management** — run multiple connections side-by-side
|
||||
|
||||
### 🔐 SSH Client
|
||||
- **SSH2 protocol** with full authentication support
|
||||
- **Password & key-based authentication**
|
||||
- **SSH certificates** support
|
||||
- **Jump hosts / Bastion** — chain through multiple hosts
|
||||
- **Proxy support** — HTTP CONNECT and SOCKS5 proxies
|
||||
- **Agent forwarding** — including OpenSSH Agent and Pageant
|
||||
- **Environment variables** — set custom env vars per host
|
||||
### 📁 SFTP + Built-in Editor
|
||||
- **File workflows** — drag & drop uploads/downloads
|
||||
- **Edit in place** — built-in editor for quick changes
|
||||
|
||||
### 📁 SFTP
|
||||
- **Dual-pane file browser** — local ↔ remote or remote ↔ remote
|
||||
- **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
|
||||
### 🎨 Personalization
|
||||
- **Custom themes** — tune the app appearance to your taste
|
||||
- **Keyword highlighting** — customize highlight rules for terminal output
|
||||
|
||||
### 🔑 Keychain
|
||||
- **Generate SSH keys** — RSA, ECDSA, ED25519
|
||||
- **Import existing keys** — PEM, OpenSSH formats
|
||||
- **SSH certificates** support
|
||||
- **Identity management** — reusable username + auth combinations
|
||||
- **Export public keys** to remote hosts
|
||||
---
|
||||
|
||||
### 🔌 Port Forwarding
|
||||
- **Local forwarding** — expose remote services locally
|
||||
- **Remote forwarding** — expose local services remotely
|
||||
- **Dynamic forwarding** — SOCKS5 proxy
|
||||
- **Visual tunnel management**
|
||||
<a name="demos"></a>
|
||||
# Demos
|
||||
|
||||
### ☁️ Cloud Sync
|
||||
- **End-to-end encrypted sync** — your data is encrypted before leaving your device
|
||||
- **Multiple providers** — GitHub Gist, S3-compatible storage, WebDAV, Google Drive, OneDrive
|
||||
- **Sync hosts, keys, snippets, and settings**
|
||||
GIF previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
|
||||
|
||||
### 🎨 Themes & Customization
|
||||
- **Light & Dark mode**
|
||||
- **Custom accent colors**
|
||||
- **50+ terminal color schemes**
|
||||
- **Font customization** — JetBrains Mono, Fira Code, and more
|
||||
- **i18n support** — English, 简体中文, and more
|
||||
### Vault views: grid / list / tree
|
||||
Switch between different Vault views to match your workflow: overview in grid, dense scanning in list, and hierarchical navigation in tree.
|
||||
|
||||

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

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

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

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

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

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

|
||||
|
||||

|
||||

|
||||
|
||||
**Nested Folders & Organization**
|
||||
<a name="vault-views"></a>
|
||||
## Vault Views
|
||||
|
||||

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

|
||||
|
||||

|
||||

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

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

|
||||
|
||||
**Split Windows**
|
||||
<a name="split-terminals"></a>
|
||||
## Split Terminals
|
||||
|
||||
**Broadcast Mode**
|
||||
Split panes help you monitor multiple servers/services at the same time (deploy + logs + metrics) without juggling windows.
|
||||
|
||||
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
|
||||
|
||||
The Keychain is your secure vault for SSH credentials. Generate new keys, import existing ones, or manage SSH certificates for enterprise authentication.
|
||||
|
||||
| Key Type | Algorithm | Recommended Use |
|
||||
|----------|-----------|----------------|
|
||||
| **ED25519** | EdDSA | Modern, fast, most secure (recommended) |
|
||||
| **ECDSA** | NIST P-256/384/521 | Good security, widely supported |
|
||||
| **RSA** | RSA 2048/4096 | Legacy compatibility, universal support |
|
||||
| **Certificate** | CA-signed | Enterprise environments, short-lived auth |
|
||||
|
||||
**Features:**
|
||||
- 🔑 Generate keys with customizable bit lengths
|
||||
- 📥 Import PEM/OpenSSH format keys
|
||||
- 👤 Create reusable identities (username + auth method)
|
||||
- 📤 One-click export public keys to remote hosts
|
||||
|
||||

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

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

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

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

|
||||

|
||||
|
||||
---
|
||||
|
||||
@@ -270,8 +199,6 @@ Netcatty automatically detects and displays OS icons for connected hosts:
|
||||
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<a name="getting-started"></a>
|
||||
# Getting Started
|
||||
|
||||
@@ -287,11 +214,7 @@ Download the latest release for your platform from [GitHub Releases](https://git
|
||||
|
||||
Or browse all releases at [GitHub Releases](https://github.com/binaricat/Netcatty/releases).
|
||||
|
||||
> **⚠️ macOS Users:** Since the app is not code-signed, macOS Gatekeeper will block it. After downloading, run this command to remove the quarantine attribute:
|
||||
> ```bash
|
||||
> xattr -cr /Applications/Netcatty.app
|
||||
> ```
|
||||
> Or right-click the app → Open → Click "Open" in the dialog.
|
||||
> **macOS Users:** Current releases are expected to be code-signed and notarized. If Gatekeeper still warns, make sure you downloaded the latest official build from GitHub Releases.
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 18+ and npm
|
||||
@@ -355,7 +278,7 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
|
||||
|
||||
| Category | Technology |
|
||||
|----------|------------|
|
||||
| Framework | Electron 39 |
|
||||
| Framework | Electron 40 |
|
||||
| Frontend | React 19, TypeScript |
|
||||
| Build Tool | Vite 7 |
|
||||
| Terminal | xterm.js 5 |
|
||||
@@ -386,9 +309,7 @@ See [agents.md](agents.md) for architecture overview and coding conventions.
|
||||
|
||||
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>
|
||||
See: https://github.com/binaricat/Netcatty/graphs/contributors
|
||||
|
||||
---
|
||||
|
||||
|
||||
254
README.zh-CN.md
@@ -11,13 +11,13 @@
|
||||
|
||||
<p align="center">
|
||||
一个基于 Electron、React 和 xterm.js 构建的功能丰富的 SSH 工作空间。<br/>
|
||||
主机管理、分屏终端、SFTP、端口转发、云同步 —— 一应俱全。
|
||||
分屏终端、Vault 多视图、SFTP 工作流、自定义主题、关键词高亮 —— 一应俱全。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
|
||||
|
||||
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
|
||||
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge&logo=electron"></a>
|
||||
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
|
||||
</p>
|
||||
@@ -40,22 +40,20 @@
|
||||
|
||||
---
|
||||
|
||||
[](screenshots/vault_grid_view.png)
|
||||
[](screenshots/main-window-dark.png)
|
||||
|
||||
---
|
||||
|
||||
# 目录 <!-- omit in toc -->
|
||||
|
||||
- [Netcatty 是什么](#netcatty-是什么)
|
||||
- [为什么是 Netcatty](#为什么是-netcatty)
|
||||
- [功能特性](#功能特性)
|
||||
- [演示](#演示)
|
||||
- [界面截图](#界面截图)
|
||||
- [主机管理](#主机管理)
|
||||
- [终端](#终端)
|
||||
- [SFTP](#sftp)
|
||||
- [密钥管理](#密钥管理)
|
||||
- [端口转发](#端口转发)
|
||||
- [云同步](#云同步)
|
||||
- [主题与定制](#主题与定制)
|
||||
- [主界面](#主界面)
|
||||
- [Vault 视图](#vault-视图)
|
||||
- [分屏终端](#分屏终端)
|
||||
- [支持的发行版](#支持的发行版)
|
||||
- [快速开始](#快速开始)
|
||||
- [构建与打包](#构建与打包)
|
||||
@@ -73,188 +71,118 @@
|
||||
- **Netcatty 是** PuTTY、Termius、SecureCRT 和 macOS Terminal.app 的现代替代品
|
||||
- **Netcatty 是** 一个强大的 SFTP 客户端,支持双窗格文件浏览
|
||||
- **Netcatty 是** 一个终端工作空间,支持分屏、标签页和会话管理
|
||||
- **Netcatty 不是** Shell 替代品 —— 它通过 SSH/Telnet 或本地终端连接到远程 Shell
|
||||
- **Netcatty 支持** SSH、本地终端、Telnet、Mosh、串口(Serial)等连接方式(视环境而定)
|
||||
- **Netcatty 不是** Shell 替代品 —— 它通过 SSH/Telnet/Mosh 或本地/串口会话连接到 Shell
|
||||
|
||||
---
|
||||
|
||||
<a name="为什么是-netcatty"></a>
|
||||
# 为什么是 Netcatty
|
||||
|
||||
如果你需要同时维护多台服务器,Netcatty 更像是“工作台”而不是单一终端:
|
||||
|
||||
- **以工作区为核心** —— 分屏 + 多会话并行,适合长期驻留的工作流
|
||||
- **Vault 管理** —— 网格/列表/树形视图,配合搜索与拖拽更顺手
|
||||
- **认真做的 SFTP** —— 内置编辑器 + 拖拽上传,文件操作更丝滑
|
||||
|
||||
---
|
||||
|
||||
<a name="功能特性"></a>
|
||||
# 功能特性
|
||||
|
||||
### 🖥️ 终端与会话
|
||||
- **基于 xterm.js 的终端**,支持 GPU 加速渲染
|
||||
- **分屏功能** —— 水平和垂直分割,多任务并行
|
||||
- **标签页管理** —— 多会话支持,拖拽排序
|
||||
- **会话持久化** —— 重启后恢复会话
|
||||
- **广播模式** —— 一次输入,发送到多个终端
|
||||
### 🗂️ Vault
|
||||
- **多种视图** —— 网格 / 列表 / 树形
|
||||
- **快速搜索** —— 迅速定位主机与分组
|
||||
|
||||
### 🔐 SSH 客户端
|
||||
- **SSH2 协议**,完整的认证支持
|
||||
- **密码和密钥认证**
|
||||
- **SSH 证书**支持
|
||||
- **跳板机 / 堡垒机** —— 多主机链式连接
|
||||
- **代理支持** —— HTTP CONNECT 和 SOCKS5 代理
|
||||
- **Agent 转发** —— 支持 OpenSSH Agent 和 Pageant
|
||||
- **环境变量** —— 为每个主机设置自定义环境变量
|
||||
### 🖥️ 终端工作区
|
||||
- **分屏** —— 水平/垂直分割,多任务并行
|
||||
- **多会话管理** —— 多连接并排处理
|
||||
|
||||
### 📁 SFTP
|
||||
- **双窗格文件浏览器** —— 本地 ↔ 远程 或 远程 ↔ 远程
|
||||
- **Sudo 提权支持** —— 使用 sudo 浏览和编辑 root 权限文件
|
||||
- **拖放操作** —— 支持上传和下载
|
||||
- **拖放传输** 文件
|
||||
- **队列管理** 批量传输
|
||||
- **进度跟踪** 显示传输速度
|
||||
### 📁 SFTP + 内置编辑器
|
||||
- **文件工作流** —— 拖拽上传/下载更直观
|
||||
- **就地编辑** —— 内置编辑器快速修改文件
|
||||
|
||||
### 🔑 密钥管理
|
||||
- **生成 SSH 密钥** —— RSA、ECDSA、ED25519
|
||||
- **导入已有密钥** —— PEM、OpenSSH 格式
|
||||
- **SSH 证书**支持
|
||||
- **身份管理** —— 可复用的用户名 + 认证方式组合
|
||||
- **导出公钥**到远程主机
|
||||
### 🎨 个性化
|
||||
- **自定义主题** —— 按喜好调整应用外观
|
||||
- **关键词高亮** —— 自定义终端输出高亮规则
|
||||
|
||||
### 🔌 端口转发
|
||||
- **本地转发** —— 将远程服务暴露到本地
|
||||
- **远程转发** —— 将本地服务暴露到远程
|
||||
- **动态转发** —— SOCKS5 代理
|
||||
- **可视化隧道管理**
|
||||
---
|
||||
|
||||
### ☁️ 云同步
|
||||
- **端到端加密同步** —— 数据在离开设备前加密
|
||||
- **多种存储后端** —— GitHub Gist、S3 兼容存储、WebDAV、Google Drive、OneDrive
|
||||
- **同步主机、密钥、代码片段和设置**
|
||||
<a name="演示"></a>
|
||||
# 演示
|
||||
|
||||
### 🎨 主题与定制
|
||||
- **浅色 & 深色模式**
|
||||
- **自定义强调色**
|
||||
- **50+ 终端配色方案**
|
||||
- **字体自定义** —— JetBrains Mono、Fira Code 等
|
||||
- **多语言支持** —— English、简体中文 等
|
||||
GIF 预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
|
||||
|
||||
### Vault 视图:网格 / 列表 / 树形
|
||||
根据不同场景自由切换视图:网格适合总览,列表适合密集浏览,树形适合层级导航与整理。
|
||||
|
||||

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

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

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

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

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

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

|
||||
|
||||

|
||||

|
||||
|
||||
**层级文件夹与分组**
|
||||
<a name="vault-视图"></a>
|
||||
## Vault 视图
|
||||
|
||||

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

|
||||
|
||||

|
||||

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

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

|
||||
|
||||
**分屏窗口**
|
||||
<a name="分屏终端"></a>
|
||||
## 分屏终端
|
||||
|
||||
**广播模式**
|
||||
分屏适合同时处理多个任务(例如部署 + 日志 + 排障),不用频繁切换窗口。
|
||||
|
||||
一次输入,多处执行。非常适合同时维护这多台服务器。
|
||||
|
||||

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

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

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

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

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

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

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

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

|
||||

|
||||
|
||||
---
|
||||
|
||||
<a name="支持的发行版"></a>
|
||||
# 支持的发行版
|
||||
|
||||
Netcatty 自动检测并显示已连接主机的操作系统图标:
|
||||
Netcatty 会自动识别并在主机列表中展示对应的系统图标:
|
||||
|
||||
<p align="center">
|
||||
<img src="public/distro/ubuntu.svg" width="48" alt="Ubuntu" title="Ubuntu">
|
||||
@@ -271,8 +199,6 @@ Netcatty 自动检测并显示已连接主机的操作系统图标:
|
||||
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<a name="快速开始"></a>
|
||||
# 快速开始
|
||||
|
||||
@@ -288,11 +214,7 @@ Netcatty 自动检测并显示已连接主机的操作系统图标:
|
||||
|
||||
或在 [GitHub Releases](https://github.com/binaricat/Netcatty/releases) 浏览所有版本。
|
||||
|
||||
> **⚠️ macOS 用户注意:** 由于应用未经代码签名,macOS Gatekeeper 会阻止运行。下载后,请在终端运行以下命令移除隔离属性:
|
||||
> ```bash
|
||||
> xattr -cr /Applications/Netcatty.app
|
||||
> ```
|
||||
> 或者右键点击应用 → 打开 → 在弹出的对话框中点击"打开"。
|
||||
> **macOS 用户注意:** 当前发布版本应已完成代码签名和公证。如果 Gatekeeper 仍然提示风险,请确认您下载的是 GitHub Releases 中的最新官方构建。
|
||||
|
||||
### 前置条件
|
||||
- Node.js 18+ 和 npm
|
||||
@@ -356,7 +278,7 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
| 分类 | 技术 |
|
||||
|-----|-----|
|
||||
| 框架 | Electron 39 |
|
||||
| 框架 | Electron 40 |
|
||||
| 前端 | React 19, TypeScript |
|
||||
| 构建工具 | Vite 7 |
|
||||
| 终端 | xterm.js 5 |
|
||||
@@ -387,9 +309,7 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
感谢所有参与贡献的人!
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
|
||||
</a>
|
||||
查看:https://github.com/binaricat/Netcatty/graphs/contributors
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ const en: Messages = {
|
||||
'common.import': 'Import',
|
||||
'common.generate': 'Generate',
|
||||
'common.delete': 'Delete',
|
||||
'common.edit': 'Edit',
|
||||
'common.clear': 'Clear',
|
||||
'common.optional': 'Optional',
|
||||
'common.selectPlaceholder': 'Select...',
|
||||
@@ -33,6 +34,7 @@ const en: Messages = {
|
||||
'common.advanced': 'Advanced',
|
||||
'common.left': 'Left',
|
||||
'common.right': 'Right',
|
||||
'common.more': 'More',
|
||||
'common.selectAHost': 'Select a host',
|
||||
'common.selectAHostPlaceholder': 'Select a host...',
|
||||
'sort.az': 'A-z',
|
||||
@@ -48,6 +50,7 @@ const en: Messages = {
|
||||
// Dialogs / prompts
|
||||
'confirm.deleteHost': 'Delete Host "{name}"?',
|
||||
'confirm.deleteIdentity': 'Delete Identity "{name}"?',
|
||||
'confirm.removeProvider': 'Remove provider "{name}"?',
|
||||
'dialog.createWorkspace.title': 'Create Workspace',
|
||||
'dialog.renameWorkspace.title': 'Rename workspace',
|
||||
'dialog.renameSession.title': 'Rename session',
|
||||
@@ -57,6 +60,9 @@ const en: Messages = {
|
||||
'placeholder.sessionName': 'Session name',
|
||||
'placeholder.searchHosts': 'Search hosts...',
|
||||
'toast.settingsUnavailable': 'Settings window is unavailable on this platform.',
|
||||
'credentials.protectionUnavailable.title': 'Credential Protection Unavailable',
|
||||
'credentials.protectionUnavailable.message': 'Saved passwords and keys cannot be auto-decrypted on this device. Re-enter credentials before connecting.',
|
||||
'credentials.protectionUnavailable.action': 'Open Settings',
|
||||
|
||||
// Settings shell
|
||||
'settings.title': 'Settings',
|
||||
@@ -80,6 +86,35 @@ const en: Messages = {
|
||||
'settings.system.clearing': 'Clearing...',
|
||||
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
|
||||
'settings.system.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
|
||||
'settings.system.credentials.title': 'Credential Protection',
|
||||
'settings.system.credentials.status': 'Status',
|
||||
'settings.system.credentials.checking': 'Checking...',
|
||||
'settings.system.credentials.available': 'Available (OS keychain ready)',
|
||||
'settings.system.credentials.unavailable': 'Unavailable (cannot decrypt saved credentials)',
|
||||
'settings.system.credentials.unknown': 'Unknown (not supported in this environment)',
|
||||
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
|
||||
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
|
||||
|
||||
// Settings > System > Software Update
|
||||
'settings.update.title': 'Software Update',
|
||||
'settings.update.currentVersion': 'Current version',
|
||||
'settings.update.checkForUpdates': 'Check for Updates',
|
||||
'settings.update.checking': 'Checking...',
|
||||
'settings.update.upToDate': 'You are using the latest version.',
|
||||
'settings.update.available': 'New version {version} is available.',
|
||||
'settings.update.download': 'Download Update',
|
||||
'settings.update.downloading': 'Downloading... {percent}%',
|
||||
'settings.update.readyToInstall': 'Update downloaded and ready to install.',
|
||||
'settings.update.restartNow': 'Restart to Update',
|
||||
'settings.update.error': 'Failed to check for updates.',
|
||||
'settings.update.downloadError': 'Download failed.',
|
||||
'settings.update.manualDownload': 'Download from GitHub',
|
||||
'settings.update.manualDownloadHint': 'Auto-update is not available on this platform. Download the latest version from GitHub.',
|
||||
'settings.update.hint': 'Netcatty checks for updates from GitHub Releases.',
|
||||
'settings.update.lastCheckedJustNow': 'just now',
|
||||
'settings.update.lastCheckedMinutesAgo': '{n} min ago',
|
||||
'settings.update.lastCheckedHoursAgo': '{n} hr ago',
|
||||
'settings.update.lastCheckedPrefix': 'Last checked: ',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': 'Session Logs',
|
||||
@@ -99,6 +134,35 @@ const en: Messages = {
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.hint': 'Session logs capture all terminal output for troubleshooting and auditing purposes.',
|
||||
|
||||
// Settings > Global Hotkey (Quake Mode)
|
||||
'settings.globalHotkey.title': 'Global Hotkey',
|
||||
'settings.globalHotkey.toggleWindow': 'Toggle Window',
|
||||
'settings.globalHotkey.toggleWindowDesc': 'Press a key combination to set a global shortcut for showing/hiding the window.',
|
||||
'settings.globalHotkey.notSet': 'Not set',
|
||||
'settings.globalHotkey.reset': 'Reset to default',
|
||||
'settings.globalHotkey.closeToTray': 'Close to System Tray',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window will minimize to the system tray instead of quitting.',
|
||||
'settings.globalHotkey.hint': 'Global hotkey works system-wide to quickly show or hide the window (Quake-style terminal).',
|
||||
|
||||
// Tray Panel
|
||||
'tray.openMainWindow': 'Open Main Window',
|
||||
'tray.sessions': 'Sessions',
|
||||
'tray.portForwarding': 'Port Forwarding',
|
||||
'tray.status.connected': 'Connected',
|
||||
'tray.status.connecting': 'Connecting',
|
||||
'tray.status.disconnected': 'Disconnected',
|
||||
'tray.status.active': 'Active',
|
||||
'tray.status.inactive': 'Inactive',
|
||||
'tray.status.error': 'Error',
|
||||
'tray.recentHosts': 'Recent Hosts',
|
||||
'tray.empty.title': 'Nothing here yet',
|
||||
'tray.empty.subtitle': 'Go connect to a server, they miss you 🚀',
|
||||
'tray.quit': 'Quit Netcatty',
|
||||
|
||||
// Vault Sidebar
|
||||
'vault.sidebar.collapse': 'Collapse sidebar',
|
||||
'vault.sidebar.expand': 'Expand sidebar',
|
||||
|
||||
// Settings > Application
|
||||
'settings.application.checkUpdates': 'Check for updates',
|
||||
'settings.application.reportProblem': 'Report a problem',
|
||||
@@ -118,13 +182,23 @@ const en: Messages = {
|
||||
'update.upToDate.message': 'You are running the latest version ({version}).',
|
||||
'update.error': 'Failed to check for updates',
|
||||
'update.downloadNow': 'Download Now',
|
||||
'update.viewInSettings': 'View in Settings',
|
||||
'update.readyToInstall.title': 'Update Ready',
|
||||
'update.readyToInstall.message': 'Version {version} downloaded and ready to install.',
|
||||
'update.restartNow': 'Restart Now',
|
||||
'update.downloadFailed.title': 'Update Failed',
|
||||
'update.downloadFailed.message': 'Failed to download update. You can download it manually.',
|
||||
'update.openReleases': 'Open Releases',
|
||||
'update.remindLater': 'Remind Later',
|
||||
'update.skipVersion': 'Skip This Version',
|
||||
|
||||
// Settings > Appearance
|
||||
'settings.appearance.uiTheme': 'UI Theme',
|
||||
'settings.appearance.darkMode': 'Dark Mode',
|
||||
'settings.appearance.darkMode.desc': 'Toggle between light and dark theme',
|
||||
'settings.appearance.theme': 'Theme',
|
||||
'settings.appearance.theme.desc': 'Choose light, dark, or follow system preference',
|
||||
'settings.appearance.theme.light': 'Light',
|
||||
'settings.appearance.theme.dark': 'Dark',
|
||||
'settings.appearance.theme.system': 'System',
|
||||
'settings.appearance.accentColor': 'Accent Color',
|
||||
'settings.appearance.customColor': 'Custom color',
|
||||
'settings.appearance.accentColor.mode': 'Use custom accent',
|
||||
@@ -185,10 +259,13 @@ const en: Messages = {
|
||||
'settings.terminal.behavior.rightClick.paste': 'Paste',
|
||||
'settings.terminal.behavior.rightClick.selectWord': 'Select word',
|
||||
'settings.terminal.behavior.copyOnSelect': 'Copy on select',
|
||||
'settings.terminal.behavior.copyOnSelect.desc': 'Automatically copy selected text',
|
||||
'settings.terminal.behavior.copyOnSelect.desc': 'Automatically copy selected text. In tmux/vim with mouse mode, hold Option on macOS or Shift on Windows/Linux to select',
|
||||
'settings.terminal.behavior.middleClickPaste': 'Middle-click paste',
|
||||
'settings.terminal.behavior.middleClickPaste.desc':
|
||||
'Paste clipboard content on middle-click',
|
||||
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
|
||||
'settings.terminal.behavior.scrollOnInput': 'Scroll on input',
|
||||
'settings.terminal.behavior.scrollOnInput.desc': 'Scroll terminal to bottom when typing',
|
||||
'settings.terminal.behavior.scrollOnOutput': 'Scroll on output',
|
||||
@@ -253,6 +330,7 @@ const en: Messages = {
|
||||
'settings.shortcuts.category.terminal': 'Terminal',
|
||||
'settings.shortcuts.category.navigation': 'Navigation',
|
||||
'settings.shortcuts.category.app': 'App',
|
||||
'settings.shortcuts.category.sftp': 'SFTP',
|
||||
|
||||
// Context menus / common actions
|
||||
'action.newHost': 'New Host',
|
||||
@@ -294,6 +372,7 @@ const en: Messages = {
|
||||
'sync.autoSync.vaultLocked': 'Vault is locked. Open Settings → Sync & Cloud to unlock.',
|
||||
'sync.autoSync.conflictDetected': 'Sync conflict detected. Open Settings → Sync & Cloud to resolve.',
|
||||
'sync.autoSync.syncFailed': 'Sync failed',
|
||||
'sync.credentialsUnavailable': 'This device cannot decrypt some saved credentials. Re-enter credentials locally before syncing.',
|
||||
'time.never': 'Never',
|
||||
'time.justNow': 'Just now',
|
||||
'time.minutesAgo': '{minutes}m ago',
|
||||
@@ -360,6 +439,8 @@ const en: Messages = {
|
||||
'vault.hosts.deselectAll': 'Deselect All',
|
||||
'vault.hosts.deleteSelected': 'Delete ({count})',
|
||||
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': 'Add data to your vault',
|
||||
@@ -463,6 +544,13 @@ const en: Messages = {
|
||||
'pf.view.list': 'List',
|
||||
'pf.rule.summary.dynamic': 'SOCKS on {bindAddress}:{localPort}',
|
||||
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
|
||||
'pf.tooltip.relayHost': 'Relay Host',
|
||||
'pf.tooltip.hostLabel': 'Host',
|
||||
'pf.tooltip.hostAddress': 'Address',
|
||||
'pf.tooltip.noHost': 'No relay host configured',
|
||||
'pf.tooltip.localDesc': 'Local port forwarding: Access remote services through SSH tunnel',
|
||||
'pf.tooltip.remoteDesc': 'Remote port forwarding: Expose local services to remote host',
|
||||
'pf.tooltip.dynamicDesc': 'Dynamic SOCKS proxy: Route traffic through SSH tunnel',
|
||||
'pf.deleteActive.title': 'Delete Active Port Forwarding?',
|
||||
'pf.deleteActive.desc': 'This port forwarding rule "{label}" is currently active. Deleting it will stop the tunnel first.',
|
||||
'pf.deleteActive.confirm': 'Stop and Delete',
|
||||
@@ -474,6 +562,9 @@ const en: Messages = {
|
||||
'sftp.newFile': 'New File',
|
||||
'sftp.filter': 'Filter',
|
||||
'sftp.filter.placeholder': 'Filter by filename...',
|
||||
'sftp.bookmark.add': 'Bookmark this path',
|
||||
'sftp.bookmark.remove': 'Remove bookmark',
|
||||
'sftp.bookmark.empty': 'No bookmarks yet',
|
||||
'sftp.columns.name': 'Name',
|
||||
'sftp.columns.modified': 'Modified',
|
||||
'sftp.columns.size': 'Size',
|
||||
@@ -506,7 +597,11 @@ const en: Messages = {
|
||||
'sftp.status.loading': 'Loading...',
|
||||
'sftp.status.uploading': 'Uploading...',
|
||||
'sftp.status.ready': 'Ready',
|
||||
'sftp.transfers': 'Transfers',
|
||||
'sftp.transfers.active': '{count} active',
|
||||
'sftp.transfers.clearCompleted': 'Clear completed',
|
||||
'sftp.goUp': 'Go up',
|
||||
'sftp.goToTerminalCwd': 'Go to terminal directory',
|
||||
'sftp.encoding.label': 'Filename Encoding',
|
||||
'sftp.encoding.auto': 'Auto',
|
||||
'sftp.encoding.utf8': 'UTF-8',
|
||||
@@ -649,6 +744,7 @@ const en: Messages = {
|
||||
'sftp.upload.currentFile': 'Current: {fileName}',
|
||||
'sftp.upload.cancelled': 'Upload cancelled',
|
||||
'sftp.upload.cancel': 'Cancel',
|
||||
'sftp.upload.completedToPath': 'Uploaded to {path}',
|
||||
|
||||
// SFTP Download
|
||||
'sftp.download.completed': 'Downloaded',
|
||||
@@ -665,9 +761,9 @@ const en: Messages = {
|
||||
|
||||
// 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.desc': 'Display hidden files (dotfiles on Unix/macOS and files with the hidden attribute on Windows) in the SFTP file browser.',
|
||||
'settings.sftp.showHiddenFiles.enable': 'Show hidden files',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': 'Display Windows hidden files when browsing local filesystem',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': 'Display hidden files when browsing both local and remote filesystems',
|
||||
|
||||
// Settings > SFTP Compressed Upload
|
||||
'settings.sftp.compressedUpload': 'Folder Compression Transfer',
|
||||
@@ -677,9 +773,6 @@ const en: Messages = {
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Search hosts or tabs',
|
||||
'qs.recentConnections': 'Recent connections',
|
||||
'qs.createWorkspace': 'Create a workspace',
|
||||
'qs.restore': 'Restore',
|
||||
'qs.jumpTo': 'Jump To',
|
||||
'qs.localTerminal': 'Local Terminal',
|
||||
|
||||
@@ -742,9 +835,13 @@ const en: Messages = {
|
||||
'hostDetails.certs.empty': 'No certificates available',
|
||||
'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.agentForwarding.agentNotRunning': 'SSH Agent is not available',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': 'No SSH agent detected. Enable OpenSSH Authentication Agent in Windows Services, or use a compatible agent such as Bitwarden, 1Password, or gpg-agent.',
|
||||
'hostDetails.section.agentForwarding': 'SSH Agent',
|
||||
'hostDetails.section.legacyAlgorithms': 'Legacy Algorithms',
|
||||
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
|
||||
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
|
||||
'hostDetails.jumpHosts': 'Proxy via Hosts',
|
||||
'hostDetails.jumpHosts.hops': '{count} hop(s)',
|
||||
'hostDetails.jumpHosts.direct': 'Direct',
|
||||
@@ -860,9 +957,26 @@ const en: Messages = {
|
||||
'terminal.toolbar.broadcast': 'Broadcast',
|
||||
'terminal.toolbar.broadcastEnable': 'Enable Broadcast Mode',
|
||||
'terminal.toolbar.broadcastDisable': 'Disable Broadcast Mode',
|
||||
'terminal.toolbar.composeBar': 'Compose Bar',
|
||||
'terminal.composeBar.placeholder': 'Type command here, press Enter to send...',
|
||||
'terminal.composeBar.send': 'Send',
|
||||
'terminal.composeBar.close': 'Close compose bar',
|
||||
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
|
||||
'terminal.toolbar.focus': 'Focus',
|
||||
'terminal.toolbar.focusMode': 'Focus Mode',
|
||||
'terminal.toolbar.encoding': 'Terminal Encoding',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
'terminal.toolbar.closeSession': 'Close session',
|
||||
'terminal.toolbar.hostHighlight.title': 'Host Keyword Highlighting',
|
||||
'terminal.toolbar.hostHighlight.noRules': 'No custom highlight rules defined for this host',
|
||||
'terminal.toolbar.hostHighlight.addRule': 'Add New Rule',
|
||||
'terminal.toolbar.hostHighlight.labelPlaceholder': 'Label (e.g., Error)',
|
||||
'terminal.toolbar.hostHighlight.patternPlaceholder': 'Regex pattern (e.g., \\bfailed\\b)',
|
||||
'terminal.toolbar.hostHighlight.invalidPattern': 'Invalid regex pattern',
|
||||
'terminal.toolbar.hostHighlight.clearAll': 'Clear All',
|
||||
'terminal.toolbar.hostHighlight.changeColor': 'Change highlight color for',
|
||||
'terminal.toolbar.hostHighlight.selectColor': 'Select color for new rule',
|
||||
'terminal.serverStats.cpu': 'CPU Usage',
|
||||
'terminal.serverStats.cpuCores': 'CPU Core Usage',
|
||||
'terminal.serverStats.memory': 'Memory Usage',
|
||||
@@ -871,6 +985,10 @@ const en: Messages = {
|
||||
'terminal.serverStats.memBuffers': 'Buffers',
|
||||
'terminal.serverStats.memCached': 'Cache',
|
||||
'terminal.serverStats.memFree': 'Free',
|
||||
'terminal.serverStats.swap': 'Swap',
|
||||
'terminal.serverStats.swapUsed': 'Swap Used',
|
||||
'terminal.serverStats.swapFree': 'Swap Free',
|
||||
'terminal.serverStats.swapTotal': 'Total',
|
||||
'terminal.serverStats.topProcesses': 'Top Processes by Memory',
|
||||
'terminal.serverStats.disk': 'Disk Usage (Root)',
|
||||
'terminal.serverStats.diskDetails': 'Mounted Disks',
|
||||
@@ -907,6 +1025,10 @@ const en: Messages = {
|
||||
'terminal.auth.selectKey': 'Select Key',
|
||||
'terminal.auth.noKeysHint': 'No keys available. Add keys in Keychain.',
|
||||
'terminal.auth.continueSave': 'Continue & Save',
|
||||
'terminal.auth.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.',
|
||||
'terminal.auth.proxyCredentialsUnavailable': 'Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.',
|
||||
'terminal.auth.keyUnavailableFallbackPassword': 'Saved SSH key is unavailable on this device. Falling back to password authentication.',
|
||||
'terminal.progress.timeoutIn': 'Timeout in {seconds}s',
|
||||
'terminal.progress.disconnected': 'Disconnected',
|
||||
'terminal.progress.cancelling': 'Cancelling...',
|
||||
@@ -922,10 +1044,50 @@ const en: Messages = {
|
||||
'terminal.themeModal.title': 'Terminal Appearance',
|
||||
'terminal.themeModal.tab.theme': 'Theme',
|
||||
'terminal.themeModal.tab.font': 'Font',
|
||||
'terminal.themeModal.tab.custom': 'Custom',
|
||||
'terminal.themeModal.fontSize': 'Font Size',
|
||||
'terminal.themeModal.livePreview': 'Live Preview',
|
||||
'terminal.themeModal.themeType': '{type} theme',
|
||||
|
||||
// Custom Themes
|
||||
'terminal.customTheme.section': 'Custom Themes',
|
||||
'terminal.customTheme.yourThemes': 'Your Themes',
|
||||
'terminal.customTheme.new': 'New Theme',
|
||||
'terminal.customTheme.newDesc': 'Clone current theme and customize',
|
||||
'terminal.customTheme.newTitle': 'New Custom Theme',
|
||||
'terminal.customTheme.editTitle': 'Edit Theme',
|
||||
'terminal.customTheme.import': 'Import .itermcolors',
|
||||
'terminal.customTheme.importDesc': 'Import from iTerm2 color scheme file',
|
||||
'terminal.customTheme.importError': 'Failed to parse the selected file. Please ensure it is a valid .itermcolors XML file.',
|
||||
'terminal.customTheme.delete': 'Delete Theme',
|
||||
'terminal.customTheme.confirmDelete': 'Confirm Delete',
|
||||
'terminal.customTheme.name': 'Name',
|
||||
'terminal.customTheme.namePlaceholder': 'My Custom Theme',
|
||||
'terminal.customTheme.type': 'Type',
|
||||
'terminal.customTheme.group.general': 'General',
|
||||
'terminal.customTheme.group.normal': 'Normal Colors',
|
||||
'terminal.customTheme.group.bright': 'Bright Colors',
|
||||
'terminal.customTheme.color.background': 'Background',
|
||||
'terminal.customTheme.color.foreground': 'Foreground',
|
||||
'terminal.customTheme.color.cursor': 'Cursor',
|
||||
'terminal.customTheme.color.selection': 'Selection',
|
||||
'terminal.customTheme.color.black': 'Black',
|
||||
'terminal.customTheme.color.red': 'Red',
|
||||
'terminal.customTheme.color.green': 'Green',
|
||||
'terminal.customTheme.color.yellow': 'Yellow',
|
||||
'terminal.customTheme.color.blue': 'Blue',
|
||||
'terminal.customTheme.color.magenta': 'Magenta',
|
||||
'terminal.customTheme.color.cyan': 'Cyan',
|
||||
'terminal.customTheme.color.white': 'White',
|
||||
'terminal.customTheme.color.brightBlack': 'Bright Black',
|
||||
'terminal.customTheme.color.brightRed': 'Bright Red',
|
||||
'terminal.customTheme.color.brightGreen': 'Bright Green',
|
||||
'terminal.customTheme.color.brightYellow': 'Bright Yellow',
|
||||
'terminal.customTheme.color.brightBlue': 'Bright Blue',
|
||||
'terminal.customTheme.color.brightMagenta': 'Bright Magenta',
|
||||
'terminal.customTheme.color.brightCyan': 'Bright Cyan',
|
||||
'terminal.customTheme.color.brightWhite': 'Bright White',
|
||||
|
||||
// Cloud Sync Settings
|
||||
'cloudSync.gate.title': 'End-to-End Encrypted Sync',
|
||||
'cloudSync.gate.desc':
|
||||
@@ -965,6 +1127,7 @@ const en: Messages = {
|
||||
'cloudSync.webdav.password': 'Password',
|
||||
'cloudSync.webdav.token': 'Token',
|
||||
'cloudSync.webdav.showSecret': 'Show secret',
|
||||
'cloudSync.webdav.allowInsecure': 'Allow insecure connection (ignore certificate errors)',
|
||||
'cloudSync.webdav.validation.endpoint': 'Enter a valid WebDAV endpoint.',
|
||||
'cloudSync.webdav.validation.credentials': 'Username and password are required.',
|
||||
'cloudSync.webdav.validation.token': 'Token is required.',
|
||||
@@ -1240,6 +1403,15 @@ const en: Messages = {
|
||||
'snippets.renameDialog.error.duplicate': 'A package with this name already exists',
|
||||
'snippets.renameDialog.error.invalidChars': 'Package name can only contain letters, numbers, hyphens, and underscores',
|
||||
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': 'Keyboard Shortcut',
|
||||
'snippets.shortkey.placeholder': 'Click to set shortcut',
|
||||
'snippets.shortkey.recording': 'Press a key combination...',
|
||||
'snippets.shortkey.hint': 'Press this shortcut in terminal to quickly send the command.',
|
||||
'snippets.shortkey.clear': 'Clear shortcut',
|
||||
'snippets.shortkey.error.systemConflict': 'This shortcut conflicts with a system shortcut',
|
||||
'snippets.shortkey.error.snippetConflict': 'This shortcut is already used by snippet: {name}',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': 'Serial',
|
||||
'serial.modal.title': 'Connect to Serial Port',
|
||||
@@ -1304,6 +1476,151 @@ const en: Messages = {
|
||||
'passphrase.unlock': 'Unlock',
|
||||
'passphrase.unlocking': 'Unlocking...',
|
||||
'passphrase.skip': 'Skip',
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': 'Word Wrap',
|
||||
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent Settings',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': 'Configure AI providers, agents, and safety settings',
|
||||
'ai.providers': 'Providers',
|
||||
'ai.providers.empty': 'No providers configured. Add a provider to get started.',
|
||||
'ai.providers.add': 'Add Provider',
|
||||
'ai.providers.active': 'Active',
|
||||
'ai.providers.apiKeyConfigured': 'API key configured',
|
||||
'ai.providers.noApiKey': 'No API key',
|
||||
'ai.providers.configure': 'Configure',
|
||||
'ai.providers.remove': 'Remove',
|
||||
'ai.providers.name': 'Display Name',
|
||||
'ai.providers.name.placeholder': 'e.g. My Provider',
|
||||
'ai.providers.apiKey': 'API Key',
|
||||
'ai.providers.apiKey.placeholder': 'Enter API key',
|
||||
'ai.providers.apiKey.decrypting': 'Decrypting...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.defaultModel': 'Default Model',
|
||||
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.refreshModels': 'Refresh models',
|
||||
'ai.providers.searchModel': 'Search or type model ID...',
|
||||
'ai.providers.filterModels': 'Filter models...',
|
||||
'ai.providers.loadingModels': 'Loading models...',
|
||||
'ai.providers.noMatchingModels': 'No matching models',
|
||||
'ai.providers.clickToLoadModels': 'Click to load models',
|
||||
'ai.providers.showingModels': 'Showing first 100 of {count} models. Type to filter.',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT subscription here, or configure an OpenAI provider API key (passed as CODEX_API_KEY).',
|
||||
'ai.codex.detecting': 'Detecting...',
|
||||
'ai.codex.notFound': 'Not found',
|
||||
'ai.codex.awaitingLogin': 'Awaiting login',
|
||||
'ai.codex.connectedChatGPT': 'Connected via ChatGPT',
|
||||
'ai.codex.connectedApiKey': 'Connected via API key',
|
||||
'ai.codex.notConnected': 'Not connected',
|
||||
'ai.codex.statusUnknown': 'Status unknown',
|
||||
'ai.codex.path': 'Path:',
|
||||
'ai.codex.notFoundHint': 'Could not find codex in PATH. Install it or specify the executable path below.',
|
||||
'ai.codex.customPathPlaceholder': 'e.g. /usr/local/bin/codex',
|
||||
'ai.codex.check': 'Check',
|
||||
'ai.codex.openLogin': 'Open Login',
|
||||
'ai.codex.logout': 'Logout',
|
||||
'ai.codex.connectChatGPT': 'Connect ChatGPT',
|
||||
'ai.codex.refreshStatus': 'Refresh Status',
|
||||
'ai.codex.apiKeyHint': 'Enabled OpenAI provider API key detected. Codex ACP can also authenticate without ChatGPT login.',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': "Anthropic's agentic coding assistant. Uses claude-code-acp for ACP protocol streaming.",
|
||||
'ai.claude.detecting': 'Detecting...',
|
||||
'ai.claude.detected': 'Detected',
|
||||
'ai.claude.notFound': 'Not found',
|
||||
'ai.claude.path': 'Path:',
|
||||
'ai.claude.notFoundHint': 'Could not find claude in PATH. Install it or specify the executable path below.',
|
||||
'ai.claude.customPathPlaceholder': 'e.g. /usr/local/bin/claude',
|
||||
'ai.claude.check': 'Check',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': 'Default Agent',
|
||||
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
|
||||
'ai.defaultAgent.catty': 'Catty (Built-in)',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
|
||||
'ai.chat.toolDenied': 'Action was rejected by the user.',
|
||||
'ai.chat.toolApprovalTitle': 'Permission Required',
|
||||
'ai.chat.toolApproved': 'Approved',
|
||||
'ai.chat.toolApprovalHint': 'Press Enter to approve, Escape to reject',
|
||||
'ai.chat.approve': 'Approve',
|
||||
'ai.chat.reject': 'Reject',
|
||||
'ai.chat.toolLabel': 'Tool',
|
||||
'ai.chat.targetLabel': 'Target',
|
||||
'ai.chat.permissionRequired': 'Permission Required',
|
||||
'ai.chat.permissionDescription': 'The AI agent wants to execute a tool call that requires your approval.',
|
||||
'ai.chat.commandBlocked': 'This command is blocked by your security policy and cannot be executed.',
|
||||
'ai.chat.recommendAllow': 'Allow',
|
||||
'ai.chat.recommendConfirm': 'Confirm',
|
||||
'ai.chat.recommendDeny': 'Deny',
|
||||
'ai.chat.exportConversation': 'Export conversation',
|
||||
'ai.chat.exportAs': 'Export As',
|
||||
'ai.chat.exportMarkdown': 'Markdown',
|
||||
'ai.chat.exportJSON': 'JSON',
|
||||
'ai.chat.exportPlainText': 'Plain Text',
|
||||
'ai.chat.thinking': 'Thinking',
|
||||
'ai.chat.thoughtFor': 'Thought for {duration}',
|
||||
'ai.chat.thought': 'Thought',
|
||||
'ai.chat.agents': 'Agents',
|
||||
'ai.chat.detectedOnMachine': 'Detected on this machine',
|
||||
'ai.chat.rescan': 'Re-scan',
|
||||
'ai.chat.permObserver': 'Observer',
|
||||
'ai.chat.permConfirm': 'Confirm',
|
||||
'ai.chat.permAuto': 'Auto',
|
||||
'ai.chat.permObserverDesc': 'Read only',
|
||||
'ai.chat.permConfirmDesc': 'Ask before actions',
|
||||
'ai.chat.permAutoDesc': 'Execute freely',
|
||||
'ai.chat.emptyHint': 'Ask about your servers, run commands, or get help with configurations.',
|
||||
'ai.chat.placeholder': 'Message {agent} — @ to include context, / for commands',
|
||||
'ai.chat.placeholderDefault': 'Message Catty Agent...',
|
||||
'ai.chat.noModel': 'No model',
|
||||
'ai.chat.recent': 'Recent',
|
||||
'ai.chat.viewAll': 'View All',
|
||||
'ai.chat.untitled': 'Untitled',
|
||||
'ai.chat.justNow': 'Just now',
|
||||
'ai.chat.minutesAgo': '{n}m ago',
|
||||
'ai.chat.hoursAgo': '{n}h ago',
|
||||
'ai.chat.daysAgo': '{n}d ago',
|
||||
'ai.chat.newChat': 'New Chat',
|
||||
'ai.chat.allSessions': 'All Sessions',
|
||||
'ai.chat.noSessions': 'No previous sessions',
|
||||
'ai.chat.retryHint': 'You can retry by sending your message again.',
|
||||
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
|
||||
'ai.chat.menuHosts': 'Hosts',
|
||||
'ai.chat.menuContext': 'Context',
|
||||
'ai.chat.menuFiles': 'Files',
|
||||
'ai.chat.menuImage': 'Image',
|
||||
'ai.chat.menuMentionHost': 'Mention Host',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
|
||||
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': 'Safety',
|
||||
'ai.safety.permissionMode': 'Permission Mode',
|
||||
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your terminals. Observer mode blocks all write operations via MCP Server, enforced for both built-in and ACP agents. Confirm mode is advisory for ACP agents (they control their own tool approval flow).',
|
||||
'ai.safety.permissionMode.observer': 'Observer - Read only, no actions',
|
||||
'ai.safety.permissionMode.confirm': 'Confirm - Ask before actions',
|
||||
'ai.safety.permissionMode.autonomous': 'Autonomous - Execute freely',
|
||||
'ai.safety.commandTimeout': 'Command Timeout',
|
||||
'ai.safety.commandTimeout.description': 'Maximum seconds a command can run before being terminated. Applies to both built-in and ACP agents.',
|
||||
'ai.safety.commandTimeout.unit': 'sec',
|
||||
'ai.safety.maxIterations': 'Max Iterations',
|
||||
'ai.safety.maxIterations.description': 'Maximum number of AI tool-use loops to prevent runaway execution. ACP agents may have their own internal iteration limits that take precedence.',
|
||||
'ai.safety.blocklist': 'Command Blocklist',
|
||||
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents via MCP Server.',
|
||||
'ai.safety.blocklist.placeholder': 'Regex pattern...',
|
||||
'ai.safety.blocklist.reset': 'Reset to defaults',
|
||||
'ai.safety.blocklist.add': 'Add pattern',
|
||||
'ai.safety.note': 'Command Blocklist, Command Timeout, and Observer mode are enforced at the MCP Server level, applying to all agent types. Confirm mode and Max Iterations are fully enforced for the built-in agent; ACP agents may have their own internal controls for these settings.',
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -22,6 +22,7 @@ const zhCN: Messages = {
|
||||
'common.use': '使用',
|
||||
'common.left': '左侧',
|
||||
'common.right': '右侧',
|
||||
'common.more': '更多',
|
||||
'common.selectAHost': '选择主机',
|
||||
'sort.az': 'A-z',
|
||||
'sort.za': 'Z-a',
|
||||
@@ -36,12 +37,16 @@ const zhCN: Messages = {
|
||||
// Dialogs / prompts
|
||||
'confirm.deleteHost': '删除主机 "{name}"?',
|
||||
'confirm.deleteIdentity': '删除身份 "{name}"?',
|
||||
'confirm.removeProvider': '移除提供商 "{name}"?',
|
||||
'dialog.renameWorkspace.title': '重命名工作区',
|
||||
'dialog.renameSession.title': '重命名会话',
|
||||
'field.name': '名称',
|
||||
'placeholder.workspaceName': '工作区名称',
|
||||
'placeholder.sessionName': '会话名称',
|
||||
'toast.settingsUnavailable': '当前平台无法打开设置窗口。',
|
||||
'credentials.protectionUnavailable.title': '凭据保护不可用',
|
||||
'credentials.protectionUnavailable.message': '当前设备无法自动解密已保存的密码和密钥。连接前请重新输入凭据。',
|
||||
'credentials.protectionUnavailable.action': '打开设置',
|
||||
|
||||
// Settings shell
|
||||
'settings.title': '设置',
|
||||
@@ -65,6 +70,35 @@ const zhCN: Messages = {
|
||||
'settings.system.clearing': '清理中...',
|
||||
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
|
||||
'settings.system.tempDirectoryHint': '临时文件在使用外部应用打开远程文件时创建。SFTP 会话关闭时会自动清理。',
|
||||
'settings.system.credentials.title': '凭据保护',
|
||||
'settings.system.credentials.status': '状态',
|
||||
'settings.system.credentials.checking': '检查中...',
|
||||
'settings.system.credentials.available': '可用(系统钥匙串正常)',
|
||||
'settings.system.credentials.unavailable': '不可用(无法解密已保存凭据)',
|
||||
'settings.system.credentials.unknown': '未知(当前环境不支持)',
|
||||
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
|
||||
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
|
||||
|
||||
// Settings > System > Software Update
|
||||
'settings.update.title': '软件更新',
|
||||
'settings.update.currentVersion': '当前版本',
|
||||
'settings.update.checkForUpdates': '检查更新',
|
||||
'settings.update.checking': '检查中...',
|
||||
'settings.update.upToDate': '当前已是最新版本。',
|
||||
'settings.update.available': '新版本 {version} 已发布。',
|
||||
'settings.update.download': '下载更新',
|
||||
'settings.update.downloading': '正在下载... {percent}%',
|
||||
'settings.update.readyToInstall': '更新已下载,准备安装。',
|
||||
'settings.update.restartNow': '重启并更新',
|
||||
'settings.update.error': '检查更新失败。',
|
||||
'settings.update.downloadError': '下载失败。',
|
||||
'settings.update.manualDownload': '前往 GitHub 下载',
|
||||
'settings.update.manualDownloadHint': '当前平台不支持自动更新,请前往 GitHub 下载最新版本。',
|
||||
'settings.update.hint': 'Netcatty 从 GitHub Releases 检查更新。',
|
||||
'settings.update.lastCheckedJustNow': '刚刚',
|
||||
'settings.update.lastCheckedMinutesAgo': '{n} 分钟前',
|
||||
'settings.update.lastCheckedHoursAgo': '{n} 小时前',
|
||||
'settings.update.lastCheckedPrefix': '上次检查:',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': '会话日志',
|
||||
@@ -84,6 +118,35 @@ const zhCN: Messages = {
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.hint': '会话日志用于记录终端输出,便于故障排查和审计。',
|
||||
|
||||
// Settings > Global Hotkey (Quake Mode)
|
||||
'settings.globalHotkey.title': '全局快捷键',
|
||||
'settings.globalHotkey.toggleWindow': '切换窗口',
|
||||
'settings.globalHotkey.toggleWindowDesc': '按下组合键以设置显示/隐藏窗口的全局快捷键。',
|
||||
'settings.globalHotkey.notSet': '未设置',
|
||||
'settings.globalHotkey.reset': '恢复默认',
|
||||
'settings.globalHotkey.closeToTray': '关闭时最小化到托盘',
|
||||
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口将最小化到系统托盘而不是退出程序。',
|
||||
'settings.globalHotkey.hint': '全局快捷键在系统范围内工作,可快速显示或隐藏窗口(下拉式终端风格)。',
|
||||
|
||||
// Tray Panel
|
||||
'tray.openMainWindow': '打开主窗口',
|
||||
'tray.sessions': '会话',
|
||||
'tray.portForwarding': '端口转发',
|
||||
'tray.status.connected': '已连接',
|
||||
'tray.status.connecting': '连接中',
|
||||
'tray.status.disconnected': '已断开',
|
||||
'tray.status.active': '已启用',
|
||||
'tray.status.inactive': '未启用',
|
||||
'tray.status.error': '错误',
|
||||
'tray.recentHosts': '最近连接的主机',
|
||||
'tray.empty.title': '一切都很安静',
|
||||
'tray.empty.subtitle': '去连接个服务器吧,它们想念你了 🚀',
|
||||
'tray.quit': '退出 Netcatty',
|
||||
|
||||
// Vault Sidebar
|
||||
'vault.sidebar.collapse': '收起侧边栏',
|
||||
'vault.sidebar.expand': '展开侧边栏',
|
||||
|
||||
// Settings > Application
|
||||
'settings.application.checkUpdates': '检查更新',
|
||||
'settings.application.reportProblem': '反馈问题',
|
||||
@@ -103,13 +166,23 @@ const zhCN: Messages = {
|
||||
'update.upToDate.message': '当前版本 ({version}) 已是最新。',
|
||||
'update.error': '检查更新失败',
|
||||
'update.downloadNow': '立即下载',
|
||||
'update.viewInSettings': '在设置中查看',
|
||||
'update.readyToInstall.title': '更新已就绪',
|
||||
'update.readyToInstall.message': '版本 {version} 已下载完成,准备安装。',
|
||||
'update.restartNow': '立即重启',
|
||||
'update.downloadFailed.title': '更新失败',
|
||||
'update.downloadFailed.message': '下载更新失败,可前往 GitHub 手动下载。',
|
||||
'update.openReleases': '打开 Releases',
|
||||
'update.remindLater': '稍后提醒',
|
||||
'update.skipVersion': '跳过此版本',
|
||||
|
||||
// Settings > Appearance
|
||||
'settings.appearance.uiTheme': '界面主题',
|
||||
'settings.appearance.darkMode': '深色模式',
|
||||
'settings.appearance.darkMode.desc': '在浅色与深色主题之间切换',
|
||||
'settings.appearance.theme': '主题',
|
||||
'settings.appearance.theme.desc': '选择浅色、深色或跟随系统设置',
|
||||
'settings.appearance.theme.light': '浅色',
|
||||
'settings.appearance.theme.dark': '深色',
|
||||
'settings.appearance.theme.system': '系统',
|
||||
'settings.appearance.accentColor': '强调色',
|
||||
'settings.appearance.customColor': '自定义颜色',
|
||||
'settings.appearance.accentColor.mode': '使用自定义强调色',
|
||||
@@ -162,6 +235,7 @@ const zhCN: Messages = {
|
||||
'sync.autoSync.vaultLocked': 'Vault 处于锁定状态。请打开 设置 → Sync & Cloud 解锁。',
|
||||
'sync.autoSync.conflictDetected': '检测到同步冲突。请打开 设置 → Sync & Cloud 处理。',
|
||||
'sync.autoSync.syncFailed': '同步失败',
|
||||
'sync.credentialsUnavailable': '当前设备无法解密部分已保存凭据。请先在本地重新输入凭据后再同步。',
|
||||
'time.never': '从未',
|
||||
'time.justNow': '刚刚',
|
||||
'time.minutesAgo': '{minutes} 分钟前',
|
||||
@@ -228,6 +302,8 @@ const zhCN: Messages = {
|
||||
'vault.hosts.deselectAll': '取消全选',
|
||||
'vault.hosts.deleteSelected': '删除 ({count})',
|
||||
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': '添加数据到你的 Vault',
|
||||
@@ -324,6 +400,9 @@ const zhCN: Messages = {
|
||||
'sftp.newFile': '新建文件',
|
||||
'sftp.filter': '筛选',
|
||||
'sftp.filter.placeholder': '按文件名筛选...',
|
||||
'sftp.bookmark.add': '收藏此路径',
|
||||
'sftp.bookmark.remove': '取消收藏',
|
||||
'sftp.bookmark.empty': '暂无收藏路径',
|
||||
'sftp.columns.name': '名称',
|
||||
'sftp.columns.modified': '修改时间',
|
||||
'sftp.columns.size': '大小',
|
||||
@@ -356,7 +435,11 @@ const zhCN: Messages = {
|
||||
'sftp.status.loading': '加载中...',
|
||||
'sftp.status.uploading': '上传中...',
|
||||
'sftp.status.ready': '就绪',
|
||||
'sftp.transfers': '传输',
|
||||
'sftp.transfers.active': '{count} 个进行中',
|
||||
'sftp.transfers.clearCompleted': '清除已完成',
|
||||
'sftp.goUp': '上一级',
|
||||
'sftp.goToTerminalCwd': '定位到终端当前目录',
|
||||
'sftp.encoding.label': '文件名编码',
|
||||
'sftp.encoding.auto': '自动',
|
||||
'sftp.encoding.utf8': 'UTF-8',
|
||||
@@ -404,9 +487,6 @@ const zhCN: Messages = {
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': '搜索主机或标签页',
|
||||
'qs.recentConnections': '最近连接',
|
||||
'qs.createWorkspace': '创建工作区',
|
||||
'qs.restore': '恢复',
|
||||
'qs.jumpTo': '跳转到',
|
||||
'qs.localTerminal': '本地终端',
|
||||
|
||||
@@ -465,9 +545,13 @@ const zhCN: Messages = {
|
||||
'hostDetails.certs.empty': '暂无证书',
|
||||
'hostDetails.agentForwarding': '转发 SSH 密钥',
|
||||
'hostDetails.agentForwarding.desc': '允许远程服务器使用本地 SSH 密钥(例如用于 git 操作)',
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 未运行',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': '请在 Windows 服务管理器 (services.msc) 中启用 OpenSSH Authentication Agent 服务。',
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 不可用',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': '未检测到 SSH Agent。请启用 Windows OpenSSH Authentication Agent 服务,或使用兼容的 Agent(如 Bitwarden、1Password、gpg-agent)。',
|
||||
'hostDetails.section.agentForwarding': 'SSH 代理',
|
||||
'hostDetails.section.legacyAlgorithms': '旧版算法',
|
||||
'hostDetails.legacyAlgorithms': '允许旧版算法',
|
||||
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法(diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
|
||||
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
|
||||
'hostDetails.jumpHosts': '通过主机代理',
|
||||
'hostDetails.jumpHosts.hops': '{count} 跳',
|
||||
'hostDetails.jumpHosts.direct': '直连',
|
||||
@@ -554,9 +638,26 @@ const zhCN: Messages = {
|
||||
'terminal.toolbar.broadcast': '广播',
|
||||
'terminal.toolbar.broadcastEnable': '启用广播模式',
|
||||
'terminal.toolbar.broadcastDisable': '关闭广播模式',
|
||||
'terminal.toolbar.composeBar': '撰写栏',
|
||||
'terminal.composeBar.placeholder': '在此输入命令,按回车发送...',
|
||||
'terminal.composeBar.send': '发送',
|
||||
'terminal.composeBar.close': '关闭撰写栏',
|
||||
'terminal.composeBar.broadcasting': '正在广播到所有会话',
|
||||
'terminal.toolbar.focus': '聚焦',
|
||||
'terminal.toolbar.focusMode': '聚焦模式',
|
||||
'terminal.toolbar.encoding': '终端编码',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
'terminal.toolbar.closeSession': '关闭会话',
|
||||
'terminal.toolbar.hostHighlight.title': '主机关键字高亮',
|
||||
'terminal.toolbar.hostHighlight.noRules': '此主机未定义自定义高亮规则',
|
||||
'terminal.toolbar.hostHighlight.addRule': '添加新规则',
|
||||
'terminal.toolbar.hostHighlight.labelPlaceholder': '标签(例如:错误)',
|
||||
'terminal.toolbar.hostHighlight.patternPlaceholder': '正则表达式(例如:\\bfailed\\b)',
|
||||
'terminal.toolbar.hostHighlight.invalidPattern': '无效的正则表达式',
|
||||
'terminal.toolbar.hostHighlight.clearAll': '清除全部',
|
||||
'terminal.toolbar.hostHighlight.changeColor': '更改高亮颜色',
|
||||
'terminal.toolbar.hostHighlight.selectColor': '选择新规则的颜色',
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.memory': '内存使用',
|
||||
@@ -565,6 +666,10 @@ const zhCN: Messages = {
|
||||
'terminal.serverStats.memBuffers': '缓冲区',
|
||||
'terminal.serverStats.memCached': '缓存',
|
||||
'terminal.serverStats.memFree': '空闲',
|
||||
'terminal.serverStats.swap': '交换空间',
|
||||
'terminal.serverStats.swapUsed': '已用交换',
|
||||
'terminal.serverStats.swapFree': '空闲交换',
|
||||
'terminal.serverStats.swapTotal': '总计',
|
||||
'terminal.serverStats.topProcesses': '内存占用前十进程',
|
||||
'terminal.serverStats.disk': '磁盘使用(根分区)',
|
||||
'terminal.serverStats.diskDetails': '已挂载磁盘',
|
||||
@@ -601,6 +706,10 @@ const zhCN: Messages = {
|
||||
'terminal.auth.selectKey': '选择密钥',
|
||||
'terminal.auth.noKeysHint': '暂无密钥,请先在钥匙串中添加。',
|
||||
'terminal.auth.continueSave': '继续并保存',
|
||||
'terminal.auth.credentialsUnavailable': '当前设备无法解密已保存凭据,请重新输入并再次保存。',
|
||||
'terminal.auth.jumpCredentialsUnavailable': '某个跳板机的已保存凭据无法在当前设备解密,请到主机设置中重新填写。',
|
||||
'terminal.auth.proxyCredentialsUnavailable': '代理凭据无法在当前设备解密,请到主机设置中重新填写代理密码。',
|
||||
'terminal.auth.keyUnavailableFallbackPassword': '已保存 SSH 密钥在当前设备不可用,改用密码认证。',
|
||||
'terminal.connectionErrorTitle': '连接错误',
|
||||
'terminal.progress.timeoutIn': '将在 {seconds}s 后超时',
|
||||
'terminal.progress.disconnected': '已断开',
|
||||
@@ -617,10 +726,50 @@ const zhCN: Messages = {
|
||||
'terminal.themeModal.title': 'Terminal 外观',
|
||||
'terminal.themeModal.tab.theme': '主题',
|
||||
'terminal.themeModal.tab.font': '字体',
|
||||
'terminal.themeModal.tab.custom': '自定义',
|
||||
'terminal.themeModal.fontSize': '字体大小',
|
||||
'terminal.themeModal.livePreview': '实时预览',
|
||||
'terminal.themeModal.themeType': '{type} 主题',
|
||||
// Cloud Sync Settings
|
||||
|
||||
// Custom Themes
|
||||
'terminal.customTheme.section': '自定义主题',
|
||||
'terminal.customTheme.yourThemes': '我的主题',
|
||||
'terminal.customTheme.new': '新建主题',
|
||||
'terminal.customTheme.newDesc': '克隆当前主题并自定义',
|
||||
'terminal.customTheme.newTitle': '新建自定义主题',
|
||||
'terminal.customTheme.editTitle': '编辑主题',
|
||||
'terminal.customTheme.import': '导入 .itermcolors',
|
||||
'terminal.customTheme.importDesc': '从 iTerm2 配色方案文件导入',
|
||||
'terminal.customTheme.importError': '无法解析所选文件,请确保它是有效的 .itermcolors XML 文件。',
|
||||
'terminal.customTheme.delete': '删除主题',
|
||||
'terminal.customTheme.confirmDelete': '确认删除',
|
||||
'terminal.customTheme.name': '名称',
|
||||
'terminal.customTheme.namePlaceholder': '我的自定义主题',
|
||||
'terminal.customTheme.type': '类型',
|
||||
'terminal.customTheme.group.general': '通用',
|
||||
'terminal.customTheme.group.normal': '标准色',
|
||||
'terminal.customTheme.group.bright': '高亮色',
|
||||
'terminal.customTheme.color.background': '背景',
|
||||
'terminal.customTheme.color.foreground': '前景',
|
||||
'terminal.customTheme.color.cursor': '光标',
|
||||
'terminal.customTheme.color.selection': '选区',
|
||||
'terminal.customTheme.color.black': '黑色',
|
||||
'terminal.customTheme.color.red': '红色',
|
||||
'terminal.customTheme.color.green': '绿色',
|
||||
'terminal.customTheme.color.yellow': '黄色',
|
||||
'terminal.customTheme.color.blue': '蓝色',
|
||||
'terminal.customTheme.color.magenta': '品红',
|
||||
'terminal.customTheme.color.cyan': '青色',
|
||||
'terminal.customTheme.color.white': '白色',
|
||||
'terminal.customTheme.color.brightBlack': '亮黑',
|
||||
'terminal.customTheme.color.brightRed': '亮红',
|
||||
'terminal.customTheme.color.brightGreen': '亮绿',
|
||||
'terminal.customTheme.color.brightYellow': '亮黄',
|
||||
'terminal.customTheme.color.brightBlue': '亮蓝',
|
||||
'terminal.customTheme.color.brightMagenta': '亮品红',
|
||||
'terminal.customTheme.color.brightCyan': '亮青色',
|
||||
'terminal.customTheme.color.brightWhite': '亮白',
|
||||
|
||||
'cloudSync.gate.title': '端到端加密同步',
|
||||
'cloudSync.gate.desc':
|
||||
'数据会在本地加密后再同步,云端不会看到明文。设置主密钥以启用安全同步。',
|
||||
@@ -659,6 +808,7 @@ const zhCN: Messages = {
|
||||
'cloudSync.webdav.password': '密码',
|
||||
'cloudSync.webdav.token': 'Token',
|
||||
'cloudSync.webdav.showSecret': '显示密钥',
|
||||
'cloudSync.webdav.allowInsecure': '允许不安全的连接(忽略证书错误)',
|
||||
'cloudSync.webdav.validation.endpoint': '请输入有效的 WebDAV 端点。',
|
||||
'cloudSync.webdav.validation.credentials': '请输入用户名和密码。',
|
||||
'cloudSync.webdav.validation.token': '请输入 Token。',
|
||||
@@ -779,6 +929,7 @@ const zhCN: Messages = {
|
||||
'common.import': '导入',
|
||||
'common.generate': '生成',
|
||||
'common.delete': '删除',
|
||||
'common.edit': '编辑',
|
||||
'common.clear': '清除',
|
||||
'common.optional': '可选',
|
||||
'common.selectPlaceholder': '请选择...',
|
||||
@@ -807,6 +958,13 @@ const zhCN: Messages = {
|
||||
'pf.view.list': '列表',
|
||||
'pf.rule.summary.dynamic': 'SOCKS 监听于 {bindAddress}:{localPort}',
|
||||
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
|
||||
'pf.tooltip.relayHost': '中转主机',
|
||||
'pf.tooltip.hostLabel': '主机',
|
||||
'pf.tooltip.hostAddress': '地址',
|
||||
'pf.tooltip.noHost': '未配置中转主机',
|
||||
'pf.tooltip.localDesc': '本地端口转发:通过 SSH 隧道访问远程服务',
|
||||
'pf.tooltip.remoteDesc': '远程端口转发:将本地服务暴露给远程主机',
|
||||
'pf.tooltip.dynamicDesc': '动态 SOCKS 代理:通过 SSH 隧道转发流量',
|
||||
'pf.deleteActive.title': '删除正在运行的端口转发?',
|
||||
'pf.deleteActive.desc': '端口转发规则 "{label}" 当前正在运行。删除前将先关闭转发连接。',
|
||||
'pf.deleteActive.confirm': '关闭并删除',
|
||||
@@ -912,6 +1070,7 @@ const zhCN: Messages = {
|
||||
'sftp.upload.currentFile': '当前: {fileName}',
|
||||
'sftp.upload.cancelled': '上传已取消',
|
||||
'sftp.upload.cancel': '取消',
|
||||
'sftp.upload.completedToPath': '已上传至 {path}',
|
||||
|
||||
// SFTP Download
|
||||
'sftp.download.completed': '已下载',
|
||||
@@ -928,9 +1087,9 @@ const zhCN: Messages = {
|
||||
|
||||
// Settings > SFTP Show Hidden Files
|
||||
'settings.sftp.showHiddenFiles': '显示隐藏文件',
|
||||
'settings.sftp.showHiddenFiles.desc': '在浏览本地 Windows 文件系统时,显示具有 Windows 隐藏属性的文件。',
|
||||
'settings.sftp.showHiddenFiles.desc': '在 SFTP 文件浏览器中显示隐藏文件(Unix/macOS 点文件和 Windows 隐藏属性文件)。',
|
||||
'settings.sftp.showHiddenFiles.enable': '显示隐藏文件',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': '浏览本地文件系统时显示 Windows 隐藏文件',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': '浏览本地和远程文件系统时显示隐藏文件',
|
||||
|
||||
// Settings > SFTP Compressed Upload
|
||||
'settings.sftp.compressedUpload': '文件夹压缩传输',
|
||||
@@ -977,9 +1136,12 @@ const zhCN: Messages = {
|
||||
'settings.terminal.behavior.rightClick.paste': '粘贴',
|
||||
'settings.terminal.behavior.rightClick.selectWord': '选择单词',
|
||||
'settings.terminal.behavior.copyOnSelect': '选择即复制',
|
||||
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本',
|
||||
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本。在 tmux/vim 鼠标模式下,macOS 按住 Option,Windows/Linux 按住 Shift 拖选即可选中文本',
|
||||
'settings.terminal.behavior.middleClickPaste': '中键粘贴',
|
||||
'settings.terminal.behavior.middleClickPaste.desc': '中键点击时粘贴剪贴板内容',
|
||||
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
|
||||
'settings.terminal.behavior.scrollOnInput': '输入时自动滚动',
|
||||
'settings.terminal.behavior.scrollOnInput.desc': '输入时将终端滚动到底部',
|
||||
'settings.terminal.behavior.scrollOnOutput': '输出时自动滚动',
|
||||
@@ -1041,6 +1203,36 @@ const zhCN: Messages = {
|
||||
'settings.shortcuts.category.terminal': '终端',
|
||||
'settings.shortcuts.category.navigation': '导航',
|
||||
'settings.shortcuts.category.app': '应用',
|
||||
'settings.shortcuts.category.sftp': 'SFTP',
|
||||
'settings.shortcuts.binding.switch-tab-1-9': '切换到标签页 [1...9]',
|
||||
'settings.shortcuts.binding.next-tab': '下一个标签页',
|
||||
'settings.shortcuts.binding.prev-tab': '上一个标签页',
|
||||
'settings.shortcuts.binding.close-tab': '关闭标签页',
|
||||
'settings.shortcuts.binding.new-tab': '新建本地标签页',
|
||||
'settings.shortcuts.binding.copy': '从终端复制',
|
||||
'settings.shortcuts.binding.paste': '粘贴到终端',
|
||||
'settings.shortcuts.binding.select-all': '全选终端内容',
|
||||
'settings.shortcuts.binding.clear-buffer': '清空终端缓冲区',
|
||||
'settings.shortcuts.binding.search-terminal': '打开终端搜索',
|
||||
'settings.shortcuts.binding.move-focus': '在分屏间移动焦点',
|
||||
'settings.shortcuts.binding.split-horizontal': '水平分屏',
|
||||
'settings.shortcuts.binding.split-vertical': '垂直分屏',
|
||||
'settings.shortcuts.binding.open-hosts': '打开主机列表',
|
||||
'settings.shortcuts.binding.open-local': '打开本地终端',
|
||||
'settings.shortcuts.binding.open-sftp': '打开 SFTP',
|
||||
'settings.shortcuts.binding.port-forwarding': '打开端口转发',
|
||||
'settings.shortcuts.binding.command-palette': '打开命令面板',
|
||||
'settings.shortcuts.binding.quick-switch': '快速切换',
|
||||
'settings.shortcuts.binding.snippets': '打开代码片段',
|
||||
'settings.shortcuts.binding.broadcast': '切换广播模式',
|
||||
'settings.shortcuts.binding.sftp-copy': '复制文件',
|
||||
'settings.shortcuts.binding.sftp-cut': '剪切文件',
|
||||
'settings.shortcuts.binding.sftp-paste': '粘贴文件',
|
||||
'settings.shortcuts.binding.sftp-select-all': '全选文件',
|
||||
'settings.shortcuts.binding.sftp-rename': '重命名文件',
|
||||
'settings.shortcuts.binding.sftp-delete': '删除文件',
|
||||
'settings.shortcuts.binding.sftp-refresh': '刷新',
|
||||
'settings.shortcuts.binding.sftp-new-folder': '新建文件夹',
|
||||
|
||||
// Host Details (sub-panels)
|
||||
'hostDetails.proxyPanel.title': 'Proxy',
|
||||
@@ -1226,6 +1418,15 @@ const zhCN: Messages = {
|
||||
'snippets.renameDialog.error.duplicate': '已存在同名的代码包',
|
||||
'snippets.renameDialog.error.invalidChars': '代码包名称只能包含字母、数字、连字符和下划线',
|
||||
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': '快捷键',
|
||||
'snippets.shortkey.placeholder': '点击设置快捷键',
|
||||
'snippets.shortkey.recording': '请按下快捷键组合...',
|
||||
'snippets.shortkey.hint': '在终端中按下此快捷键可快速发送命令。',
|
||||
'snippets.shortkey.clear': '清除快捷键',
|
||||
'snippets.shortkey.error.systemConflict': '此快捷键与系统快捷键冲突',
|
||||
'snippets.shortkey.error.snippetConflict': '此快捷键已被代码片段使用:{name}',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': '串口',
|
||||
'serial.modal.title': '连接串口',
|
||||
@@ -1290,6 +1491,151 @@ const zhCN: Messages = {
|
||||
'passphrase.unlock': '解锁',
|
||||
'passphrase.unlocking': '解锁中...',
|
||||
'passphrase.skip': '跳过',
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': '自动换行',
|
||||
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 设置',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': '配置 AI 提供商、Agent 和安全设置',
|
||||
'ai.providers': '提供商',
|
||||
'ai.providers.empty': '尚未配置提供商。添加一个提供商以开始使用。',
|
||||
'ai.providers.add': '添加提供商',
|
||||
'ai.providers.active': '活跃',
|
||||
'ai.providers.apiKeyConfigured': 'API Key 已配置',
|
||||
'ai.providers.noApiKey': '未设置 API Key',
|
||||
'ai.providers.configure': '配置',
|
||||
'ai.providers.remove': '移除',
|
||||
'ai.providers.name': '显示名称',
|
||||
'ai.providers.name.placeholder': '例如 我的提供商',
|
||||
'ai.providers.apiKey': 'API Key',
|
||||
'ai.providers.apiKey.placeholder': '输入 API Key',
|
||||
'ai.providers.apiKey.decrypting': '解密中...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.defaultModel': '默认模型',
|
||||
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.refreshModels': '刷新模型列表',
|
||||
'ai.providers.searchModel': '搜索或输入模型 ID...',
|
||||
'ai.providers.filterModels': '筛选模型...',
|
||||
'ai.providers.loadingModels': '加载模型中...',
|
||||
'ai.providers.noMatchingModels': '没有匹配的模型',
|
||||
'ai.providers.clickToLoadModels': '点击加载模型',
|
||||
'ai.providers.showingModels': '显示前 100 个,共 {count} 个模型。输入以筛选。',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。在此通过 ChatGPT 订阅登录,或配置 OpenAI 提供商的 API Key(将作为 CODEX_API_KEY 传递)。',
|
||||
'ai.codex.detecting': '检测中...',
|
||||
'ai.codex.notFound': '未找到',
|
||||
'ai.codex.awaitingLogin': '等待登录',
|
||||
'ai.codex.connectedChatGPT': '已通过 ChatGPT 连接',
|
||||
'ai.codex.connectedApiKey': '已通过 API Key 连接',
|
||||
'ai.codex.notConnected': '未连接',
|
||||
'ai.codex.statusUnknown': '状态未知',
|
||||
'ai.codex.path': '路径:',
|
||||
'ai.codex.notFoundHint': '在 PATH 中未找到 codex。请安装或在下方指定可执行文件路径。',
|
||||
'ai.codex.customPathPlaceholder': '例如 /usr/local/bin/codex',
|
||||
'ai.codex.check': '检查',
|
||||
'ai.codex.openLogin': '打开登录',
|
||||
'ai.codex.logout': '退出登录',
|
||||
'ai.codex.connectChatGPT': '连接 ChatGPT',
|
||||
'ai.codex.refreshStatus': '刷新状态',
|
||||
'ai.codex.apiKeyHint': '检测到已启用的 OpenAI 提供商 API Key。Codex ACP 也可以无需 ChatGPT 登录进行认证。',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': 'Anthropic 的智能编程助手。使用 claude-code-acp 进行 ACP 协议流式传输。',
|
||||
'ai.claude.detecting': '检测中...',
|
||||
'ai.claude.detected': '已检测到',
|
||||
'ai.claude.notFound': '未找到',
|
||||
'ai.claude.path': '路径:',
|
||||
'ai.claude.notFoundHint': '在 PATH 中未找到 claude。请安装或在下方指定可执行文件路径。',
|
||||
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
|
||||
'ai.claude.check': '检查',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': '默认 Agent',
|
||||
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
|
||||
'ai.defaultAgent.catty': 'Catty(内置)',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
|
||||
'ai.chat.toolDenied': '操作已被用户拒绝。',
|
||||
'ai.chat.toolApprovalTitle': '需要权限确认',
|
||||
'ai.chat.toolApproved': '已批准',
|
||||
'ai.chat.toolApprovalHint': '按回车批准,按 Esc 拒绝',
|
||||
'ai.chat.approve': '批准',
|
||||
'ai.chat.reject': '拒绝',
|
||||
'ai.chat.toolLabel': '工具',
|
||||
'ai.chat.targetLabel': '目标',
|
||||
'ai.chat.permissionRequired': '需要权限',
|
||||
'ai.chat.permissionDescription': 'AI Agent 希望执行一个需要你批准的工具调用。',
|
||||
'ai.chat.commandBlocked': '此命令已被安全策略拦截,无法执行。',
|
||||
'ai.chat.recommendAllow': '允许',
|
||||
'ai.chat.recommendConfirm': '确认',
|
||||
'ai.chat.recommendDeny': '拒绝',
|
||||
'ai.chat.exportConversation': '导出对话',
|
||||
'ai.chat.exportAs': '导出为',
|
||||
'ai.chat.exportMarkdown': 'Markdown',
|
||||
'ai.chat.exportJSON': 'JSON',
|
||||
'ai.chat.exportPlainText': '纯文本',
|
||||
'ai.chat.thinking': '思考中',
|
||||
'ai.chat.thoughtFor': '思考了 {duration}',
|
||||
'ai.chat.thought': '思考',
|
||||
'ai.chat.agents': 'Agents',
|
||||
'ai.chat.detectedOnMachine': '在本机检测到',
|
||||
'ai.chat.rescan': '重新扫描',
|
||||
'ai.chat.permObserver': '观察',
|
||||
'ai.chat.permConfirm': '确认',
|
||||
'ai.chat.permAuto': '自主',
|
||||
'ai.chat.permObserverDesc': '只读模式',
|
||||
'ai.chat.permConfirmDesc': '操作前询问',
|
||||
'ai.chat.permAutoDesc': '自由执行',
|
||||
'ai.chat.emptyHint': '询问服务器相关问题、执行命令或获取配置帮助。',
|
||||
'ai.chat.placeholder': '向 {agent} 发送消息 — @ 引用上下文,/ 使用命令',
|
||||
'ai.chat.placeholderDefault': '向 Catty Agent 发送消息...',
|
||||
'ai.chat.noModel': '未选择模型',
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '查看全部',
|
||||
'ai.chat.untitled': '无标题',
|
||||
'ai.chat.justNow': '刚刚',
|
||||
'ai.chat.minutesAgo': '{n}分钟前',
|
||||
'ai.chat.hoursAgo': '{n}小时前',
|
||||
'ai.chat.daysAgo': '{n}天前',
|
||||
'ai.chat.newChat': '新对话',
|
||||
'ai.chat.allSessions': '所有会话',
|
||||
'ai.chat.noSessions': '没有历史会话',
|
||||
'ai.chat.retryHint': '你可以重新发送消息来重试。',
|
||||
'ai.chat.approvalTimeout': '工具审批已超时(5 分钟)。你可以重新发送消息来重试。',
|
||||
'ai.chat.menuHosts': '主机',
|
||||
'ai.chat.menuContext': '上下文',
|
||||
'ai.chat.menuFiles': '文件',
|
||||
'ai.chat.menuImage': '图片',
|
||||
'ai.chat.menuMentionHost': '提及主机',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
|
||||
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': '安全',
|
||||
'ai.safety.permissionMode': '权限模式',
|
||||
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式通过 MCP Server 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性(ACP Agent 有自己的工具审批流程)。',
|
||||
'ai.safety.permissionMode.observer': '观察者 - 只读,禁止操作',
|
||||
'ai.safety.permissionMode.confirm': '确认 - 操作前询问',
|
||||
'ai.safety.permissionMode.autonomous': '自主 - 自由执行',
|
||||
'ai.safety.commandTimeout': '命令超时',
|
||||
'ai.safety.commandTimeout.description': '命令执行的最大秒数,超时将被终止。对内置和 ACP Agent 均生效。',
|
||||
'ai.safety.commandTimeout.unit': '秒',
|
||||
'ai.safety.maxIterations': '最大迭代次数',
|
||||
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。ACP Agent 可能有自己的内部迭代限制,以其为准。',
|
||||
'ai.safety.blocklist': '命令黑名单',
|
||||
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 MCP Server 对内置和 ACP Agent 均生效。',
|
||||
'ai.safety.blocklist.placeholder': '正则表达式...',
|
||||
'ai.safety.blocklist.reset': '恢复默认',
|
||||
'ai.safety.blocklist.add': '添加规则',
|
||||
'ai.safety.note': '命令黑名单、命令超时和观察者模式通过 MCP Server 层强制执行,对所有 Agent 类型生效。确认模式和最大迭代次数对内置 Agent 完全强制执行;ACP Agent 可能有自己的内部控制。',
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
|
||||
175
application/state/customThemeStore.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useSyncExternalStore, useCallback } from 'react';
|
||||
import { TerminalTheme } from '../../domain/models';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
import { STORAGE_KEY_CUSTOM_THEMES } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
// Access the Electron bridge for cross-window IPC
|
||||
type NetcattyBridge = {
|
||||
notifySettingsChanged?(payload: { key: string; value: unknown }): void;
|
||||
onSettingsChanged?(cb: (payload: { key: string; value: unknown }) => void): () => void;
|
||||
};
|
||||
const getBridge = (): NetcattyBridge | undefined =>
|
||||
(window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
|
||||
/**
|
||||
* Custom Theme Store - manages user-created terminal themes
|
||||
* Uses useSyncExternalStore pattern (same as fontStore)
|
||||
* Persists to localStorage + cross-window IPC sync
|
||||
*/
|
||||
type Listener = () => void;
|
||||
|
||||
class CustomThemeStore {
|
||||
private themes: TerminalTheme[] = [];
|
||||
private listeners = new Set<Listener>();
|
||||
/** Cached merged array for stable useSyncExternalStore snapshots */
|
||||
private cachedAllThemes: TerminalTheme[] | null = null;
|
||||
|
||||
constructor() {
|
||||
this.loadFromStorage();
|
||||
this.setupCrossWindowSync();
|
||||
}
|
||||
|
||||
private loadFromStorage = () => {
|
||||
try {
|
||||
const parsed = localStorageAdapter.read<TerminalTheme[]>(STORAGE_KEY_CUSTOM_THEMES);
|
||||
if (Array.isArray(parsed)) {
|
||||
this.themes = parsed.map((t: TerminalTheme) => ({ ...t, isCustom: true }));
|
||||
}
|
||||
} catch {
|
||||
// ignore corrupt data
|
||||
}
|
||||
this.cachedAllThemes = null; // invalidate cache
|
||||
};
|
||||
|
||||
private saveToStorage = () => {
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_CUSTOM_THEMES, this.themes);
|
||||
} catch {
|
||||
// storage full or unavailable
|
||||
}
|
||||
};
|
||||
|
||||
private notify = () => {
|
||||
this.cachedAllThemes = null; // invalidate cache on any mutation
|
||||
this.listeners.forEach(listener => listener());
|
||||
};
|
||||
|
||||
/** Broadcast change to other Electron windows via IPC */
|
||||
private broadcastChange = () => {
|
||||
try {
|
||||
getBridge()?.notifySettingsChanged?.({
|
||||
key: STORAGE_KEY_CUSTOM_THEMES,
|
||||
value: this.themes,
|
||||
});
|
||||
} catch {
|
||||
// not in Electron or bridge unavailable
|
||||
}
|
||||
};
|
||||
|
||||
/** Listen for changes from other windows and reload */
|
||||
private setupCrossWindowSync = () => {
|
||||
try {
|
||||
getBridge()?.onSettingsChanged?.((payload) => {
|
||||
if (payload.key === STORAGE_KEY_CUSTOM_THEMES) {
|
||||
// Another window changed custom themes — reload from localStorage
|
||||
this.loadFromStorage();
|
||||
this.notify();
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// not in Electron or bridge unavailable
|
||||
}
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
// ---- Getters (stable references for useSyncExternalStore) ----
|
||||
|
||||
getCustomThemes = (): TerminalTheme[] => this.themes;
|
||||
|
||||
/** Returns all themes: built-in + custom (cached for snapshot stability) */
|
||||
getAllThemes = (): TerminalTheme[] => {
|
||||
if (!this.cachedAllThemes) {
|
||||
this.cachedAllThemes = [...TERMINAL_THEMES, ...this.themes];
|
||||
}
|
||||
return this.cachedAllThemes;
|
||||
};
|
||||
|
||||
/** Find a theme by ID across both built-in and custom */
|
||||
getThemeById = (id: string): TerminalTheme | undefined => {
|
||||
return TERMINAL_THEMES.find(t => t.id === id) || this.themes.find(t => t.id === id);
|
||||
};
|
||||
|
||||
// ---- Mutations ----
|
||||
|
||||
addTheme = (theme: TerminalTheme) => {
|
||||
this.themes = [...this.themes, { ...theme, isCustom: true }];
|
||||
this.saveToStorage();
|
||||
this.notify();
|
||||
this.broadcastChange();
|
||||
};
|
||||
|
||||
updateTheme = (id: string, updates: Partial<TerminalTheme>) => {
|
||||
this.themes = this.themes.map(t =>
|
||||
t.id === id ? { ...t, ...updates, isCustom: true } : t
|
||||
);
|
||||
this.saveToStorage();
|
||||
this.notify();
|
||||
this.broadcastChange();
|
||||
};
|
||||
|
||||
deleteTheme = (id: string) => {
|
||||
this.themes = this.themes.filter(t => t.id !== id);
|
||||
this.saveToStorage();
|
||||
this.notify();
|
||||
this.broadcastChange();
|
||||
};
|
||||
}
|
||||
|
||||
// Singleton
|
||||
export const customThemeStore = new CustomThemeStore();
|
||||
|
||||
// ============== Hooks ==============
|
||||
|
||||
/** Get all themes (built-in + custom) */
|
||||
export const useAllThemes = (): TerminalTheme[] => {
|
||||
return useSyncExternalStore(
|
||||
customThemeStore.subscribe,
|
||||
customThemeStore.getAllThemes
|
||||
);
|
||||
};
|
||||
|
||||
/** Get custom themes only */
|
||||
export const useCustomThemes = (): TerminalTheme[] => {
|
||||
return useSyncExternalStore(
|
||||
customThemeStore.subscribe,
|
||||
customThemeStore.getCustomThemes
|
||||
);
|
||||
};
|
||||
|
||||
/** Get theme by ID (built-in or custom) with fallback */
|
||||
export const useThemeById = (id: string): TerminalTheme => {
|
||||
const allThemes = useAllThemes();
|
||||
return allThemes.find(t => t.id === id) || TERMINAL_THEMES[0];
|
||||
};
|
||||
|
||||
/** Theme mutation actions */
|
||||
export const useCustomThemeActions = () => {
|
||||
const addTheme = useCallback((theme: TerminalTheme) => {
|
||||
customThemeStore.addTheme(theme);
|
||||
}, []);
|
||||
|
||||
const updateTheme = useCallback((id: string, updates: Partial<TerminalTheme>) => {
|
||||
customThemeStore.updateTheme(id, updates);
|
||||
}, []);
|
||||
|
||||
const deleteTheme = useCallback((id: string) => {
|
||||
customThemeStore.deleteTheme(id);
|
||||
}, []);
|
||||
|
||||
return { addTheme, updateTheme, deleteTheme };
|
||||
};
|
||||
@@ -4,31 +4,16 @@ export const isSessionError = (err: unknown): boolean => {
|
||||
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")
|
||||
msg.includes("session lost") ||
|
||||
msg.includes("channel not ready") ||
|
||||
msg.includes("readdir is not a function") ||
|
||||
msg.includes("channel closed") ||
|
||||
msg.includes("connection closed") ||
|
||||
msg.includes("connection reset") ||
|
||||
msg.includes("write after end") ||
|
||||
msg.includes("no response") ||
|
||||
msg.includes("not connected") ||
|
||||
msg.includes("client disconnected") ||
|
||||
msg.includes("timed out")
|
||||
);
|
||||
};
|
||||
|
||||
52
application/state/sftp/sharedRemoteHostCache.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
|
||||
interface SharedRemoteHostCacheEntry {
|
||||
path: string;
|
||||
homeDir: string;
|
||||
files: SftpFileEntry[];
|
||||
filenameEncoding: SftpFilenameEncoding;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
const SHARED_REMOTE_HOST_CACHE_TTL_MS = 60_000;
|
||||
|
||||
const sharedRemoteHostCache = new Map<string, SharedRemoteHostCacheEntry>();
|
||||
|
||||
/**
|
||||
* Build a cache key that includes connection details so that the same host ID
|
||||
* with different session-time overrides (port, protocol) uses separate entries.
|
||||
*/
|
||||
export const buildCacheKey = (
|
||||
hostId: string,
|
||||
hostname?: string,
|
||||
port?: number,
|
||||
protocol?: string,
|
||||
sftpSudo?: boolean,
|
||||
username?: string,
|
||||
): string => {
|
||||
return `${hostId}:${hostname ?? ''}:${port ?? ''}:${protocol ?? ''}:${sftpSudo ? 'sudo' : ''}:${username ?? ''}`;
|
||||
};
|
||||
|
||||
export const getSharedRemoteHostCache = (
|
||||
cacheKey: string,
|
||||
): SharedRemoteHostCacheEntry | null => {
|
||||
const entry = sharedRemoteHostCache.get(cacheKey);
|
||||
if (!entry) return null;
|
||||
|
||||
if (Date.now() - entry.updatedAt > SHARED_REMOTE_HOST_CACHE_TTL_MS) {
|
||||
sharedRemoteHostCache.delete(cacheKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry;
|
||||
};
|
||||
|
||||
export const setSharedRemoteHostCache = (
|
||||
cacheKey: string,
|
||||
entry: Omit<SharedRemoteHostCacheEntry, "updatedAt">,
|
||||
): void => {
|
||||
sharedRemoteHostCache.set(cacheKey, {
|
||||
...entry,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
};
|
||||
@@ -10,6 +10,7 @@ export interface SftpPane {
|
||||
selectedFiles: Set<string>;
|
||||
filter: string;
|
||||
filenameEncoding: SftpFilenameEncoding;
|
||||
showHiddenFiles: boolean;
|
||||
}
|
||||
|
||||
// Multi-tab state for left and right sides
|
||||
@@ -22,7 +23,10 @@ export interface SftpSideTabs {
|
||||
export const EMPTY_LEFT_PANE_ID = "__empty_left__";
|
||||
export const EMPTY_RIGHT_PANE_ID = "__empty_right__";
|
||||
|
||||
export const createEmptyPane = (id?: string): SftpPane => ({
|
||||
export const createEmptyPane = (
|
||||
id?: string,
|
||||
showHiddenFiles = false,
|
||||
): SftpPane => ({
|
||||
id: id || crypto.randomUUID(),
|
||||
connection: null,
|
||||
files: [],
|
||||
@@ -32,6 +36,7 @@ export const createEmptyPane = (id?: string): SftpPane => ({
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
filenameEncoding: "auto",
|
||||
showHiddenFiles,
|
||||
});
|
||||
|
||||
// File watch event types
|
||||
@@ -52,4 +57,7 @@ export interface FileWatchErrorEvent {
|
||||
export interface SftpStateOptions {
|
||||
onFileWatchSynced?: (event: FileWatchSyncedEvent) => void;
|
||||
onFileWatchError?: (event: FileWatchErrorEvent) => void;
|
||||
useCompressedUpload?: boolean;
|
||||
defaultShowHiddenFiles?: boolean;
|
||||
autoConnectLocalOnMount?: boolean;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Host, Identity, SftpConnection, SftpFileEntry, SftpFilenameEncodin
|
||||
import type { SftpPane } from "./types";
|
||||
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
|
||||
import { useSftpHostCredentials } from "./useSftpHostCredentials";
|
||||
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
|
||||
|
||||
interface UseSftpConnectionsParams {
|
||||
hosts: Host[];
|
||||
@@ -24,14 +25,16 @@ interface UseSftpConnectionsParams {
|
||||
dirCacheRef: MutableRefObject<Map<string, { files: SftpFileEntry[]; timestamp: number }>>;
|
||||
sftpSessionsRef: MutableRefObject<Map<string, string>>;
|
||||
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
|
||||
connectionCacheKeyMapRef: MutableRefObject<Map<string, string>>;
|
||||
reconnectingRef: MutableRefObject<{ left: boolean; right: boolean }>;
|
||||
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
|
||||
clearCacheForConnection: (connectionId: string) => void;
|
||||
createEmptyPane: (id?: string) => SftpPane;
|
||||
createEmptyPane: (id?: string, showHiddenFiles?: boolean) => SftpPane;
|
||||
autoConnectLocalOnMount?: boolean;
|
||||
}
|
||||
|
||||
interface UseSftpConnectionsResult {
|
||||
connect: (side: "left" | "right", host: Host | "local") => Promise<void>;
|
||||
connect: (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean }) => Promise<void>;
|
||||
disconnect: (side: "left" | "right") => Promise<void>;
|
||||
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
|
||||
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||||
@@ -55,22 +58,24 @@ export const useSftpConnections = ({
|
||||
dirCacheRef,
|
||||
sftpSessionsRef,
|
||||
lastConnectedHostRef,
|
||||
connectionCacheKeyMapRef,
|
||||
reconnectingRef,
|
||||
makeCacheKey,
|
||||
clearCacheForConnection,
|
||||
createEmptyPane,
|
||||
autoConnectLocalOnMount = true,
|
||||
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
|
||||
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities });
|
||||
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
|
||||
|
||||
const connect = useCallback(
|
||||
async (side: "left" | "right", host: Host | "local") => {
|
||||
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean }) => {
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
|
||||
let activeTabId: string | null = null;
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
|
||||
if (!sideTabs.activeTabId) {
|
||||
if (!sideTabs.activeTabId || options?.forceNewTab) {
|
||||
const newPane = createEmptyPane();
|
||||
activeTabId = newPane.id;
|
||||
setTabs((prev) => ({
|
||||
@@ -89,6 +94,14 @@ export const useSftpConnections = ({
|
||||
const connectRequestId = navSeqRef.current[side];
|
||||
|
||||
lastConnectedHostRef.current[side] = host;
|
||||
// Store the cache key for this connection so pane actions can look it up
|
||||
// by connectionId instead of relying on the per-side lastConnectedHostRef.
|
||||
if (host !== "local") {
|
||||
connectionCacheKeyMapRef.current.set(
|
||||
connectionId,
|
||||
buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username),
|
||||
);
|
||||
}
|
||||
|
||||
const currentPane = getActivePane(side);
|
||||
// Reset encoding to host's configured encoding or "auto" when connecting to a new host
|
||||
@@ -96,18 +109,22 @@ export const useSftpConnections = ({
|
||||
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
|
||||
// When forceNewTab is set, we're preserving the old tab for instant switching —
|
||||
// don't close its SFTP session or clear its cache.
|
||||
if (!options?.forceNewTab) {
|
||||
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);
|
||||
}
|
||||
sftpSessionsRef.current.delete(currentPane.connection.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,22 +179,33 @@ export const useSftpConnections = ({
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
const hostCacheKey = buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username);
|
||||
const sharedHostCacheCandidate = getSharedRemoteHostCache(hostCacheKey);
|
||||
const sharedHostCache =
|
||||
sharedHostCacheCandidate?.filenameEncoding === filenameEncoding
|
||||
? sharedHostCacheCandidate
|
||||
: null;
|
||||
const cachedStartPath = sharedHostCache?.path ?? "/";
|
||||
|
||||
const connection: SftpConnection = {
|
||||
id: connectionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
isLocal: false,
|
||||
status: "connecting",
|
||||
currentPath: "/",
|
||||
currentPath: cachedStartPath,
|
||||
};
|
||||
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection,
|
||||
// Always show loading while connecting — even with cached files.
|
||||
// The cached file list is shown as a preview, but the pane stays
|
||||
// non-interactive until the SFTP session is actually established.
|
||||
loading: true,
|
||||
reconnecting: prev.reconnecting,
|
||||
error: null,
|
||||
files: prev.reconnecting ? prev.files : [],
|
||||
files: prev.reconnecting ? prev.files : (sharedHostCache?.files ?? []),
|
||||
filenameEncoding, // Reset encoding for new connection
|
||||
}));
|
||||
|
||||
@@ -238,72 +266,137 @@ export const useSftpConnections = ({
|
||||
|
||||
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;
|
||||
let startPath = sharedHostCache?.path ?? "/";
|
||||
let homeDir = sharedHostCache?.homeDir ?? startPath;
|
||||
|
||||
if (!sharedHostCache) {
|
||||
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;
|
||||
homeDir = candidate;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Ignore missing/permission errors
|
||||
}
|
||||
} 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 === "/") {
|
||||
} else {
|
||||
if (credentials.username === "root") {
|
||||
try {
|
||||
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
|
||||
if (rootFiles) startPath = "/root";
|
||||
if (rootFiles) {
|
||||
startPath = "/root";
|
||||
homeDir = "/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}`;
|
||||
homeDir = startPath;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to /root check
|
||||
}
|
||||
if (startPath === "/") {
|
||||
try {
|
||||
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
|
||||
if (rootFiles) {
|
||||
startPath = "/root";
|
||||
homeDir = "/root";
|
||||
}
|
||||
} catch {
|
||||
// Fallback path not available
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
|
||||
if (rootFiles) {
|
||||
startPath = "/root";
|
||||
homeDir = "/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);
|
||||
const provisionalCacheKey = sharedHostCache
|
||||
? makeCacheKey(connectionId, startPath, filenameEncoding)
|
||||
: null;
|
||||
if (sharedHostCache && provisionalCacheKey) {
|
||||
dirCacheRef.current.set(provisionalCacheKey, {
|
||||
files: sharedHostCache.files,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
let files: SftpFileEntry[] = [];
|
||||
try {
|
||||
files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
|
||||
} catch {
|
||||
// Cached path may be stale (deleted, permissions changed).
|
||||
// Remove the provisional cache entry so phantom files don't resurface.
|
||||
if (provisionalCacheKey) {
|
||||
dirCacheRef.current.delete(provisionalCacheKey);
|
||||
}
|
||||
// Fall back to homeDir, then "/", chaining attempts.
|
||||
let fallbackSucceeded = false;
|
||||
if (sharedHostCache && startPath !== homeDir) {
|
||||
try {
|
||||
startPath = homeDir;
|
||||
files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
|
||||
fallbackSucceeded = true;
|
||||
} catch {
|
||||
// homeDir also failed, try root
|
||||
}
|
||||
}
|
||||
if (!fallbackSucceeded && startPath !== "/") {
|
||||
try {
|
||||
startPath = "/";
|
||||
files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
|
||||
fallbackSucceeded = true;
|
||||
} catch {
|
||||
// root also failed
|
||||
}
|
||||
}
|
||||
if (!fallbackSucceeded) {
|
||||
throw new Error("Cannot list any remote directory");
|
||||
}
|
||||
}
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
dirCacheRef.current.set(makeCacheKey(connectionId, startPath, filenameEncoding), {
|
||||
files,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setSharedRemoteHostCache(hostCacheKey, {
|
||||
path: startPath,
|
||||
homeDir,
|
||||
files,
|
||||
filenameEncoding,
|
||||
});
|
||||
|
||||
reconnectingRef.current[side] = false;
|
||||
|
||||
@@ -314,7 +407,7 @@ export const useSftpConnections = ({
|
||||
...prev.connection,
|
||||
status: "connected",
|
||||
currentPath: startPath,
|
||||
homeDir: startPath,
|
||||
homeDir,
|
||||
}
|
||||
: null,
|
||||
files,
|
||||
@@ -346,6 +439,7 @@ export const useSftpConnections = ({
|
||||
getActivePane,
|
||||
updateTab,
|
||||
clearCacheForConnection,
|
||||
createEmptyPane,
|
||||
makeCacheKey,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
@@ -355,13 +449,17 @@ export const useSftpConnections = ({
|
||||
const initialConnectDoneRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialConnectDoneRef.current && leftTabs.tabs.length === 0) {
|
||||
if (
|
||||
autoConnectLocalOnMount &&
|
||||
!initialConnectDoneRef.current &&
|
||||
leftTabs.tabs.length === 0
|
||||
) {
|
||||
initialConnectDoneRef.current = true;
|
||||
setTimeout(() => {
|
||||
connect("left", "local");
|
||||
}, 0);
|
||||
}
|
||||
}, [connect, leftTabs.tabs.length]);
|
||||
}, [autoConnectLocalOnMount, connect, leftTabs.tabs.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const attemptReconnect = async (side: "left" | "right") => {
|
||||
@@ -412,7 +510,7 @@ export const useSftpConnections = ({
|
||||
}
|
||||
}
|
||||
|
||||
updateTab(side, activeTabId, () => createEmptyPane(activeTabId));
|
||||
updateTab(side, activeTabId, () => createEmptyPane(activeTabId, pane.showHiddenFiles));
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[getActivePane, clearCacheForConnection, updateTab],
|
||||
|
||||
@@ -7,11 +7,13 @@ import { joinPath } from "./utils";
|
||||
import {
|
||||
UploadController,
|
||||
uploadFromDataTransfer,
|
||||
uploadEntriesDirect,
|
||||
UploadBridge,
|
||||
UploadCallbacks,
|
||||
UploadResult,
|
||||
UploadTaskInfo,
|
||||
} from "../../../lib/uploadService";
|
||||
import type { DropEntry } from "../../../lib/sftpFileUtils";
|
||||
|
||||
// Re-export UploadResult for external usage
|
||||
export type { UploadResult };
|
||||
@@ -20,8 +22,12 @@ interface UseSftpExternalOperationsParams {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
refresh: (side: "left" | "right") => Promise<void>;
|
||||
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
|
||||
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
|
||||
clearDirCacheEntry?: (connectionId: string, path: string) => void;
|
||||
useCompressedUpload?: boolean;
|
||||
addExternalUpload?: (task: TransferTask) => void;
|
||||
updateExternalUpload?: (taskId: string, updates: Partial<TransferTask>) => void;
|
||||
isTransferCancelled?: (taskId: string) => boolean;
|
||||
dismissExternalUpload?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
@@ -36,10 +42,16 @@ interface SftpExternalOperationsResult {
|
||||
appPath: string,
|
||||
options?: { enableWatch?: boolean }
|
||||
) => Promise<{ localTempPath: string; watchId?: string }>;
|
||||
activeFileWatchCountRef: React.MutableRefObject<number>;
|
||||
uploadExternalFiles: (
|
||||
side: "left" | "right",
|
||||
dataTransfer: DataTransfer
|
||||
) => Promise<UploadResult[]>;
|
||||
uploadExternalEntries: (
|
||||
side: "left" | "right",
|
||||
entries: DropEntry[],
|
||||
options?: { targetPath?: string }
|
||||
) => Promise<UploadResult[]>;
|
||||
cancelExternalUpload: () => Promise<void>;
|
||||
selectApplication: () => Promise<{ path: string; name: string } | null>;
|
||||
}
|
||||
@@ -47,11 +59,26 @@ interface SftpExternalOperationsResult {
|
||||
export const useSftpExternalOperations = (
|
||||
params: UseSftpExternalOperationsParams
|
||||
): SftpExternalOperationsResult => {
|
||||
const { getActivePane, refresh, sftpSessionsRef, addExternalUpload, updateExternalUpload, dismissExternalUpload } = params;
|
||||
const {
|
||||
getActivePane,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
connectionCacheKeyMapRef,
|
||||
clearDirCacheEntry,
|
||||
useCompressedUpload = false,
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
isTransferCancelled,
|
||||
dismissExternalUpload,
|
||||
} = params;
|
||||
|
||||
// Upload controller for cancellation support
|
||||
const uploadControllerRef = useRef<UploadController | null>(null);
|
||||
|
||||
// Track active file watches so the side panel can block host-switching.
|
||||
// Reset to 0 when the SFTP session disconnects (handled in SftpSidePanel).
|
||||
const activeFileWatchCountRef = useRef(0);
|
||||
|
||||
const readTextFile = useCallback(
|
||||
async (side: "left" | "right", filePath: string): Promise<string> => {
|
||||
const pane = getActivePane(side);
|
||||
@@ -173,14 +200,113 @@ export const useSftpExternalOperations = (
|
||||
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 });
|
||||
let localTempPath: string;
|
||||
let wasCancelled = false;
|
||||
let externalTransferId: string | undefined;
|
||||
const isLocalTempDownloadCancelled = () =>
|
||||
!!externalTransferId && !!isTransferCancelled?.(externalTransferId);
|
||||
const cleanupTempDownload = async (filePath: string) => {
|
||||
if (!bridge.deleteTempFile) return;
|
||||
try {
|
||||
await bridge.deleteTempFile(filePath);
|
||||
} catch (err) {
|
||||
console.warn("[SFTP] Failed to delete cancelled temp download:", err);
|
||||
}
|
||||
};
|
||||
|
||||
if (bridge.downloadSftpToTempWithProgress && addExternalUpload && updateExternalUpload) {
|
||||
externalTransferId = `download-temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
addExternalUpload({
|
||||
id: externalTransferId,
|
||||
fileName,
|
||||
sourcePath: remotePath,
|
||||
targetPath: "(temp)",
|
||||
sourceConnectionId: pane.connection.id,
|
||||
targetConnectionId: "local",
|
||||
direction: "download",
|
||||
status: "transferring" as TransferStatus,
|
||||
totalBytes: 0,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: false,
|
||||
retryable: false,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await bridge.downloadSftpToTempWithProgress(
|
||||
sftpId,
|
||||
remotePath,
|
||||
fileName,
|
||||
pane.filenameEncoding,
|
||||
externalTransferId,
|
||||
(transferred, total, speed) => {
|
||||
updateExternalUpload(externalTransferId, {
|
||||
transferredBytes: transferred,
|
||||
totalBytes: total,
|
||||
speed,
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
(error) => {
|
||||
updateExternalUpload(externalTransferId, {
|
||||
status: "failed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
error,
|
||||
speed: 0,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
updateExternalUpload(externalTransferId, {
|
||||
status: "cancelled" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
wasCancelled = result.cancelled;
|
||||
localTempPath = result.localPath;
|
||||
} catch (err) {
|
||||
updateExternalUpload(externalTransferId, {
|
||||
status: "failed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
speed: 0,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (wasCancelled) {
|
||||
if (localTempPath && bridge.deleteTempFile) {
|
||||
bridge.deleteTempFile(localTempPath).catch(() => {});
|
||||
}
|
||||
return { localTempPath: "" };
|
||||
}
|
||||
|
||||
if (isLocalTempDownloadCancelled()) {
|
||||
await cleanupTempDownload(localTempPath);
|
||||
return { localTempPath: "" };
|
||||
}
|
||||
|
||||
updateExternalUpload(externalTransferId, {
|
||||
status: "completed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
} else {
|
||||
localTempPath = await bridge.downloadSftpToTemp(
|
||||
sftpId,
|
||||
remotePath,
|
||||
fileName,
|
||||
pane.filenameEncoding,
|
||||
);
|
||||
}
|
||||
|
||||
if (isLocalTempDownloadCancelled()) {
|
||||
await cleanupTempDownload(localTempPath);
|
||||
return { localTempPath: "" };
|
||||
}
|
||||
|
||||
if (bridge.registerTempFile) {
|
||||
try {
|
||||
@@ -190,15 +316,23 @@ export const useSftpExternalOperations = (
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[SFTP] Opening with application", { localTempPath, appPath });
|
||||
await bridge.openWithApplication(localTempPath, appPath);
|
||||
console.log("[SFTP] Application launched");
|
||||
try {
|
||||
await bridge.openWithApplication(localTempPath, appPath);
|
||||
} catch (err) {
|
||||
if (externalTransferId) {
|
||||
updateExternalUpload(externalTransferId, {
|
||||
status: "failed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
speed: 0,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -206,23 +340,23 @@ export const useSftpExternalOperations = (
|
||||
pane.filenameEncoding,
|
||||
);
|
||||
watchId = result.watchId;
|
||||
console.log("[SFTP] File watch started successfully", { watchId, localTempPath, remotePath });
|
||||
activeFileWatchCountRef.current += 1;
|
||||
} 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],
|
||||
[getActivePane, sftpSessionsRef, addExternalUpload, updateExternalUpload, isTransferCancelled],
|
||||
);
|
||||
|
||||
// Create upload callbacks that translate to TransferTask updates
|
||||
const createUploadCallbacks = useCallback((
|
||||
connectionId: string,
|
||||
targetPath: string
|
||||
targetPath: string,
|
||||
targetHostId?: string,
|
||||
targetConnectionKey?: string,
|
||||
): UploadCallbacks => {
|
||||
return {
|
||||
onScanningStart: (taskId: string) => {
|
||||
@@ -234,6 +368,8 @@ export const useSftpExternalOperations = (
|
||||
targetPath,
|
||||
sourceConnectionId: "external",
|
||||
targetConnectionId: connectionId,
|
||||
targetHostId,
|
||||
targetConnectionKey,
|
||||
direction: "upload",
|
||||
status: "pending" as TransferStatus,
|
||||
totalBytes: 0,
|
||||
@@ -259,6 +395,8 @@ export const useSftpExternalOperations = (
|
||||
targetPath: joinPath(targetPath, task.fileName),
|
||||
sourceConnectionId: "external",
|
||||
targetConnectionId: connectionId,
|
||||
targetHostId,
|
||||
targetConnectionKey,
|
||||
direction: "upload",
|
||||
status: "transferring" as TransferStatus,
|
||||
totalBytes: task.totalBytes,
|
||||
@@ -390,7 +528,12 @@ export const useSftpExternalOperations = (
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
|
||||
const callbacks = createUploadCallbacks(pane.connection.id, pane.connection.currentPath);
|
||||
const callbacks = createUploadCallbacks(
|
||||
pane.connection.id,
|
||||
pane.connection.currentPath,
|
||||
pane.connection.isLocal ? undefined : pane.connection.hostId,
|
||||
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
|
||||
);
|
||||
|
||||
try {
|
||||
const results = await uploadFromDataTransfer(
|
||||
@@ -402,6 +545,7 @@ export const useSftpExternalOperations = (
|
||||
bridge: createUploadBridge,
|
||||
joinPath,
|
||||
callbacks,
|
||||
useCompressedUpload,
|
||||
},
|
||||
controller
|
||||
);
|
||||
@@ -415,7 +559,99 @@ export const useSftpExternalOperations = (
|
||||
uploadControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[getActivePane, refresh, sftpSessionsRef, createUploadCallbacks, createUploadBridge],
|
||||
[
|
||||
connectionCacheKeyMapRef,
|
||||
getActivePane,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
createUploadCallbacks,
|
||||
createUploadBridge,
|
||||
useCompressedUpload,
|
||||
],
|
||||
);
|
||||
|
||||
const uploadExternalEntries = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
entries: DropEntry[],
|
||||
options?: { targetPath?: string },
|
||||
): 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");
|
||||
}
|
||||
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
const uploadTargetPath = options?.targetPath || pane.connection.currentPath;
|
||||
|
||||
const callbacks = createUploadCallbacks(
|
||||
pane.connection.id,
|
||||
uploadTargetPath,
|
||||
pane.connection.isLocal ? undefined : pane.connection.hostId,
|
||||
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
|
||||
);
|
||||
const directUploadBridge: UploadBridge = {
|
||||
...createUploadBridge,
|
||||
};
|
||||
|
||||
try {
|
||||
const results = await uploadEntriesDirect(
|
||||
entries,
|
||||
{
|
||||
targetPath: uploadTargetPath,
|
||||
sftpId,
|
||||
isLocal: pane.connection.isLocal,
|
||||
bridge: directUploadBridge,
|
||||
joinPath,
|
||||
callbacks,
|
||||
useCompressedUpload,
|
||||
},
|
||||
controller,
|
||||
);
|
||||
|
||||
// Refresh the current directory and invalidate the upload target's
|
||||
// cache entry. If the user navigated away during the upload, the
|
||||
// invalidation ensures returning to the target path triggers a fresh
|
||||
// listing instead of serving stale cached data.
|
||||
const livePane = getActivePane(side);
|
||||
if (livePane?.connection) {
|
||||
if (livePane.connection.currentPath !== uploadTargetPath && clearDirCacheEntry) {
|
||||
clearDirCacheEntry(livePane.connection.id, uploadTargetPath);
|
||||
}
|
||||
await refresh(side);
|
||||
}
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error("[SFTP] Upload failed:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
uploadControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[
|
||||
clearDirCacheEntry,
|
||||
connectionCacheKeyMapRef,
|
||||
createUploadCallbacks,
|
||||
createUploadBridge,
|
||||
getActivePane,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
useCompressedUpload,
|
||||
],
|
||||
);
|
||||
|
||||
const cancelExternalUpload = useCallback(async () => {
|
||||
@@ -443,7 +679,9 @@ export const useSftpExternalOperations = (
|
||||
writeTextFile,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalEntries,
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
activeFileWatchCountRef,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -20,12 +20,12 @@ export const useSftpHostCredentials = ({
|
||||
|
||||
const proxyConfig = host.proxyConfig
|
||||
? {
|
||||
type: host.proxyConfig.type,
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
username: host.proxyConfig.username,
|
||||
password: host.proxyConfig.password,
|
||||
}
|
||||
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;
|
||||
@@ -63,6 +63,7 @@ export const useSftpHostCredentials = ({
|
||||
password: resolved.password,
|
||||
privateKey: key?.privateKey,
|
||||
certificate: key?.certificate,
|
||||
passphrase: resolved.passphrase || key?.passphrase,
|
||||
publicKey: key?.publicKey,
|
||||
keyId: resolved.keyId,
|
||||
keySource: key?.source,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useRef } 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";
|
||||
import { buildCacheKey, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
|
||||
|
||||
interface UseSftpPaneActionsParams {
|
||||
hosts: Host[];
|
||||
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;
|
||||
@@ -15,6 +17,7 @@ interface UseSftpPaneActionsParams {
|
||||
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 }>;
|
||||
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
|
||||
reconnectingRef: React.MutableRefObject<{ left: boolean; right: boolean }>;
|
||||
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
|
||||
clearCacheForConnection: (connectionId: string) => void;
|
||||
@@ -39,11 +42,18 @@ interface UseSftpPaneActionsResult {
|
||||
createDirectory: (side: "left" | "right", name: string) => Promise<void>;
|
||||
createFile: (side: "left" | "right", name: string) => Promise<void>;
|
||||
deleteFiles: (side: "left" | "right", fileNames: string[]) => Promise<void>;
|
||||
deleteFilesAtPath: (
|
||||
side: "left" | "right",
|
||||
connectionId: string,
|
||||
path: string,
|
||||
fileNames: string[],
|
||||
) => Promise<void>;
|
||||
renameFile: (side: "left" | "right", oldName: string, newName: string) => Promise<void>;
|
||||
changePermissions: (side: "left" | "right", filePath: string, mode: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpPaneActions = ({
|
||||
hosts,
|
||||
getActivePane,
|
||||
updateTab,
|
||||
updateActiveTab,
|
||||
@@ -53,6 +63,7 @@ export const useSftpPaneActions = ({
|
||||
dirCacheRef,
|
||||
sftpSessionsRef,
|
||||
lastConnectedHostRef,
|
||||
connectionCacheKeyMapRef,
|
||||
reconnectingRef,
|
||||
makeCacheKey,
|
||||
clearCacheForConnection,
|
||||
@@ -62,6 +73,43 @@ export const useSftpPaneActions = ({
|
||||
isSessionError,
|
||||
dirCacheTtlMs,
|
||||
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
|
||||
// Build the shared cache key for the active pane. Prefer the last connected
|
||||
// host (which includes session-time overrides), fall back to the vault hosts list.
|
||||
const hostsRef = useRef(hosts);
|
||||
hostsRef.current = hosts;
|
||||
const getActivePaneCacheKey = useCallback((side: "left" | "right", hostId: string, connectionId?: string): string => {
|
||||
// Prefer the per-connection cache key — it's set at connect time and
|
||||
// correctly identifies the endpoint even when multiple tabs share the
|
||||
// same hostId with different session-time overrides.
|
||||
if (connectionId) {
|
||||
const perConnKey = connectionCacheKeyMapRef.current.get(connectionId);
|
||||
if (perConnKey) return perConnKey;
|
||||
}
|
||||
// Fallback: lastConnectedHostRef (per-side, may be stale for multi-tab)
|
||||
const connHost = lastConnectedHostRef.current[side];
|
||||
if (connHost && connHost !== "local" && connHost.id === hostId) {
|
||||
return buildCacheKey(connHost.id, connHost.hostname, connHost.port, connHost.protocol, connHost.sftpSudo, connHost.username);
|
||||
}
|
||||
// Fall back to vault host
|
||||
const host = hostsRef.current.find(h => h.id === hostId);
|
||||
if (host) {
|
||||
return buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username);
|
||||
}
|
||||
return hostId;
|
||||
}, [connectionCacheKeyMapRef, lastConnectedHostRef]);
|
||||
|
||||
// Track the latest navigation request ID per tab, so we can distinguish
|
||||
// whether a superseded request was superseded by the same tab or a different tab.
|
||||
const tabNavSeqRef = useRef(new Map<string, number>());
|
||||
|
||||
// Track the last confirmed (successfully loaded) state per tab, so that
|
||||
// restore-on-error/supersede always reverts to a known-good state rather
|
||||
// than an intermediate optimistic state from another in-flight navigation.
|
||||
// Includes connectionId so stale entries from a previous host are ignored.
|
||||
const lastConfirmedRef = useRef(
|
||||
new Map<string, { connectionId: string; path: string; files: SftpFileEntry[]; selectedFiles: Set<string> }>(),
|
||||
);
|
||||
|
||||
const navigateTo = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
@@ -86,8 +134,9 @@ export const useSftpPaneActions = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const connectionId = pane.connection.id;
|
||||
const requestId = ++navSeqRef.current[side];
|
||||
const cacheKey = makeCacheKey(pane.connection.id, path, pane.filenameEncoding);
|
||||
const cacheKey = makeCacheKey(connectionId, path, pane.filenameEncoding);
|
||||
const cached = options?.force
|
||||
? undefined
|
||||
: dirCacheRef.current.get(cacheKey);
|
||||
@@ -98,6 +147,13 @@ export const useSftpPaneActions = ({
|
||||
cached.files
|
||||
) {
|
||||
console.log("[SFTP navigateTo] Using cached files for path", { path, cacheKey });
|
||||
tabNavSeqRef.current.set(activeTabId, requestId);
|
||||
lastConfirmedRef.current.set(activeTabId, {
|
||||
connectionId,
|
||||
path,
|
||||
files: cached.files,
|
||||
selectedFiles: new Set(),
|
||||
});
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
@@ -108,11 +164,53 @@ export const useSftpPaneActions = ({
|
||||
error: null,
|
||||
selectedFiles: new Set(),
|
||||
}));
|
||||
if (!pane.connection.isLocal) {
|
||||
// Use hostId as the shared cache key — this is safe because the
|
||||
// shared cache is a best-effort optimization and hostId uniquely
|
||||
// identifies the connection in the common case. Session-time
|
||||
// overrides create separate connections with distinct cache keys
|
||||
// at the connect() layer.
|
||||
setSharedRemoteHostCache(getActivePaneCacheKey(side, pane.connection.hostId, pane.connection.id), {
|
||||
path,
|
||||
homeDir: pane.connection.homeDir ?? path,
|
||||
files: cached.files,
|
||||
filenameEncoding: pane.filenameEncoding,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[SFTP navigateTo] Fetching files from server for path", { path });
|
||||
updateTab(side, activeTabId, (prev) => ({ ...prev, loading: true, error: null }));
|
||||
// Re-seed confirmed state whenever the pane is settled (not loading), or
|
||||
// when the connection has changed. This captures post-mutation state from
|
||||
// optimistic updates (e.g. deleteFilesAtPath) so that a failed refresh
|
||||
// doesn't resurrect deleted items.
|
||||
const existing = lastConfirmedRef.current.get(activeTabId);
|
||||
if (!existing || existing.connectionId !== connectionId || !pane.loading) {
|
||||
lastConfirmedRef.current.set(activeTabId, {
|
||||
connectionId,
|
||||
path: pane.connection.currentPath,
|
||||
files: pane.files,
|
||||
selectedFiles: pane.selectedFiles,
|
||||
});
|
||||
}
|
||||
const confirmed = lastConfirmedRef.current.get(activeTabId)!;
|
||||
const previousPath = confirmed.path;
|
||||
const previousFiles = confirmed.files;
|
||||
const previousSelection = confirmed.selectedFiles;
|
||||
tabNavSeqRef.current.set(activeTabId, requestId);
|
||||
// Keep existing files visible during loading — the loading overlay
|
||||
// (pointer-events-none) prevents interaction. This avoids blanking a tab
|
||||
// that gets superseded by another tab navigating on the same side.
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
? { ...prev.connection, currentPath: path }
|
||||
: null,
|
||||
selectedFiles: new Set(),
|
||||
loading: true,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
try {
|
||||
let files: SftpFileEntry[];
|
||||
@@ -158,13 +256,42 @@ export const useSftpPaneActions = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (navSeqRef.current[side] !== requestId) return;
|
||||
if (navSeqRef.current[side] !== requestId) {
|
||||
// Another navigation on this side superseded this request.
|
||||
// Only restore if no newer navigation has occurred on this specific tab
|
||||
// AND the tab still belongs to the same connection (connect/disconnect
|
||||
// bump navSeqRef but not tabNavSeqRef).
|
||||
if (tabNavSeqRef.current.get(activeTabId) !== requestId) {
|
||||
return;
|
||||
}
|
||||
updateTab(side, activeTabId, (prev) => {
|
||||
if (prev.connection?.id !== connectionId) {
|
||||
// Tab was reconnected or disconnected; don't restore stale state.
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
connection: { ...prev.connection, currentPath: previousPath },
|
||||
files: previousFiles,
|
||||
selectedFiles: previousSelection,
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
dirCacheRef.current.set(cacheKey, {
|
||||
files,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
lastConfirmedRef.current.set(activeTabId, {
|
||||
connectionId,
|
||||
path,
|
||||
files,
|
||||
selectedFiles: new Set(),
|
||||
});
|
||||
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
@@ -174,18 +301,52 @@ export const useSftpPaneActions = ({
|
||||
loading: false,
|
||||
selectedFiles: new Set(),
|
||||
}));
|
||||
if (!pane.connection.isLocal) {
|
||||
setSharedRemoteHostCache(getActivePaneCacheKey(side, pane.connection.hostId, pane.connection.id), {
|
||||
path,
|
||||
homeDir: pane.connection.homeDir ?? path,
|
||||
files,
|
||||
filenameEncoding: pane.filenameEncoding,
|
||||
});
|
||||
}
|
||||
} 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,
|
||||
}));
|
||||
if (navSeqRef.current[side] !== requestId) {
|
||||
if (tabNavSeqRef.current.get(activeTabId) !== requestId) {
|
||||
return;
|
||||
}
|
||||
updateTab(side, activeTabId, (prev) => {
|
||||
if (prev.connection?.id !== connectionId) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
connection: { ...prev.connection, currentPath: previousPath },
|
||||
files: previousFiles,
|
||||
selectedFiles: previousSelection,
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateTab(side, activeTabId, (prev) => {
|
||||
if (prev.connection?.id !== connectionId) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
connection: { ...prev.connection, currentPath: previousPath },
|
||||
files: previousFiles,
|
||||
selectedFiles: previousSelection,
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to list directory",
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
getActivePane,
|
||||
getActivePaneCacheKey,
|
||||
updateTab,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
@@ -452,6 +613,88 @@ export const useSftpPaneActions = ({
|
||||
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||||
);
|
||||
|
||||
const deleteFilesAtPath = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
connectionId: string,
|
||||
path: string,
|
||||
fileNames: string[],
|
||||
) => {
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
const pane = sideTabs.tabs.find((tab) => tab.connection?.id === connectionId);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("Source pane is no longer available");
|
||||
}
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge) {
|
||||
throw new Error("Netcatty bridge not available");
|
||||
}
|
||||
|
||||
try {
|
||||
for (const name of fileNames) {
|
||||
const fullPath = joinPath(path, name);
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
if (!bridge.deleteLocalFile) {
|
||||
throw new Error("Local delete unavailable");
|
||||
}
|
||||
await bridge.deleteLocalFile(fullPath);
|
||||
} else {
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
const error = new Error("SFTP session not found");
|
||||
handleSessionError(side, error);
|
||||
throw error;
|
||||
}
|
||||
if (!bridge.deleteSftp) {
|
||||
throw new Error("SFTP delete unavailable");
|
||||
}
|
||||
await bridge.deleteSftp(sftpId, fullPath, pane.filenameEncoding);
|
||||
}
|
||||
}
|
||||
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
|
||||
if (sideTabs.activeTabId === pane.id && pane.connection.currentPath === path) {
|
||||
await refresh(side);
|
||||
} else {
|
||||
updateTab(side, pane.id, (prev) => {
|
||||
if (!prev.connection || prev.connection.id !== connectionId) return prev;
|
||||
if (prev.connection.currentPath !== path) return prev;
|
||||
|
||||
const removeSet = new Set(fileNames);
|
||||
const filteredFiles = prev.files.filter((file) => !removeSet.has(file.name));
|
||||
const nextSelection = new Set(prev.selectedFiles);
|
||||
for (const name of fileNames) {
|
||||
nextSelection.delete(name);
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
files: filteredFiles,
|
||||
selectedFiles: nextSelection,
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
handleSessionError(side, err as Error);
|
||||
throw err;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[
|
||||
clearCacheForConnection,
|
||||
handleSessionError,
|
||||
isSessionError,
|
||||
leftTabsRef,
|
||||
refresh,
|
||||
rightTabsRef,
|
||||
sftpSessionsRef,
|
||||
updateTab,
|
||||
],
|
||||
);
|
||||
|
||||
const renameFile = useCallback(
|
||||
async (side: "left" | "right", oldName: string, newName: string) => {
|
||||
const pane = getActivePane(side);
|
||||
@@ -529,6 +772,7 @@ export const useSftpPaneActions = ({
|
||||
createDirectory,
|
||||
createFile,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
renameFile,
|
||||
changePermissions,
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface SftpTabsState {
|
||||
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;
|
||||
setTabShowHiddenFiles: (side: "left" | "right", tabId: string, showHiddenFiles: boolean) => void;
|
||||
addTab: (side: "left" | "right") => string;
|
||||
closeTab: (side: "left" | "right", tabId: string) => void;
|
||||
selectTab: (side: "left" | "right", tabId: string) => void;
|
||||
@@ -33,7 +34,11 @@ export interface SftpTabsState {
|
||||
getActiveTabId: (side: "left" | "right") => string | null;
|
||||
}
|
||||
|
||||
export const useSftpTabsState = (): SftpTabsState => {
|
||||
export const useSftpTabsState = ({
|
||||
defaultShowHiddenFiles = false,
|
||||
}: {
|
||||
defaultShowHiddenFiles?: boolean;
|
||||
} = {}): SftpTabsState => {
|
||||
const [leftTabs, setLeftTabs] = useState<SftpSideTabs>({
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
@@ -45,8 +50,10 @@ export const useSftpTabsState = (): SftpTabsState => {
|
||||
|
||||
const leftTabsRef = useRef(leftTabs);
|
||||
const rightTabsRef = useRef(rightTabs);
|
||||
const defaultShowHiddenFilesRef = useRef(defaultShowHiddenFiles);
|
||||
leftTabsRef.current = leftTabs;
|
||||
rightTabsRef.current = rightTabs;
|
||||
defaultShowHiddenFilesRef.current = defaultShowHiddenFiles;
|
||||
|
||||
const getActivePane = useCallback((side: "left" | "right"): SftpPane | null => {
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
@@ -58,14 +65,14 @@ export const useSftpTabsState = (): SftpTabsState => {
|
||||
const pane = leftTabs.activeTabId
|
||||
? leftTabs.tabs.find((t) => t.id === leftTabs.activeTabId)
|
||||
: null;
|
||||
return pane || createEmptyPane(EMPTY_LEFT_PANE_ID);
|
||||
return pane || createEmptyPane(EMPTY_LEFT_PANE_ID, defaultShowHiddenFilesRef.current);
|
||||
}, [leftTabs]);
|
||||
|
||||
const rightPane = useMemo(() => {
|
||||
const pane = rightTabs.activeTabId
|
||||
? rightTabs.tabs.find((t) => t.id === rightTabs.activeTabId)
|
||||
: null;
|
||||
return pane || createEmptyPane(EMPTY_RIGHT_PANE_ID);
|
||||
return pane || createEmptyPane(EMPTY_RIGHT_PANE_ID, defaultShowHiddenFilesRef.current);
|
||||
}, [rightTabs]);
|
||||
|
||||
const updateTab = useCallback(
|
||||
@@ -88,9 +95,24 @@ export const useSftpTabsState = (): SftpTabsState => {
|
||||
[updateTab],
|
||||
);
|
||||
|
||||
const setTabShowHiddenFiles = useCallback(
|
||||
(side: "left" | "right", tabId: string, showHiddenFiles: boolean) => {
|
||||
updateTab(side, tabId, (prev) => {
|
||||
if (prev.showHiddenFiles === showHiddenFiles) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
showHiddenFiles,
|
||||
};
|
||||
});
|
||||
},
|
||||
[updateTab],
|
||||
);
|
||||
|
||||
const addTab = useCallback(
|
||||
(side: "left" | "right") => {
|
||||
const newPane = createEmptyPane();
|
||||
const newPane = createEmptyPane(undefined, defaultShowHiddenFilesRef.current);
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
setTabs((prev) => ({
|
||||
tabs: [...prev.tabs, newPane],
|
||||
@@ -236,6 +258,7 @@ export const useSftpTabsState = (): SftpTabsState => {
|
||||
getActivePane,
|
||||
updateTab,
|
||||
updateActiveTab,
|
||||
setTabShowHiddenFiles,
|
||||
addTab,
|
||||
closeTab,
|
||||
selectTab,
|
||||
|
||||
@@ -29,16 +29,30 @@ interface UseSftpTransfersResult {
|
||||
sourceFiles: { name: string; isDirectory: boolean }[],
|
||||
sourceSide: "left" | "right",
|
||||
targetSide: "left" | "right",
|
||||
) => Promise<void>;
|
||||
options?: {
|
||||
sourcePane?: SftpPane;
|
||||
sourcePath?: string;
|
||||
sourceConnectionId?: string;
|
||||
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
|
||||
},
|
||||
) => Promise<TransferResult[]>;
|
||||
addExternalUpload: (task: TransferTask) => void;
|
||||
updateExternalUpload: (taskId: string, updates: Partial<TransferTask>) => void;
|
||||
cancelTransfer: (transferId: string) => Promise<void>;
|
||||
isTransferCancelled: (transferId: string) => boolean;
|
||||
retryTransfer: (transferId: string) => Promise<void>;
|
||||
clearCompletedTransfers: () => void;
|
||||
dismissTransfer: (transferId: string) => void;
|
||||
resolveConflict: (conflictId: string, action: "replace" | "skip" | "duplicate") => Promise<void>;
|
||||
}
|
||||
|
||||
interface TransferResult {
|
||||
id: string;
|
||||
fileName: string;
|
||||
originalFileName?: string;
|
||||
status: TransferStatus;
|
||||
}
|
||||
|
||||
export const useSftpTransfers = ({
|
||||
getActivePane,
|
||||
refresh,
|
||||
@@ -53,6 +67,7 @@ export const useSftpTransfers = ({
|
||||
const progressIntervalsRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
// Track cancelled task IDs for checking during async operations
|
||||
const cancelledTasksRef = useRef<Set<string>>(new Set());
|
||||
const completionHandlersRef = useRef<Map<string, (result: TransferResult) => void | Promise<void>>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const intervalsRef = progressIntervalsRef.current;
|
||||
@@ -109,6 +124,73 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearCancelledTask = useCallback((taskId: string) => {
|
||||
cancelledTasksRef.current.delete(taskId);
|
||||
}, []);
|
||||
|
||||
const isTransferCancelledError = useCallback(
|
||||
(error: unknown): boolean =>
|
||||
error instanceof Error && error.message === "Transfer cancelled",
|
||||
[],
|
||||
);
|
||||
|
||||
const getEntrySize = useCallback((entry: SftpFileEntry): number => {
|
||||
if (typeof entry.size === "string") {
|
||||
const parsed = parseInt(entry.size, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
return typeof entry.size === "number" && entry.size > 0 ? entry.size : 0;
|
||||
}, []);
|
||||
|
||||
const estimateDirectoryBytes = useCallback(
|
||||
async (
|
||||
sourcePath: string,
|
||||
sourceSftpId: string | null,
|
||||
sourceIsLocal: boolean,
|
||||
sourceEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string,
|
||||
): Promise<number> => {
|
||||
if (cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const files = sourceIsLocal
|
||||
? await listLocalFiles(sourcePath)
|
||||
: sourceSftpId
|
||||
? await listRemoteFiles(sourceSftpId, sourcePath, sourceEncoding)
|
||||
: null;
|
||||
|
||||
if (!files) {
|
||||
throw new Error("No source connection");
|
||||
}
|
||||
|
||||
let totalBytes = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (file.name === "..") continue;
|
||||
|
||||
if (cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
if (file.type === "directory") {
|
||||
totalBytes += await estimateDirectoryBytes(
|
||||
joinPath(sourcePath, file.name),
|
||||
sourceSftpId,
|
||||
sourceIsLocal,
|
||||
sourceEncoding,
|
||||
rootTaskId,
|
||||
);
|
||||
} else {
|
||||
totalBytes += getEntrySize(file);
|
||||
}
|
||||
}
|
||||
|
||||
return totalBytes;
|
||||
},
|
||||
[getEntrySize, listLocalFiles, listRemoteFiles],
|
||||
);
|
||||
|
||||
const transferFile = async (
|
||||
task: TransferTask,
|
||||
sourceSftpId: string | null,
|
||||
@@ -118,6 +200,7 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding: SftpFilenameEncoding,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string, // The original top-level task ID for cancellation checking
|
||||
onStreamProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
): Promise<void> => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
@@ -144,15 +227,23 @@ export const useSftpTransfers = ({
|
||||
total: number,
|
||||
speed: number,
|
||||
) => {
|
||||
// Bubble up streaming progress to parent (for directory transfers)
|
||||
onStreamProgress?.(transferred, total, speed);
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== task.id) return t;
|
||||
if (t.status === "cancelled") return t;
|
||||
const normalizedTotal = total > 0 ? total : t.totalBytes;
|
||||
const normalizedTransferred = Math.max(
|
||||
t.transferredBytes,
|
||||
Math.min(transferred, normalizedTotal > 0 ? normalizedTotal : transferred),
|
||||
);
|
||||
return {
|
||||
...t,
|
||||
transferredBytes: transferred,
|
||||
totalBytes: total || t.totalBytes,
|
||||
speed,
|
||||
transferredBytes: normalizedTransferred,
|
||||
totalBytes: normalizedTotal,
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -235,6 +326,7 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding: SftpFilenameEncoding,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string, // The original top-level task ID for cancellation checking
|
||||
onChildProgress?: (completedBytes: number, currentFileTransferred: number, currentFileTotal: number, speed: number) => void,
|
||||
) => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
@@ -256,6 +348,9 @@ export const useSftpTransfers = ({
|
||||
throw new Error("No source connection");
|
||||
}
|
||||
|
||||
// Track bytes completed so far in this directory (including subdirectories)
|
||||
let completedBytesInDir = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (file.name === "..") continue;
|
||||
|
||||
@@ -268,6 +363,7 @@ export const useSftpTransfers = ({
|
||||
...task,
|
||||
id: crypto.randomUUID(),
|
||||
fileName: file.name,
|
||||
originalFileName: file.name,
|
||||
sourcePath: joinPath(task.sourcePath, file.name),
|
||||
targetPath: joinPath(task.targetPath, file.name),
|
||||
isDirectory: file.type === "directory",
|
||||
@@ -275,6 +371,13 @@ export const useSftpTransfers = ({
|
||||
};
|
||||
|
||||
if (file.type === "directory") {
|
||||
// For subdirectories, create a nested progress tracker
|
||||
let subDirCompletedBytes = 0;
|
||||
const onSubDirChildProgress = (subCompleted: number, currentTransferred: number, currentTotal: number, speed: number) => {
|
||||
subDirCompletedBytes = subCompleted;
|
||||
// Report to parent: our completed + subdirectory's (completed + in-progress)
|
||||
onChildProgress?.(completedBytesInDir + subCompleted, currentTransferred, currentTotal, speed);
|
||||
};
|
||||
await transferDirectory(
|
||||
childTask,
|
||||
sourceSftpId,
|
||||
@@ -284,8 +387,14 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
rootTaskId,
|
||||
onSubDirChildProgress,
|
||||
);
|
||||
completedBytesInDir += subDirCompletedBytes;
|
||||
} else {
|
||||
// For files, report streaming progress
|
||||
const onFileStreamProgress = (transferred: number, total: number, speed: number) => {
|
||||
onChildProgress?.(completedBytesInDir, transferred, total, speed);
|
||||
};
|
||||
await transferFile(
|
||||
childTask,
|
||||
sourceSftpId,
|
||||
@@ -295,7 +404,12 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
rootTaskId,
|
||||
onFileStreamProgress,
|
||||
);
|
||||
// After file completes, add its bytes to completed total
|
||||
const childSize = typeof file.size === 'string' ? parseInt(file.size, 10) || 0 : (file.size || 0);
|
||||
completedBytesInDir += childSize;
|
||||
onChildProgress?.(completedBytesInDir, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -305,7 +419,7 @@ export const useSftpTransfers = ({
|
||||
sourcePane: SftpPane,
|
||||
targetPane: SftpPane,
|
||||
targetSide: "left" | "right",
|
||||
) => {
|
||||
): Promise<TransferStatus> => {
|
||||
const updateTask = (updates: Partial<TransferTask>) => {
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => (t.id === task.id ? { ...t, ...updates } : t)),
|
||||
@@ -321,7 +435,27 @@ export const useSftpTransfers = ({
|
||||
: targetPane.filenameEncoding || "auto";
|
||||
|
||||
let actualFileSize = task.totalBytes;
|
||||
if (!task.isDirectory && actualFileSize === 0) {
|
||||
let prescanCancelled = false;
|
||||
if (task.isDirectory) {
|
||||
try {
|
||||
const sourceSftpId = sourcePane.connection?.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(sourcePane.connection!.id);
|
||||
|
||||
actualFileSize = await estimateDirectoryBytes(
|
||||
task.sourcePath,
|
||||
sourceSftpId,
|
||||
sourcePane.connection!.isLocal,
|
||||
sourceEncoding,
|
||||
task.id,
|
||||
);
|
||||
} catch (err) {
|
||||
if (isTransferCancelledError(err)) {
|
||||
prescanCancelled = true;
|
||||
}
|
||||
// Fall back to the existing estimate below if size discovery fails.
|
||||
}
|
||||
} else if (actualFileSize === 0) {
|
||||
try {
|
||||
const sourceSftpId = sourcePane.connection?.isLocal
|
||||
? null
|
||||
@@ -352,13 +486,6 @@ export const useSftpTransfers = ({
|
||||
|
||||
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);
|
||||
@@ -378,12 +505,24 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
|
||||
let useSimulatedProgress = false;
|
||||
if (!hasStreamingTransfer || task.isDirectory) {
|
||||
useSimulatedProgress = true;
|
||||
startProgressSimulation(task.id, estimatedSize);
|
||||
}
|
||||
|
||||
try {
|
||||
if (prescanCancelled) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
updateTask({
|
||||
status: "transferring",
|
||||
totalBytes: estimatedSize,
|
||||
transferredBytes: 0,
|
||||
startTime: Date.now(),
|
||||
});
|
||||
|
||||
if (!hasStreamingTransfer && !task.isDirectory) {
|
||||
useSimulatedProgress = true;
|
||||
startProgressSimulation(task.id, estimatedSize);
|
||||
}
|
||||
|
||||
if (!task.skipConflictCheck && !task.isDirectory && targetPane.connection) {
|
||||
let targetExists = false;
|
||||
let existingStat: { size: number; mtime: number } | null = null;
|
||||
@@ -461,11 +600,36 @@ export const useSftpTransfers = ({
|
||||
status: "pending",
|
||||
totalBytes: sourceStat?.size || estimatedSize,
|
||||
});
|
||||
return;
|
||||
return "pending";
|
||||
}
|
||||
}
|
||||
|
||||
if (task.isDirectory) {
|
||||
// Track real progress for directory transfers:
|
||||
// completedBytes = sum of all finished child files
|
||||
// + currentFileTransferred = in-progress bytes of the currently transferring file
|
||||
const onChildProgress = (completedBytes: number, currentFileTransferred: number, currentFileTotal: number, speed: number) => {
|
||||
const totalProgress = completedBytes + currentFileTransferred;
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== task.id || t.status === "cancelled") return t;
|
||||
const newTotal = Math.max(
|
||||
t.totalBytes,
|
||||
totalProgress,
|
||||
completedBytes + currentFileTotal,
|
||||
);
|
||||
return {
|
||||
...t,
|
||||
transferredBytes: Math.max(
|
||||
t.transferredBytes,
|
||||
Math.min(totalProgress, newTotal),
|
||||
),
|
||||
totalBytes: newTotal,
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : t.speed,
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
await transferDirectory(
|
||||
task,
|
||||
sourceSftpId,
|
||||
@@ -475,6 +639,7 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
task.id, // rootTaskId - this is the top-level task
|
||||
onChildProgress,
|
||||
);
|
||||
} else {
|
||||
await transferFile(
|
||||
@@ -507,6 +672,20 @@ export const useSftpTransfers = ({
|
||||
);
|
||||
|
||||
await refresh(targetSide);
|
||||
const completionHandler = completionHandlersRef.current.get(task.id);
|
||||
if (completionHandler) {
|
||||
try {
|
||||
await completionHandler({
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
originalFileName: task.originalFileName ?? task.fileName,
|
||||
status: "completed",
|
||||
});
|
||||
} finally {
|
||||
completionHandlersRef.current.delete(task.id);
|
||||
}
|
||||
}
|
||||
return "completed";
|
||||
} catch (err) {
|
||||
if (useSimulatedProgress) {
|
||||
stopProgressSimulation(task.id);
|
||||
@@ -518,7 +697,21 @@ export const useSftpTransfers = ({
|
||||
|
||||
if (isCancelled) {
|
||||
// Don't update status - cancelTransfer already set it to cancelled
|
||||
return;
|
||||
const completionHandler = completionHandlersRef.current.get(task.id);
|
||||
if (completionHandler) {
|
||||
try {
|
||||
await completionHandler({
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
originalFileName: task.originalFileName ?? task.fileName,
|
||||
status: "cancelled",
|
||||
});
|
||||
} finally {
|
||||
completionHandlersRef.current.delete(task.id);
|
||||
}
|
||||
}
|
||||
clearCancelledTask(task.id);
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
updateTask({
|
||||
@@ -527,6 +720,20 @@ export const useSftpTransfers = ({
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
const completionHandler = completionHandlersRef.current.get(task.id);
|
||||
if (completionHandler) {
|
||||
try {
|
||||
await completionHandler({
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
originalFileName: task.originalFileName ?? task.fileName,
|
||||
status: "failed",
|
||||
});
|
||||
} finally {
|
||||
completionHandlersRef.current.delete(task.id);
|
||||
}
|
||||
}
|
||||
return "failed";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -535,22 +742,29 @@ export const useSftpTransfers = ({
|
||||
sourceFiles: { name: string; isDirectory: boolean }[],
|
||||
sourceSide: "left" | "right",
|
||||
targetSide: "left" | "right",
|
||||
options?: {
|
||||
sourcePane?: SftpPane;
|
||||
sourcePath?: string;
|
||||
sourceConnectionId?: string;
|
||||
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
|
||||
},
|
||||
) => {
|
||||
const sourcePane = getActivePane(sourceSide);
|
||||
const sourcePane = options?.sourcePane ?? getActivePane(sourceSide);
|
||||
const targetPane = getActivePane(targetSide);
|
||||
|
||||
if (!sourcePane?.connection || !targetPane?.connection) return;
|
||||
if (!sourcePane?.connection || !targetPane?.connection) return [];
|
||||
|
||||
const sourceEncoding: SftpFilenameEncoding = sourcePane.connection.isLocal
|
||||
? "auto"
|
||||
: sourcePane.filenameEncoding || "auto";
|
||||
|
||||
const sourcePath = sourcePane.connection.currentPath;
|
||||
const sourcePath = options?.sourcePath ?? sourcePane.connection.currentPath;
|
||||
const targetPath = targetPane.connection.currentPath;
|
||||
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
|
||||
|
||||
const sourceSftpId = sourcePane.connection.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(sourcePane.connection.id);
|
||||
: sftpSessionsRef.current.get(sourceConnectionId);
|
||||
|
||||
const newTasks: TransferTask[] = [];
|
||||
|
||||
@@ -570,11 +784,11 @@ export const useSftpTransfers = ({
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(fullPath);
|
||||
if (stat) fileSize = stat.size;
|
||||
} else if (sourceSftpId) {
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
fullPath,
|
||||
sourceEncoding,
|
||||
);
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
fullPath,
|
||||
sourceEncoding,
|
||||
);
|
||||
if (stat) fileSize = stat.size;
|
||||
}
|
||||
} catch {
|
||||
@@ -585,9 +799,10 @@ export const useSftpTransfers = ({
|
||||
newTasks.push({
|
||||
id: crypto.randomUUID(),
|
||||
fileName: file.name,
|
||||
originalFileName: file.name,
|
||||
sourcePath: joinPath(sourcePath, file.name),
|
||||
targetPath: joinPath(targetPath, file.name),
|
||||
sourceConnectionId: sourcePane.connection!.id,
|
||||
sourceConnectionId,
|
||||
targetConnectionId: targetPane.connection!.id,
|
||||
direction,
|
||||
status: "pending" as TransferStatus,
|
||||
@@ -601,9 +816,25 @@ export const useSftpTransfers = ({
|
||||
|
||||
setTransfers((prev) => [...prev, ...newTasks]);
|
||||
|
||||
for (const task of newTasks) {
|
||||
await processTransfer(task, sourcePane, targetPane, targetSide);
|
||||
if (options?.onTransferComplete) {
|
||||
for (const task of newTasks) {
|
||||
completionHandlersRef.current.set(task.id, options.onTransferComplete);
|
||||
}
|
||||
}
|
||||
|
||||
const results: TransferResult[] = [];
|
||||
|
||||
for (const task of newTasks) {
|
||||
const status = await processTransfer(task, sourcePane, targetPane, targetSide);
|
||||
results.push({
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
originalFileName: task.originalFileName ?? task.fileName,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[getActivePane, sftpSessionsRef],
|
||||
@@ -638,10 +869,6 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up cancelled task ID after a delay to ensure all async ops see it
|
||||
setTimeout(() => {
|
||||
cancelledTasksRef.current.delete(transferId);
|
||||
}, 5000);
|
||||
},
|
||||
[stopProgressSimulation],
|
||||
);
|
||||
@@ -649,7 +876,18 @@ export const useSftpTransfers = ({
|
||||
const retryTransfer = useCallback(
|
||||
async (transferId: string) => {
|
||||
const task = transfers.find((t) => t.id === transferId);
|
||||
if (!task) return;
|
||||
if (!task || task.retryable === false) return;
|
||||
|
||||
const retriedTask: TransferTask = {
|
||||
...task,
|
||||
id: crypto.randomUUID(),
|
||||
status: "pending" as TransferStatus,
|
||||
error: undefined,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
endTime: undefined,
|
||||
};
|
||||
|
||||
const sourceSide = task.sourceConnectionId.startsWith("left") ? "left" : "right";
|
||||
const targetSide = task.targetConnectionId.startsWith("left") ? "left" : "right";
|
||||
@@ -657,14 +895,20 @@ export const useSftpTransfers = ({
|
||||
const targetPane = getActivePane(targetSide as "left" | "right");
|
||||
|
||||
if (sourcePane?.connection && targetPane?.connection) {
|
||||
const completionHandler = completionHandlersRef.current.get(transferId);
|
||||
if (completionHandler) {
|
||||
completionHandlersRef.current.set(retriedTask.id, completionHandler);
|
||||
completionHandlersRef.current.delete(transferId);
|
||||
}
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === transferId
|
||||
? { ...t, status: "pending" as TransferStatus, error: undefined }
|
||||
? retriedTask
|
||||
: t,
|
||||
),
|
||||
);
|
||||
await processTransfer(task, sourcePane, targetPane, targetSide);
|
||||
await processTransfer(retriedTask, sourcePane, targetPane, targetSide);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline
|
||||
@@ -681,6 +925,10 @@ export const useSftpTransfers = ({
|
||||
setTransfers((prev) => prev.filter((t) => t.id !== transferId));
|
||||
}, []);
|
||||
|
||||
const isTransferCancelled = useCallback((transferId: string) => {
|
||||
return cancelledTasksRef.current.has(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
|
||||
@@ -693,7 +941,27 @@ export const useSftpTransfers = ({
|
||||
|
||||
const updateExternalUpload = useCallback((taskId: string, updates: Partial<TransferTask>) => {
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => (t.id === taskId ? { ...t, ...updates } : t)),
|
||||
prev.map((t) => {
|
||||
if (t.id !== taskId) return t;
|
||||
|
||||
const merged: TransferTask = { ...t, ...updates };
|
||||
|
||||
// Keep progress monotonic and bounded for stable progress UI.
|
||||
if (typeof merged.totalBytes === "number" && merged.totalBytes > 0) {
|
||||
merged.transferredBytes = Math.max(
|
||||
t.transferredBytes,
|
||||
Math.min(merged.transferredBytes, merged.totalBytes),
|
||||
);
|
||||
} else {
|
||||
merged.transferredBytes = Math.max(t.transferredBytes, merged.transferredBytes);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(merged.speed) || merged.speed < 0) {
|
||||
merged.speed = 0;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
@@ -715,6 +983,19 @@ export const useSftpTransfers = ({
|
||||
: t,
|
||||
),
|
||||
);
|
||||
const completionHandler = completionHandlersRef.current.get(conflictId);
|
||||
if (completionHandler) {
|
||||
try {
|
||||
await completionHandler({
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
originalFileName: task.originalFileName ?? task.fileName,
|
||||
status: "cancelled",
|
||||
});
|
||||
} finally {
|
||||
completionHandlersRef.current.delete(conflictId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -777,6 +1058,7 @@ export const useSftpTransfers = ({
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
cancelTransfer,
|
||||
isTransferCancelled,
|
||||
retryTransfer,
|
||||
clearCompletedTransfers,
|
||||
dismissTransfer,
|
||||
|
||||
558
application/state/useAIState.ts
Normal file
@@ -0,0 +1,558 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
||||
STORAGE_KEY_AI_ACTIVE_MODEL,
|
||||
STORAGE_KEY_AI_PERMISSION_MODE,
|
||||
STORAGE_KEY_AI_HOST_PERMISSIONS,
|
||||
STORAGE_KEY_AI_EXTERNAL_AGENTS,
|
||||
STORAGE_KEY_AI_DEFAULT_AGENT,
|
||||
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type {
|
||||
AISession,
|
||||
AIPermissionMode,
|
||||
ProviderConfig,
|
||||
HostAIPermission,
|
||||
ExternalAgentConfig,
|
||||
ChatMessage,
|
||||
AISessionScope,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
|
||||
|
||||
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
|
||||
function getAIBridge() {
|
||||
return (window as unknown as { netcatty?: Record<string, (...args: unknown[]) => unknown> }).netcatty;
|
||||
}
|
||||
|
||||
|
||||
/** Maximum number of sessions to keep in localStorage. */
|
||||
const MAX_STORED_SESSIONS = 50;
|
||||
/** Maximum number of messages per session when persisting to localStorage. */
|
||||
const MAX_SESSION_MESSAGES = 200;
|
||||
|
||||
/**
|
||||
* Prune sessions before writing to localStorage to prevent hitting the
|
||||
* ~5-10 MB storage quota. Only affects what is persisted — the in-memory
|
||||
* state retains all messages until the session is reloaded.
|
||||
*
|
||||
* - Keeps only the MAX_STORED_SESSIONS most-recently-updated sessions.
|
||||
* - Trims each session's messages to the last MAX_SESSION_MESSAGES.
|
||||
*/
|
||||
function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
||||
// Sort by updatedAt descending so we keep the newest
|
||||
const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
const limited = sorted.slice(0, MAX_STORED_SESSIONS);
|
||||
return limited.map(s => {
|
||||
if (s.messages.length > MAX_SESSION_MESSAGES) {
|
||||
return { ...s, messages: s.messages.slice(-MAX_SESSION_MESSAGES) };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
}
|
||||
|
||||
export function useAIState() {
|
||||
// ── Provider Config ──
|
||||
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
|
||||
localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS) ?? []
|
||||
);
|
||||
const [activeProviderId, setActiveProviderIdRaw] = useState<string>(() =>
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER) ?? ''
|
||||
);
|
||||
const [activeModelId, setActiveModelIdRaw] = useState<string>(() =>
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL) ?? ''
|
||||
);
|
||||
|
||||
// ── Permission Model ──
|
||||
const [globalPermissionMode, setGlobalPermissionModeRaw] = useState<AIPermissionMode>(() => {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
|
||||
if (stored === 'observer' || stored === 'confirm' || stored === 'autonomous') return stored;
|
||||
return 'confirm';
|
||||
});
|
||||
const [hostPermissions, setHostPermissionsRaw] = useState<HostAIPermission[]>(() =>
|
||||
localStorageAdapter.read<HostAIPermission[]>(STORAGE_KEY_AI_HOST_PERMISSIONS) ?? []
|
||||
);
|
||||
|
||||
// ── External Agents ──
|
||||
const [externalAgents, setExternalAgentsRaw] = useState<ExternalAgentConfig[]>(() =>
|
||||
localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS) ?? []
|
||||
);
|
||||
const [defaultAgentId, setDefaultAgentIdRaw] = useState<string>(() =>
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty'
|
||||
);
|
||||
|
||||
// ── Safety Settings ──
|
||||
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(() =>
|
||||
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
|
||||
);
|
||||
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60
|
||||
);
|
||||
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
|
||||
);
|
||||
|
||||
// ── Sessions ──
|
||||
const [sessions, setSessionsRaw] = useState<AISession[]>(() =>
|
||||
localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? []
|
||||
);
|
||||
// Ref that always holds the latest sessions for use inside debounced callbacks
|
||||
const sessionsRef = useRef(sessions);
|
||||
useEffect(() => {
|
||||
sessionsRef.current = sessions;
|
||||
}, [sessions]);
|
||||
// Per-scope active session: keyed by `${scopeType}:${scopeTargetId}`
|
||||
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>({});
|
||||
|
||||
// Per-agent model selection: remembers last selected model per agent
|
||||
const [agentModelMap, setAgentModelMapRaw] = useState<Record<string, string>>(() =>
|
||||
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {}
|
||||
);
|
||||
|
||||
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
|
||||
setActiveSessionIdMapRaw(prev => ({ ...prev, [scopeKey]: id }));
|
||||
}, []);
|
||||
|
||||
const setAgentModel = useCallback((agentId: string, modelId: string) => {
|
||||
setAgentModelMapRaw(prev => {
|
||||
const next = { ...prev, [agentId]: modelId };
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── Persist helpers ──
|
||||
const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => {
|
||||
setProvidersRaw(prev => {
|
||||
const next = typeof value === 'function' ? value(prev) : value;
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PROVIDERS, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setActiveProviderId = useCallback((id: string) => {
|
||||
setActiveProviderIdRaw(id);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, id);
|
||||
}, []);
|
||||
|
||||
const setActiveModelId = useCallback((id: string) => {
|
||||
setActiveModelIdRaw(id);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_MODEL, id);
|
||||
}, []);
|
||||
|
||||
const setGlobalPermissionMode = useCallback((mode: AIPermissionMode) => {
|
||||
setGlobalPermissionModeRaw(mode);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PERMISSION_MODE, mode);
|
||||
// Sync to MCP Server bridge (observer mode blocks write operations)
|
||||
const bridge = getAIBridge();
|
||||
bridge?.aiMcpSetPermissionMode?.(mode);
|
||||
}, []);
|
||||
|
||||
const setHostPermissions = useCallback((value: HostAIPermission[] | ((prev: HostAIPermission[]) => HostAIPermission[])) => {
|
||||
setHostPermissionsRaw(prev => {
|
||||
const next = typeof value === 'function' ? value(prev) : value;
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_HOST_PERMISSIONS, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setExternalAgents = useCallback((value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => {
|
||||
setExternalAgentsRaw(prev => {
|
||||
const next = typeof value === 'function' ? value(prev) : value;
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_AGENTS, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setDefaultAgentId = useCallback((id: string) => {
|
||||
setDefaultAgentIdRaw(id);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_DEFAULT_AGENT, id);
|
||||
}, []);
|
||||
|
||||
const setCommandBlocklist = useCallback((value: string[]) => {
|
||||
setCommandBlocklistRaw(value);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, value);
|
||||
// Sync to MCP Server bridge so ACP agents also respect the blocklist
|
||||
const bridge = getAIBridge();
|
||||
bridge?.aiMcpSetCommandBlocklist?.(value);
|
||||
}, []);
|
||||
|
||||
const setCommandTimeout = useCallback((value: number) => {
|
||||
setCommandTimeoutRaw(value);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, value);
|
||||
// Sync to MCP Server bridge
|
||||
const bridge = getAIBridge();
|
||||
bridge?.aiMcpSetCommandTimeout?.(value);
|
||||
}, []);
|
||||
|
||||
const setMaxIterations = useCallback((value: number) => {
|
||||
setMaxIterationsRaw(value);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, value);
|
||||
// Sync to MCP Server bridge (used by ACP agent path)
|
||||
const bridge = getAIBridge();
|
||||
bridge?.aiMcpSetMaxIterations?.(value);
|
||||
}, []);
|
||||
|
||||
// ── Cross-window sync via storage events ──
|
||||
// When the settings window updates localStorage, the main window picks up changes.
|
||||
useEffect(() => {
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
try {
|
||||
switch (e.key) {
|
||||
case STORAGE_KEY_AI_PROVIDERS: {
|
||||
const parsed = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS);
|
||||
if (parsed != null && !Array.isArray(parsed)) {
|
||||
console.warn('[useAIState] Cross-window sync: AI_PROVIDERS is not an array, skipping');
|
||||
break;
|
||||
}
|
||||
setProvidersRaw(parsed ?? []);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_ACTIVE_PROVIDER:
|
||||
setActiveProviderIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER) ?? '');
|
||||
break;
|
||||
case STORAGE_KEY_AI_ACTIVE_MODEL:
|
||||
setActiveModelIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL) ?? '');
|
||||
break;
|
||||
case STORAGE_KEY_AI_PERMISSION_MODE: {
|
||||
const mode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
|
||||
if (mode === 'observer' || mode === 'confirm' || mode === 'autonomous') {
|
||||
setGlobalPermissionModeRaw(mode);
|
||||
getAIBridge()?.aiMcpSetPermissionMode?.(mode);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_EXTERNAL_AGENTS: {
|
||||
const agents = localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS);
|
||||
if (agents != null && !Array.isArray(agents)) {
|
||||
console.warn('[useAIState] Cross-window sync: AI_EXTERNAL_AGENTS is not an array, skipping');
|
||||
break;
|
||||
}
|
||||
setExternalAgentsRaw(agents ?? []);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_DEFAULT_AGENT:
|
||||
setDefaultAgentIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty');
|
||||
break;
|
||||
case STORAGE_KEY_AI_COMMAND_BLOCKLIST: {
|
||||
const list = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
||||
if (list != null && !Array.isArray(list)) {
|
||||
console.warn('[useAIState] Cross-window sync: AI_COMMAND_BLOCKLIST is not an array, skipping');
|
||||
break;
|
||||
}
|
||||
const blocklist = list ?? [...DEFAULT_COMMAND_BLOCKLIST];
|
||||
setCommandBlocklistRaw(blocklist);
|
||||
getAIBridge()?.aiMcpSetCommandBlocklist?.(blocklist);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_COMMAND_TIMEOUT: {
|
||||
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
|
||||
if (!Number.isFinite(timeout)) {
|
||||
console.warn('[useAIState] Cross-window sync: AI_COMMAND_TIMEOUT is not a finite number, skipping');
|
||||
break;
|
||||
}
|
||||
setCommandTimeoutRaw(timeout);
|
||||
getAIBridge()?.aiMcpSetCommandTimeout?.(timeout);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_MAX_ITERATIONS: {
|
||||
const iters = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
|
||||
if (!Number.isFinite(iters)) {
|
||||
console.warn('[useAIState] Cross-window sync: AI_MAX_ITERATIONS is not a finite number, skipping');
|
||||
break;
|
||||
}
|
||||
setMaxIterationsRaw(iters);
|
||||
getAIBridge()?.aiMcpSetMaxIterations?.(iters);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_HOST_PERMISSIONS: {
|
||||
const perms = localStorageAdapter.read<HostAIPermission[]>(STORAGE_KEY_AI_HOST_PERMISSIONS);
|
||||
if (perms != null && !Array.isArray(perms)) {
|
||||
console.warn('[useAIState] Cross-window sync: AI_HOST_PERMISSIONS is not an array, skipping');
|
||||
break;
|
||||
}
|
||||
setHostPermissionsRaw(perms ?? []);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_AGENT_MODEL_MAP:
|
||||
setAgentModelMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {});
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[useAIState] Cross-window sync: failed to process storage event for key', e.key, err);
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => window.removeEventListener('storage', handleStorage);
|
||||
}, []);
|
||||
|
||||
// ── Sync initial safety settings to MCP Server on mount ──
|
||||
useEffect(() => {
|
||||
const bridge = getAIBridge();
|
||||
const initialBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST];
|
||||
bridge?.aiMcpSetCommandBlocklist?.(initialBlocklist);
|
||||
const initialTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
|
||||
bridge?.aiMcpSetCommandTimeout?.(initialTimeout);
|
||||
const initialMaxIter = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
|
||||
bridge?.aiMcpSetMaxIterations?.(initialMaxIter);
|
||||
const initialPermMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE) ?? 'confirm';
|
||||
bridge?.aiMcpSetPermissionMode?.(initialPermMode);
|
||||
}, []);
|
||||
|
||||
// ── Session CRUD ──
|
||||
const persistSessions = useCallback((next: AISession[]) => {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(next));
|
||||
}, []);
|
||||
|
||||
// Debounced version of persistSessions for high-frequency updates (e.g. streaming)
|
||||
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const debouncedPersistSessions = useCallback(() => {
|
||||
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = setTimeout(() => {
|
||||
if (!mountedRef.current) return; // Skip writes after unmount
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(sessionsRef.current));
|
||||
persistTimerRef.current = null;
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
// Flush pending debounced writes on unmount
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = null;
|
||||
persistSessions(sessionsRef.current);
|
||||
}
|
||||
};
|
||||
}, [persistSessions]);
|
||||
|
||||
const createSession = useCallback((scope: AISessionScope, agentId?: string): AISession => {
|
||||
const now = Date.now();
|
||||
const session: AISession = {
|
||||
id: `ai_${now}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
title: 'New Chat',
|
||||
agentId: agentId || defaultAgentId,
|
||||
scope,
|
||||
messages: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
setSessionsRaw(prev => {
|
||||
const next = [session, ...prev];
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
const scopeKey = `${scope.type}:${scope.targetId ?? ''}`;
|
||||
setActiveSessionId(scopeKey, session.id);
|
||||
return session;
|
||||
}, [defaultAgentId, persistSessions, setActiveSessionId]);
|
||||
|
||||
const deleteSession = useCallback((sessionId: string, scopeKey?: string) => {
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = null;
|
||||
}
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.filter(s => s.id !== sessionId);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
if (scopeKey) {
|
||||
setActiveSessionIdMapRaw(prev => {
|
||||
if (prev[scopeKey] === sessionId) return { ...prev, [scopeKey]: null };
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}, [persistSessions]);
|
||||
|
||||
const deleteSessionsByTarget = useCallback((scopeType: 'terminal' | 'workspace', targetId: string) => {
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = null;
|
||||
}
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.filter(s => {
|
||||
return !(s.scope.type === scopeType && s.scope.targetId === targetId);
|
||||
});
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
const scopeKey = `${scopeType}:${targetId}`;
|
||||
setActiveSessionIdMapRaw(prev => {
|
||||
if (prev[scopeKey] != null) return { ...prev, [scopeKey]: null };
|
||||
return prev;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
const updateSessionTitle = useCallback((sessionId: string, title: string) => {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => s.id === sessionId ? { ...s, title, updatedAt: Date.now() } : s);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
// Maximum messages per session to prevent unbounded memory growth
|
||||
const MAX_MESSAGES_PER_SESSION = 500;
|
||||
|
||||
const addMessageToSession = useCallback((sessionId: string, message: ChatMessage) => {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => {
|
||||
if (s.id !== sessionId) return s;
|
||||
let msgs = [...s.messages, message];
|
||||
// Trim oldest messages if exceeding limit (keep system messages)
|
||||
if (msgs.length > MAX_MESSAGES_PER_SESSION) {
|
||||
const systemMsgs = msgs.filter(m => m.role === 'system');
|
||||
const nonSystemMsgs = msgs.filter(m => m.role !== 'system');
|
||||
const dropped = nonSystemMsgs.length - (MAX_MESSAGES_PER_SESSION - systemMsgs.length);
|
||||
console.warn(`[useAIState] Session ${sessionId}: trimmed ${dropped} oldest non-system message(s) to stay within ${MAX_MESSAGES_PER_SESSION} limit`);
|
||||
msgs = [...systemMsgs, ...nonSystemMsgs.slice(-MAX_MESSAGES_PER_SESSION + systemMsgs.length)];
|
||||
}
|
||||
return { ...s, messages: msgs, updatedAt: Date.now() };
|
||||
});
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
}, [debouncedPersistSessions]);
|
||||
|
||||
const updateLastMessage = useCallback((sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => {
|
||||
if (s.id !== sessionId || s.messages.length === 0) return s;
|
||||
const msgs = [...s.messages];
|
||||
msgs[msgs.length - 1] = updater(msgs[msgs.length - 1]);
|
||||
return { ...s, messages: msgs, updatedAt: Date.now() };
|
||||
});
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
}, [debouncedPersistSessions]);
|
||||
|
||||
const updateMessageById = useCallback((sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => {
|
||||
if (s.id !== sessionId) return s;
|
||||
const idx = s.messages.findIndex(m => m.id === messageId);
|
||||
if (idx === -1) return s;
|
||||
const msgs = [...s.messages];
|
||||
msgs[idx] = updater(msgs[idx]);
|
||||
return { ...s, messages: msgs, updatedAt: Date.now() };
|
||||
});
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
}, [debouncedPersistSessions]);
|
||||
|
||||
const clearSessionMessages = useCallback((sessionId: string) => {
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = null;
|
||||
}
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => s.id === sessionId ? { ...s, messages: [], updatedAt: Date.now() } : s);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.filter(s => {
|
||||
// Keep sessions without a targetId (global scope)
|
||||
if (!s.scope.targetId) return true;
|
||||
// Keep sessions whose target still exists
|
||||
return activeTargetIds.has(s.scope.targetId);
|
||||
});
|
||||
if (next.length !== prev.length) {
|
||||
persistSessions(next);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
// ── Provider CRUD helpers ──
|
||||
const addProvider = useCallback((provider: ProviderConfig) => {
|
||||
setProviders(prev => [...prev, provider]);
|
||||
}, [setProviders]);
|
||||
|
||||
const updateProvider = useCallback((id: string, updates: Partial<ProviderConfig>) => {
|
||||
setProviders(prev => prev.map(p => p.id === id ? { ...p, ...updates } : p));
|
||||
}, [setProviders]);
|
||||
|
||||
const removeProvider = useCallback((id: string) => {
|
||||
setProviders(prev => prev.filter(p => p.id !== id));
|
||||
// Use the raw setter to avoid stale closure over setActiveProviderId
|
||||
setActiveProviderIdRaw(prevId => {
|
||||
if (prevId === id) {
|
||||
const next = '';
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, next);
|
||||
return next;
|
||||
}
|
||||
return prevId;
|
||||
});
|
||||
}, [setProviders]);
|
||||
|
||||
// ── Computed ──
|
||||
const activeProvider = providers.find(p => p.id === activeProviderId) ?? null;
|
||||
|
||||
return {
|
||||
// Provider config
|
||||
providers,
|
||||
setProviders,
|
||||
addProvider,
|
||||
updateProvider,
|
||||
removeProvider,
|
||||
activeProviderId,
|
||||
setActiveProviderId,
|
||||
activeModelId,
|
||||
setActiveModelId,
|
||||
activeProvider,
|
||||
|
||||
// Permission model
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
hostPermissions,
|
||||
setHostPermissions,
|
||||
|
||||
// External agents
|
||||
externalAgents,
|
||||
setExternalAgents,
|
||||
defaultAgentId,
|
||||
setDefaultAgentId,
|
||||
|
||||
// Safety
|
||||
commandBlocklist,
|
||||
setCommandBlocklist,
|
||||
commandTimeout,
|
||||
setCommandTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
|
||||
// Per-agent model memory
|
||||
agentModelMap,
|
||||
setAgentModel,
|
||||
|
||||
// Sessions (per-scope active session)
|
||||
sessions,
|
||||
activeSessionIdMap,
|
||||
setActiveSessionId,
|
||||
createSession,
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
clearSessionMessages,
|
||||
cleanupOrphanedSessions,
|
||||
};
|
||||
}
|
||||
101
application/state/useAgentDiscovery.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { DiscoveredAgent, ExternalAgentConfig } from '../../infrastructure/ai/types';
|
||||
|
||||
interface NetcattyBridge {
|
||||
aiDiscoverAgents(): Promise<DiscoveredAgent[]>;
|
||||
}
|
||||
|
||||
function getBridge(): NetcattyBridge | undefined {
|
||||
return (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
}
|
||||
|
||||
export function useAgentDiscovery(
|
||||
externalAgents: ExternalAgentConfig[],
|
||||
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void,
|
||||
) {
|
||||
const [discoveredAgents, setDiscoveredAgents] = useState<DiscoveredAgent[]>([]);
|
||||
const [isDiscovering, setIsDiscovering] = useState(false);
|
||||
|
||||
const discover = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge) return;
|
||||
|
||||
setIsDiscovering(true);
|
||||
try {
|
||||
const agents = await bridge.aiDiscoverAgents();
|
||||
setDiscoveredAgents(agents);
|
||||
} catch (err) {
|
||||
console.error('Agent discovery failed:', err);
|
||||
} finally {
|
||||
setIsDiscovering(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Discover on mount
|
||||
useEffect(() => {
|
||||
discover();
|
||||
}, [discover]);
|
||||
|
||||
// Auto-update args for already-configured discovered agents when
|
||||
// the canonical args from discovery change (e.g. after an app update).
|
||||
useEffect(() => {
|
||||
if (!setExternalAgents || discoveredAgents.length === 0) return;
|
||||
|
||||
setExternalAgents((prev) => {
|
||||
let changed = false;
|
||||
const next = prev.map((ea) => {
|
||||
// Only update agents that were auto-discovered (id starts with "discovered_")
|
||||
if (!ea.id.startsWith('discovered_')) return ea;
|
||||
|
||||
const match = discoveredAgents.find(
|
||||
(da) => ea.command === da.path || ea.command === da.command,
|
||||
);
|
||||
if (!match) return ea;
|
||||
|
||||
// Check if args or ACP config differ
|
||||
const currentArgs = JSON.stringify(ea.args || []);
|
||||
const newArgs = JSON.stringify(match.args);
|
||||
const acpChanged = ea.acpCommand !== match.acpCommand
|
||||
|| JSON.stringify(ea.acpArgs || []) !== JSON.stringify(match.acpArgs || []);
|
||||
if (currentArgs !== newArgs || acpChanged) {
|
||||
changed = true;
|
||||
return { ...ea, args: match.args, acpCommand: match.acpCommand, acpArgs: match.acpArgs };
|
||||
}
|
||||
return ea;
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [discoveredAgents, setExternalAgents]);
|
||||
|
||||
// Filter out agents that are already configured as external agents
|
||||
const unconfiguredAgents = discoveredAgents.filter(
|
||||
(da) => !externalAgents.some(
|
||||
(ea) => ea.command === da.command || ea.command === da.path,
|
||||
),
|
||||
);
|
||||
|
||||
// Build ExternalAgentConfig from a discovered agent
|
||||
const enableAgent = useCallback(
|
||||
(agent: DiscoveredAgent): ExternalAgentConfig => {
|
||||
return {
|
||||
id: `discovered_${agent.command}`,
|
||||
name: agent.name,
|
||||
command: agent.path || agent.command,
|
||||
args: agent.args,
|
||||
icon: agent.icon,
|
||||
enabled: true,
|
||||
acpCommand: agent.acpCommand,
|
||||
acpArgs: agent.acpArgs,
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
discoveredAgents,
|
||||
unconfiguredAgents,
|
||||
isDiscovering,
|
||||
rediscover: discover,
|
||||
enableAgent,
|
||||
};
|
||||
}
|
||||
@@ -12,7 +12,13 @@ import { useCloudSync } from './useCloudSync';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import { getCloudSyncManager } from '../../infrastructure/services/CloudSyncManager';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import type { SyncPayload } from '../../domain/sync';
|
||||
import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
|
||||
interface AutoSyncConfig {
|
||||
@@ -22,6 +28,7 @@ interface AutoSyncConfig {
|
||||
identities?: SyncPayload['identities'];
|
||||
snippets: SyncPayload['snippets'];
|
||||
customGroups: SyncPayload['customGroups'];
|
||||
snippetPackages?: SyncPayload['snippetPackages'];
|
||||
portForwardingRules?: SyncPayload['portForwardingRules'];
|
||||
knownHosts?: SyncPayload['knownHosts'];
|
||||
|
||||
@@ -31,6 +38,7 @@ interface AutoSyncConfig {
|
||||
|
||||
// Get manager singleton for direct state access
|
||||
const manager = getCloudSyncManager();
|
||||
const AUTO_SYNC_PROVIDER_ORDER: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
|
||||
type SyncTrigger = 'auto' | 'manual';
|
||||
|
||||
@@ -45,32 +53,58 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const lastSyncedDataRef = useRef<string>('');
|
||||
const hasCheckedRemoteRef = useRef(false);
|
||||
const isInitializedRef = useRef(false);
|
||||
|
||||
// Build sync payload
|
||||
const buildPayload = useCallback((): SyncPayload => {
|
||||
|
||||
const getSyncSnapshot = useCallback(() => {
|
||||
let effectivePFRules = config.portForwardingRules;
|
||||
if (!effectivePFRules || effectivePFRules.length === 0) {
|
||||
const stored = localStorageAdapter.read<SyncPayload['portForwardingRules']>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (stored && Array.isArray(stored) && stored.length > 0) {
|
||||
effectivePFRules = stored.map((rule) => ({
|
||||
...rule,
|
||||
status: 'inactive' as const,
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveKnownHosts = getEffectiveKnownHosts(config.knownHosts);
|
||||
|
||||
return {
|
||||
hosts: config.hosts,
|
||||
keys: config.keys,
|
||||
identities: config.identities,
|
||||
snippets: config.snippets,
|
||||
customGroups: config.customGroups,
|
||||
portForwardingRules: config.portForwardingRules,
|
||||
knownHosts: config.knownHosts,
|
||||
snippetPackages: config.snippetPackages,
|
||||
portForwardingRules: effectivePFRules,
|
||||
knownHosts: effectiveKnownHosts,
|
||||
};
|
||||
}, [
|
||||
config.hosts,
|
||||
config.keys,
|
||||
config.identities,
|
||||
config.snippets,
|
||||
config.customGroups,
|
||||
config.snippetPackages,
|
||||
config.portForwardingRules,
|
||||
config.knownHosts,
|
||||
]);
|
||||
|
||||
// Build sync payload
|
||||
const buildPayload = useCallback((): SyncPayload => {
|
||||
return {
|
||||
...getSyncSnapshot(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
}, [config.hosts, config.keys, config.identities, config.snippets, config.customGroups, config.portForwardingRules, config.knownHosts]);
|
||||
}, [getSyncSnapshot]);
|
||||
|
||||
// Create a hash of current data for comparison
|
||||
const getDataHash = useCallback(() => {
|
||||
const data = {
|
||||
hosts: config.hosts,
|
||||
keys: config.keys,
|
||||
identities: config.identities,
|
||||
snippets: config.snippets,
|
||||
portForwardingRules: config.portForwardingRules,
|
||||
};
|
||||
return JSON.stringify(data);
|
||||
}, [config.hosts, config.keys, config.identities, config.snippets, config.portForwardingRules]);
|
||||
return JSON.stringify(getSyncSnapshot());
|
||||
}, [getSyncSnapshot]);
|
||||
|
||||
// Sync now handler - get fresh state directly from manager
|
||||
const syncNow = useCallback(async (options?: SyncNowOptions) => {
|
||||
@@ -80,13 +114,17 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// Get fresh state directly from CloudSyncManager singleton
|
||||
let state = manager.getState();
|
||||
|
||||
const hasProvider = Object.values(state.providers).some(p => p.status === 'connected');
|
||||
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
|
||||
const syncing = state.syncState === 'SYNCING';
|
||||
|
||||
if (!hasProvider) {
|
||||
throw new Error(t('sync.autoSync.noProvider'));
|
||||
}
|
||||
if (syncing) {
|
||||
if (trigger === 'auto') {
|
||||
console.info('[AutoSync] Skipping overlapping auto-sync because another sync is already running.');
|
||||
return;
|
||||
}
|
||||
throw new Error(t('sync.autoSync.alreadySyncing'));
|
||||
}
|
||||
|
||||
@@ -108,7 +146,14 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
throw new Error(t('sync.autoSync.vaultLocked'));
|
||||
}
|
||||
|
||||
const dataHash = getDataHash();
|
||||
const payload = buildPayload();
|
||||
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
|
||||
if (encryptedCredentialPaths.length > 0) {
|
||||
console.warn('[AutoSync] Blocked: encrypted credential placeholders found at:', encryptedCredentialPaths.join(', '));
|
||||
throw new Error(t('sync.credentialsUnavailable'));
|
||||
}
|
||||
|
||||
const results = await sync.syncNow(payload);
|
||||
|
||||
for (const result of results.values()) {
|
||||
@@ -120,7 +165,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
}
|
||||
|
||||
lastSyncedDataRef.current = getDataHash();
|
||||
lastSyncedDataRef.current = dataHash;
|
||||
} catch (error) {
|
||||
if (trigger === 'manual') {
|
||||
throw error;
|
||||
@@ -136,7 +181,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// Check remote version and pull if newer (on startup)
|
||||
const checkRemoteVersion = useCallback(async () => {
|
||||
const state = manager.getState();
|
||||
const hasProvider = Object.values(state.providers).some(p => p.status === 'connected');
|
||||
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
|
||||
const unlocked = state.securityState === 'UNLOCKED';
|
||||
|
||||
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current) {
|
||||
@@ -146,12 +191,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
hasCheckedRemoteRef.current = true;
|
||||
|
||||
// Find connected provider
|
||||
const connectedProvider =
|
||||
state.providers.github.status === 'connected' ? 'github' :
|
||||
state.providers.google.status === 'connected' ? 'google' :
|
||||
state.providers.onedrive.status === 'connected' ? 'onedrive' :
|
||||
state.providers.webdav.status === 'connected' ? 'webdav' :
|
||||
state.providers.s3.status === 'connected' ? 's3' : null;
|
||||
const connectedProvider = AUTO_SYNC_PROVIDER_ORDER.find((provider) =>
|
||||
isProviderReadyForSync(state.providers[provider]),
|
||||
) ?? null;
|
||||
|
||||
if (!connectedProvider) return;
|
||||
|
||||
@@ -190,6 +232,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (currentHash === lastSyncedDataRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for the current sync to finish, then this effect will re-run
|
||||
// because sync.isSyncing changed.
|
||||
if (sync.isSyncing) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing timeout
|
||||
if (syncTimeoutRef.current) {
|
||||
@@ -207,7 +255,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, getDataHash, syncNow]);
|
||||
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow]);
|
||||
|
||||
// Check remote version on startup/unlock
|
||||
useEffect(() => {
|
||||
|
||||
14
application/state/useClipboardBackend.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useCallback } from "react";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
|
||||
export const useClipboardBackend = () => {
|
||||
const readClipboardText = useCallback(async (): Promise<string> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.readClipboardText) throw new Error("clipboard bridge unavailable");
|
||||
|
||||
const text = await bridge.readClipboardText();
|
||||
return typeof text === "string" ? text : "";
|
||||
}, []);
|
||||
|
||||
return { readClipboardText };
|
||||
};
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type S3Config,
|
||||
formatLastSync,
|
||||
getSyncDotColor,
|
||||
isProviderReadyForSync,
|
||||
} from '../../domain/sync';
|
||||
import {
|
||||
CloudSyncManager,
|
||||
@@ -181,13 +182,13 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
|
||||
const hasAnyConnectedProvider = useMemo(() => {
|
||||
return (Object.values(state.providers) as ProviderConnection[]).some(
|
||||
(p) => p.status === 'connected' || p.status === 'syncing'
|
||||
(p) => isProviderReadyForSync(p)
|
||||
);
|
||||
}, [state.providers]);
|
||||
|
||||
const connectedProviderCount = useMemo(() => {
|
||||
return (Object.values(state.providers) as ProviderConnection[]).filter(
|
||||
(p) => p.status === 'connected' || p.status === 'syncing'
|
||||
(p) => isProviderReadyForSync(p)
|
||||
).length;
|
||||
}, [state.providers]);
|
||||
|
||||
@@ -286,7 +287,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
|
||||
// Open browser after starting server
|
||||
setTimeout(() => {
|
||||
window.open(data.url, '_blank', 'width=600,height=700');
|
||||
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
|
||||
}, 100);
|
||||
|
||||
// Wait for callback
|
||||
@@ -319,7 +320,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
|
||||
// Open browser after starting server
|
||||
setTimeout(() => {
|
||||
window.open(data.url, '_blank', 'width=600,height=700');
|
||||
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
|
||||
}, 100);
|
||||
|
||||
// Wait for callback
|
||||
@@ -519,7 +520,7 @@ export const useProviderStatus = (provider: CloudProvider) => {
|
||||
|
||||
return {
|
||||
...connection,
|
||||
isConnected: connection.status === 'connected',
|
||||
isConnected: isProviderReadyForSync(connection),
|
||||
isSyncing: connection.status === 'syncing',
|
||||
hasError: connection.status === 'error',
|
||||
dotColor: getSyncDotColor(connection.status),
|
||||
|
||||
@@ -135,8 +135,6 @@ export const useGlobalHotkeys = ({
|
||||
e.stopPropagation();
|
||||
|
||||
const currentActions = actionsRef.current;
|
||||
const _tabs = orderedTabsRef.current;
|
||||
|
||||
switch (action) {
|
||||
case 'switchToTab': {
|
||||
const num = parseInt(e.key, 10);
|
||||
|
||||
66
application/state/useImageUpload.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* useImageUpload - Handle image paste/drop with base64 conversion
|
||||
*
|
||||
* Ported from 1code's use-agents-file-upload.ts
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export interface UploadedImage {
|
||||
id: string;
|
||||
filename: string;
|
||||
dataUrl: string; // data:image/...;base64,... for preview
|
||||
base64Data: string; // raw base64 for API
|
||||
mediaType: string; // MIME type e.g. "image/png"
|
||||
}
|
||||
|
||||
async function fileToDataUrl(file: File): Promise<{ dataUrl: string; base64: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const dataUrl = reader.result as string;
|
||||
const base64 = dataUrl.split(',')[1] || '';
|
||||
resolve({ dataUrl, base64 });
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function useImageUpload() {
|
||||
const [images, setImages] = useState<UploadedImage[]>([]);
|
||||
|
||||
const addImages = useCallback(async (files: File[]) => {
|
||||
const imageFiles = files.filter((f) => f.type.startsWith('image/'));
|
||||
if (imageFiles.length === 0) return;
|
||||
|
||||
const newImages: UploadedImage[] = await Promise.all(
|
||||
imageFiles.map(async (file) => {
|
||||
const id = crypto.randomUUID();
|
||||
const filename = file.name || `screenshot-${Date.now()}.png`;
|
||||
const mediaType = file.type || 'image/png';
|
||||
let dataUrl = '';
|
||||
let base64Data = '';
|
||||
try {
|
||||
const result = await fileToDataUrl(file);
|
||||
dataUrl = result.dataUrl;
|
||||
base64Data = result.base64;
|
||||
} catch (err) {
|
||||
console.error('[useImageUpload] Failed to convert:', err);
|
||||
}
|
||||
return { id, filename, dataUrl, base64Data, mediaType };
|
||||
}),
|
||||
);
|
||||
|
||||
setImages((prev) => [...prev, ...newImages]);
|
||||
}, []);
|
||||
|
||||
const removeImage = useCallback((id: string) => {
|
||||
setImages((prev) => prev.filter((i) => i.id !== id));
|
||||
}, []);
|
||||
|
||||
const clearImages = useCallback(() => {
|
||||
setImages([]);
|
||||
}, []);
|
||||
|
||||
return { images, addImages, removeImage, clearImages };
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { logger } from "../../lib/logger";
|
||||
|
||||
export interface UsePortForwardingAutoStartOptions {
|
||||
hosts: Host[];
|
||||
keys: { id: string; privateKey: string }[];
|
||||
keys: { id: string; privateKey: string; passphrase: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,7 +30,7 @@ export const usePortForwardingAutoStart = ({
|
||||
}: UsePortForwardingAutoStartOptions): void => {
|
||||
const autoStartExecutedRef = useRef(false);
|
||||
const hostsRef = useRef<Host[]>(hosts);
|
||||
const keysRef = useRef<{ id: string; privateKey: string }[]>(keys);
|
||||
const keysRef = useRef<{ id: string; privateKey: string; passphrase: string }[]>(keys);
|
||||
|
||||
// Keep refs in sync
|
||||
useEffect(() => {
|
||||
|
||||
@@ -9,12 +9,25 @@ import { localStorageAdapter } from "../../infrastructure/persistence/localStora
|
||||
import {
|
||||
clearReconnectTimer,
|
||||
getActiveConnection,
|
||||
initReconnectCancelListener,
|
||||
reconcileWithBackend,
|
||||
startPortForward,
|
||||
stopAllPortForwards,
|
||||
stopAndCleanupRule,
|
||||
stopPortForward,
|
||||
syncWithBackend,
|
||||
} from "../../infrastructure/services/portForwardingService";
|
||||
import { useStoredViewMode, ViewMode } from "./useStoredViewMode";
|
||||
|
||||
// Module-level ref-counts: these side effects must run at most once per
|
||||
// window, not per hook instance (the hook mounts from both App.tsx
|
||||
// and PortForwardingNew.tsx). Ref-counting ensures the resources
|
||||
// stay alive as long as ANY instance is mounted.
|
||||
let reconnectCancelListenerRefs = 0;
|
||||
let reconnectCancelCleanup: (() => void) | undefined;
|
||||
let heartbeatRefs = 0;
|
||||
let heartbeatIntervalId: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
export type { ViewMode };
|
||||
|
||||
export type SortMode = "az" | "za" | "newest" | "oldest";
|
||||
@@ -50,7 +63,7 @@ export interface UsePortForwardingStateResult {
|
||||
startTunnel: (
|
||||
rule: PortForwardingRule,
|
||||
host: Host,
|
||||
keys: { id: string; privateKey: string }[],
|
||||
keys: { id: string; privateKey: string; passphrase: string }[],
|
||||
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
|
||||
enableReconnect?: boolean,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
@@ -79,8 +92,8 @@ const setGlobalRules = (newRules: PortForwardingRule[]) => {
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, newRules);
|
||||
};
|
||||
|
||||
const normalizeRulesWithConnections = (rules: PortForwardingRule[]) => {
|
||||
return rules.map((rule) => {
|
||||
const normalizeRulesWithConnections = (rules: PortForwardingRule[]): PortForwardingRule[] => {
|
||||
return rules.map((rule): PortForwardingRule => {
|
||||
const connection = getActiveConnection(rule.id);
|
||||
if (connection) {
|
||||
return {
|
||||
@@ -92,7 +105,7 @@ const normalizeRulesWithConnections = (rules: PortForwardingRule[]) => {
|
||||
|
||||
return {
|
||||
...rule,
|
||||
status: "inactive",
|
||||
status: "inactive" as const,
|
||||
error: undefined,
|
||||
};
|
||||
});
|
||||
@@ -152,6 +165,78 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
};
|
||||
}, [rules]);
|
||||
|
||||
// Listen for storage events for cross-window sync (main window <-> tray panel)
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
// Only handle changes from our specific key
|
||||
if (e.key !== STORAGE_KEY_PORT_FORWARDING) return;
|
||||
|
||||
// Parse the new value
|
||||
if (e.newValue) {
|
||||
try {
|
||||
const newRules = JSON.parse(e.newValue) as PortForwardingRule[];
|
||||
if (Array.isArray(newRules)) {
|
||||
// Update global state without triggering another localStorage write
|
||||
globalRules = normalizeRulesWithConnections(newRules);
|
||||
notifyListeners();
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
return () => window.removeEventListener("storage", handleStorageChange);
|
||||
}, []);
|
||||
|
||||
// Listen for cross-window reconnect cancellation events.
|
||||
// Ref-counted so the listener stays alive as long as ANY hook
|
||||
// instance is mounted (App.tsx outlives PortForwardingNew.tsx).
|
||||
useEffect(() => {
|
||||
reconnectCancelListenerRefs++;
|
||||
let cleanup: (() => void) | undefined;
|
||||
if (reconnectCancelListenerRefs === 1) {
|
||||
cleanup = initReconnectCancelListener();
|
||||
reconnectCancelCleanup = cleanup;
|
||||
}
|
||||
return () => {
|
||||
reconnectCancelListenerRefs--;
|
||||
if (reconnectCancelListenerRefs === 0 && reconnectCancelCleanup) {
|
||||
reconnectCancelCleanup();
|
||||
reconnectCancelCleanup = undefined;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Periodic heartbeat: reconcile renderer state with the backend every 4s.
|
||||
// Ref-counted — same pattern as the reconnect cancel listener.
|
||||
useEffect(() => {
|
||||
heartbeatRefs++;
|
||||
let intervalId: ReturnType<typeof setInterval> | undefined;
|
||||
if (heartbeatRefs === 1) {
|
||||
const HEARTBEAT_INTERVAL_MS = 4_000;
|
||||
|
||||
const tick = async () => {
|
||||
const { gone, appeared } = await reconcileWithBackend();
|
||||
if (gone.length === 0 && appeared.length === 0) return;
|
||||
|
||||
// Re-derive statuses from the now-updated activeConnections map
|
||||
setGlobalRules(normalizeRulesWithConnections(globalRules));
|
||||
};
|
||||
|
||||
intervalId = setInterval(tick, HEARTBEAT_INTERVAL_MS);
|
||||
heartbeatIntervalId = intervalId;
|
||||
}
|
||||
return () => {
|
||||
heartbeatRefs--;
|
||||
if (heartbeatRefs === 0 && heartbeatIntervalId !== undefined) {
|
||||
clearInterval(heartbeatIntervalId);
|
||||
heartbeatIntervalId = undefined;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const addRule = useCallback(
|
||||
(
|
||||
rule: Omit<PortForwardingRule, "id" | "createdAt" | "status">,
|
||||
@@ -182,6 +267,8 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
|
||||
const deleteRule = useCallback(
|
||||
(id: string) => {
|
||||
// Stop any active tunnel before removing the rule
|
||||
stopAndCleanupRule(id);
|
||||
const updated = globalRules.filter((r) => r.id !== id);
|
||||
setGlobalRules(updated);
|
||||
if (selectedRuleId === id) {
|
||||
@@ -213,6 +300,60 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
);
|
||||
|
||||
const importRules = useCallback((newRules: PortForwardingRule[]) => {
|
||||
// When clearing all rules (e.g. "Clear local data"), stop ALL tunnels
|
||||
// and broadcast per-rule reconnect cancellation. stopAllPortForwards
|
||||
// handles the backend, but we also need per-rule broadcasts so other
|
||||
// windows cancel their pending reconnect timers.
|
||||
if (newRules.length === 0) {
|
||||
// Read from localStorage since globalRules may be empty (uninitialized)
|
||||
const storedRules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
const rulesToCancel = globalRules.length > 0
|
||||
? globalRules
|
||||
: (storedRules && Array.isArray(storedRules) ? storedRules : []);
|
||||
for (const rule of rulesToCancel) {
|
||||
stopAndCleanupRule(rule.id);
|
||||
}
|
||||
// Safety net: also stop anything the renderer doesn't know about
|
||||
void stopAllPortForwards();
|
||||
}
|
||||
|
||||
// Stop tunnels for rules that are being removed or whose connection
|
||||
// config has changed (same ID but different host/port/type means the
|
||||
// old tunnel is pointing at stale parameters and must be torn down).
|
||||
//
|
||||
// Use globalRules as the diff baseline. In a freshly opened settings
|
||||
// window, globalRules may still be empty because initializeStore is
|
||||
// async. Fall back to reading directly from localStorage to avoid
|
||||
// missing tunnels that need to be stopped.
|
||||
let diffBaseline = globalRules;
|
||||
if (diffBaseline.length === 0 && newRules.length > 0) {
|
||||
const stored = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (stored && Array.isArray(stored) && stored.length > 0) {
|
||||
diffBaseline = stored;
|
||||
}
|
||||
}
|
||||
const newRulesById = new Map(newRules.map((r) => [r.id, r]));
|
||||
for (const existing of diffBaseline) {
|
||||
const incoming = newRulesById.get(existing.id);
|
||||
if (!incoming) {
|
||||
// Rule removed entirely
|
||||
stopAndCleanupRule(existing.id);
|
||||
} else if (
|
||||
existing.type !== incoming.type ||
|
||||
existing.localPort !== incoming.localPort ||
|
||||
existing.remoteHost !== incoming.remoteHost ||
|
||||
existing.remotePort !== incoming.remotePort ||
|
||||
existing.bindAddress !== incoming.bindAddress ||
|
||||
existing.hostId !== incoming.hostId
|
||||
) {
|
||||
// Connection-relevant config changed — tear down the old tunnel
|
||||
stopAndCleanupRule(existing.id);
|
||||
}
|
||||
}
|
||||
setGlobalRules(normalizeRulesWithConnections(newRules));
|
||||
}, []);
|
||||
|
||||
@@ -236,7 +377,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
async (
|
||||
rule: PortForwardingRule,
|
||||
host: Host,
|
||||
keys: { id: string; privateKey: string }[],
|
||||
keys: { id: string; privateKey: string; passphrase: string }[],
|
||||
onStatusChange?: (
|
||||
status: PortForwardingRule["status"],
|
||||
error?: string,
|
||||
|
||||
@@ -49,9 +49,10 @@ export const useSessionState = () => {
|
||||
username: 'local',
|
||||
status: 'connecting',
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
}, [setActiveTabId]);
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
return sessionId;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const createSerialSession = useCallback((config: SerialConfig) => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
@@ -69,6 +70,7 @@ export const useSessionState = () => {
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
return sessionId;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const connectToHost = useCallback((host: Host) => {
|
||||
@@ -100,7 +102,7 @@ export const useSessionState = () => {
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
return;
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
const newSession: TerminalSession = {
|
||||
@@ -115,9 +117,10 @@ export const useSessionState = () => {
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(newSession.id);
|
||||
}, [setActiveTabId]);
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(newSession.id);
|
||||
return newSession.id;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const updateSessionStatus = useCallback((sessionId: string, status: TerminalSession['status']) => {
|
||||
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
import { useCallback,useEffect,useLayoutEffect,useMemo,useState } from 'react';
|
||||
import { SyncConfig, TerminalSettings, DEFAULT_TERMINAL_SETTINGS, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat } from '../../domain/models';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
||||
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
|
||||
import {
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_SYNC,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
STORAGE_KEY_HOTKEY_SCHEME,
|
||||
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
STORAGE_KEY_HOTKEY_RECORDING,
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_UI_LANGUAGE,
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_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,
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_SYNC,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
STORAGE_KEY_HOTKEY_SCHEME,
|
||||
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
STORAGE_KEY_HOTKEY_RECORDING,
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_UI_LANGUAGE,
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_UI_FONT_FAMILY,
|
||||
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_EDITOR_WORD_WRAP,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../state/customThemeStore';
|
||||
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';
|
||||
@@ -35,7 +39,13 @@ import { useAvailableFonts } from './fontStore';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
const DEFAULT_THEME: 'light' | 'dark' = 'light';
|
||||
const DEFAULT_THEME: 'light' | 'dark' | 'system' = 'dark';
|
||||
|
||||
/** Resolve the current OS color scheme preference. */
|
||||
const getSystemPreference = (): 'light' | 'dark' =>
|
||||
typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
const DEFAULT_LIGHT_UI_THEME = 'snow';
|
||||
const DEFAULT_DARK_UI_THEME = 'midnight';
|
||||
const DEFAULT_ACCENT_MODE: 'theme' | 'custom' = 'theme';
|
||||
@@ -52,6 +62,9 @@ const DEFAULT_SFTP_AUTO_SYNC = false;
|
||||
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
|
||||
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
|
||||
|
||||
// Editor defaults
|
||||
const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
|
||||
// Session Logs defaults
|
||||
const DEFAULT_SESSION_LOGS_ENABLED = false;
|
||||
const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
|
||||
@@ -70,7 +83,7 @@ const readStoredString = (key: string): string | null => {
|
||||
}
|
||||
};
|
||||
|
||||
const isValidTheme = (value: unknown): value is 'light' | 'dark' => value === 'light' || value === 'dark';
|
||||
const isValidTheme = (value: unknown): value is 'light' | 'dark' | 'system' => value === 'light' || value === 'dark' || value === 'system';
|
||||
|
||||
const isValidHslToken = (value: string): boolean => {
|
||||
// Expect: "<h> <s>% <l>%", e.g. "221.2 83.2% 53.3%"
|
||||
@@ -87,18 +100,25 @@ const isValidUiFontId = (value: string): boolean => {
|
||||
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);
|
||||
uiFontStore.getAvailableFonts().some((font) => font.id === value);
|
||||
};
|
||||
|
||||
const serializeTerminalSettings = (settings: TerminalSettings): string =>
|
||||
JSON.stringify(settings);
|
||||
|
||||
const areTerminalSettingsEqual = (a: TerminalSettings, b: TerminalSettings): boolean =>
|
||||
serializeTerminalSettings(a) === serializeTerminalSettings(b);
|
||||
|
||||
const applyThemeTokens = (
|
||||
theme: 'light' | 'dark',
|
||||
themeSource: 'light' | 'dark' | 'system',
|
||||
resolvedTheme: 'light' | 'dark',
|
||||
tokens: UiThemeTokens,
|
||||
accentMode: 'theme' | 'custom',
|
||||
accentOverride: string,
|
||||
) => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(theme);
|
||||
root.classList.add(resolvedTheme);
|
||||
root.style.setProperty('--background', tokens.background);
|
||||
root.style.setProperty('--foreground', tokens.foreground);
|
||||
root.style.setProperty('--card', tokens.card);
|
||||
@@ -107,7 +127,7 @@ const applyThemeTokens = (
|
||||
root.style.setProperty('--popover-foreground', tokens.popoverForeground);
|
||||
const accentToken = accentMode === 'custom' ? accentOverride : tokens.accent;
|
||||
const accentLightness = parseFloat(accentToken.split(/\s+/)[2]?.replace('%', '') || '');
|
||||
const computedAccentForeground = theme === 'dark'
|
||||
const computedAccentForeground = resolvedTheme === 'dark'
|
||||
? '220 40% 96%'
|
||||
: (!Number.isNaN(accentLightness) && accentLightness < 55 ? '0 0% 98%' : '222 47% 12%');
|
||||
|
||||
@@ -126,17 +146,21 @@ const applyThemeTokens = (
|
||||
root.style.setProperty('--ring', accentToken);
|
||||
|
||||
// Sync with native window title bar (Electron)
|
||||
netcattyBridge.get()?.setTheme?.(theme);
|
||||
netcattyBridge.get()?.setTheme?.(themeSource);
|
||||
netcattyBridge.get()?.setBackgroundColor?.(tokens.background);
|
||||
};
|
||||
|
||||
export const useSettingsState = () => {
|
||||
const availableFonts = useAvailableFonts();
|
||||
const uiFontsLoaded = useUIFontsLoaded();
|
||||
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
|
||||
const [theme, setTheme] = useState<'dark' | 'light' | 'system'>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_THEME);
|
||||
return stored && isValidTheme(stored) ? stored : DEFAULT_THEME;
|
||||
});
|
||||
// Track the OS color scheme preference (updated by matchMedia listener)
|
||||
const [systemPreference, setSystemPreference] = useState<'light' | 'dark'>(getSystemPreference);
|
||||
// resolvedTheme is always 'light' or 'dark' — derived synchronously from theme + OS preference
|
||||
const resolvedTheme: 'light' | 'dark' = theme === 'system' ? systemPreference : theme;
|
||||
const [lightUiThemeId, setLightUiThemeId] = useState<string>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_UI_THEME_LIGHT);
|
||||
return stored && isValidUiThemeId('light', stored) ? stored : DEFAULT_LIGHT_UI_THEME;
|
||||
@@ -167,9 +191,9 @@ export const useSettingsState = () => {
|
||||
const stored = readStoredString(STORAGE_KEY_UI_LANGUAGE);
|
||||
return resolveSupportedLocale(stored || DEFAULT_UI_LOCALE);
|
||||
});
|
||||
const [terminalSettings, setTerminalSettings] = useState<TerminalSettings>(() => {
|
||||
const [terminalSettings, setTerminalSettingsState] = useState<TerminalSettings>(() => {
|
||||
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
|
||||
return stored ? { ...DEFAULT_TERMINAL_SETTINGS, ...stored } : DEFAULT_TERMINAL_SETTINGS;
|
||||
return normalizeTerminalSettings(stored);
|
||||
});
|
||||
const [hotkeyScheme, setHotkeyScheme] = useState<HotkeyScheme>(() => {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_HOTKEY_SCHEME);
|
||||
@@ -206,6 +230,12 @@ export const useSettingsState = () => {
|
||||
return DEFAULT_SFTP_USE_COMPRESSED_UPLOAD;
|
||||
});
|
||||
|
||||
// Editor Settings
|
||||
const [editorWordWrap, setEditorWordWrapState] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_EDITOR_WORD_WRAP);
|
||||
return stored === 'true' ? true : DEFAULT_EDITOR_WORD_WRAP;
|
||||
});
|
||||
|
||||
// Session Logs Settings
|
||||
const [sessionLogsEnabled, setSessionLogsEnabled] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SESSION_LOGS_ENABLED);
|
||||
@@ -220,6 +250,51 @@ export const useSettingsState = () => {
|
||||
return DEFAULT_SESSION_LOGS_FORMAT;
|
||||
});
|
||||
|
||||
// Global Toggle Window Settings (Quake Mode)
|
||||
const [toggleWindowHotkey, setToggleWindowHotkey] = useState<string>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY);
|
||||
if (stored !== null) return stored;
|
||||
// Default: Ctrl+` (Control+backtick) - similar to VS Code terminal toggle
|
||||
const isMac = typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform);
|
||||
return isMac ? '⌃ + `' : 'Ctrl + `';
|
||||
});
|
||||
const [closeToTray, setCloseToTray] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_CLOSE_TO_TRAY);
|
||||
// Default to true (enabled)
|
||||
if (stored === null) return true;
|
||||
return stored === 'true';
|
||||
});
|
||||
const [hotkeyRegistrationError, setHotkeyRegistrationError] = useState<string | null>(null);
|
||||
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
|
||||
const localTerminalSettingsVersionRef = useRef(0);
|
||||
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
|
||||
|
||||
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
|
||||
setTerminalSettingsState((prev) => {
|
||||
const candidate = typeof nextValue === 'function'
|
||||
? (nextValue as (prevState: TerminalSettings) => TerminalSettings)(prev)
|
||||
: nextValue;
|
||||
const next = normalizeTerminalSettings(candidate);
|
||||
if (areTerminalSettingsEqual(prev, next)) {
|
||||
return prev;
|
||||
}
|
||||
localTerminalSettingsVersionRef.current += 1;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const mergeIncomingTerminalSettings = useCallback((incoming: Partial<TerminalSettings>) => {
|
||||
setTerminalSettingsState((prev) => {
|
||||
const next = normalizeTerminalSettings({ ...prev, ...incoming });
|
||||
if (areTerminalSettingsEqual(prev, next)) {
|
||||
return prev;
|
||||
}
|
||||
// Mark the exact incoming snapshot so only this state is skipped for IPC rebroadcast.
|
||||
incomingTerminalSettingsSignatureRef.current = serializeTerminalSettings(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Helper to notify other windows about settings changes via IPC
|
||||
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
|
||||
try {
|
||||
@@ -247,8 +322,9 @@ export const useSettingsState = () => {
|
||||
setAccentMode(nextAccentMode);
|
||||
setCustomAccent(nextAccent);
|
||||
|
||||
const tokens = getUiThemeById(nextTheme, nextTheme === 'dark' ? nextDarkId : nextLightId).tokens;
|
||||
applyThemeTokens(nextTheme, tokens, nextAccentMode, nextAccent);
|
||||
const effective = nextTheme === 'system' ? getSystemPreference() : nextTheme;
|
||||
const tokens = getUiThemeById(effective, effective === 'dark' ? nextDarkId : nextLightId).tokens;
|
||||
applyThemeTokens(nextTheme, effective, tokens, nextAccentMode, nextAccent);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
|
||||
|
||||
const syncCustomCssFromStorage = useCallback(() => {
|
||||
@@ -257,8 +333,8 @@ export const useSettingsState = () => {
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const tokens = getUiThemeById(theme, theme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
applyThemeTokens(theme, tokens, accentMode, customAccent);
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_THEME, theme);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
|
||||
@@ -270,7 +346,18 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
|
||||
notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode);
|
||||
notifySettingsChanged(STORAGE_KEY_COLOR, customAccent);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, notifySettingsChanged]);
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, notifySettingsChanged]);
|
||||
|
||||
// Listen for OS color scheme changes to keep systemPreference in sync
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return;
|
||||
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
setSystemPreference(e.matches ? 'dark' : 'light');
|
||||
};
|
||||
mql.addEventListener('change', handler);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
|
||||
@@ -289,11 +376,11 @@ export const useSettingsState = () => {
|
||||
}, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]);
|
||||
|
||||
// Listen for settings changes from other windows via IPC
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onSettingsChanged) return;
|
||||
const unsubscribe = bridge.onSettingsChanged((payload) => {
|
||||
const { key, value } = payload;
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onSettingsChanged) return;
|
||||
const unsubscribe = bridge.onSettingsChanged((payload) => {
|
||||
const { key, value } = payload;
|
||||
if (
|
||||
key === STORAGE_KEY_THEME ||
|
||||
key === STORAGE_KEY_UI_THEME_LIGHT ||
|
||||
@@ -307,8 +394,8 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_UI_LANGUAGE && typeof value === 'string') {
|
||||
const next = resolveSupportedLocale(value);
|
||||
setUiLanguage((prev) => (prev === next ? prev : next));
|
||||
document.documentElement.lang = next;
|
||||
}
|
||||
document.documentElement.lang = next;
|
||||
}
|
||||
if (key === STORAGE_KEY_CUSTOM_CSS && typeof value === 'string') {
|
||||
syncCustomCssFromStorage();
|
||||
}
|
||||
@@ -326,6 +413,33 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_TERM_FONT_SIZE && typeof value === 'number') {
|
||||
setTerminalFontSize(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_SETTINGS) {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<TerminalSettings>;
|
||||
mergeIncomingTerminalSettings(parsed);
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
} else if (value && typeof value === 'object') {
|
||||
mergeIncomingTerminalSettings(value as Partial<TerminalSettings>);
|
||||
}
|
||||
}
|
||||
if (key === STORAGE_KEY_EDITOR_WORD_WRAP && typeof value === 'boolean') {
|
||||
setEditorWordWrapState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SESSION_LOGS_ENABLED && typeof value === 'boolean') {
|
||||
setSessionLogsEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SESSION_LOGS_DIR && typeof value === 'string') {
|
||||
setSessionLogsDir((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (
|
||||
key === STORAGE_KEY_SESSION_LOGS_FORMAT &&
|
||||
(value === 'txt' || value === 'raw' || value === 'html')
|
||||
) {
|
||||
setSessionLogsFormat((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
|
||||
setHotkeyScheme(value);
|
||||
}
|
||||
@@ -351,7 +465,7 @@ export const useSettingsState = () => {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
}, [syncAppearanceFromStorage, syncCustomCssFromStorage]);
|
||||
}, [mergeIncomingTerminalSettings, syncAppearanceFromStorage, syncCustomCssFromStorage]);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -429,14 +543,14 @@ export const useSettingsState = () => {
|
||||
}
|
||||
}
|
||||
// Sync terminal settings from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
|
||||
try {
|
||||
const newSettings = JSON.parse(e.newValue) as TerminalSettings;
|
||||
setTerminalSettings(_prev => ({ ...DEFAULT_TERMINAL_SETTINGS, ...newSettings }));
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
|
||||
try {
|
||||
const newSettings = JSON.parse(e.newValue) as TerminalSettings;
|
||||
mergeIncomingTerminalSettings(newSettings);
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
// Sync terminal theme from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_THEME && e.newValue) {
|
||||
if (e.newValue !== terminalThemeId) {
|
||||
@@ -476,6 +590,31 @@ export const useSettingsState = () => {
|
||||
setSftpShowHiddenFiles(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_EDITOR_WORD_WRAP && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== editorWordWrap) {
|
||||
setEditorWordWrapState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sessionLogsEnabled) {
|
||||
setSessionLogsEnabled(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_DIR && e.newValue !== null) {
|
||||
if (e.newValue !== sessionLogsDir) {
|
||||
setSessionLogsDir(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_FORMAT && e.newValue) {
|
||||
if (
|
||||
(e.newValue === 'txt' || e.newValue === 'raw' || e.newValue === 'html') &&
|
||||
e.newValue !== sessionLogsFormat
|
||||
) {
|
||||
setSessionLogsFormat(e.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';
|
||||
@@ -487,7 +626,7 @@ export const useSettingsState = () => {
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload]);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, mergeIncomingTerminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
@@ -506,7 +645,17 @@ export const useSettingsState = () => {
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
|
||||
}, [terminalSettings]);
|
||||
const currentSignature = serializeTerminalSettings(terminalSettings);
|
||||
const hasPendingUnbroadcastLocalChanges =
|
||||
localTerminalSettingsVersionRef.current !== broadcastedLocalTerminalSettingsVersionRef.current;
|
||||
if (incomingTerminalSettingsSignatureRef.current === currentSignature && !hasPendingUnbroadcastLocalChanges) {
|
||||
incomingTerminalSettingsSignatureRef.current = null;
|
||||
return;
|
||||
}
|
||||
incomingTerminalSettingsSignatureRef.current = null;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
|
||||
broadcastedLocalTerminalSettingsVersionRef.current = localTerminalSettingsVersionRef.current;
|
||||
}, [terminalSettings, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
|
||||
@@ -578,6 +727,49 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
|
||||
}, [sessionLogsFormat, notifySettingsChanged]);
|
||||
|
||||
// Persist and sync toggle window hotkey setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
// Register/unregister the global hotkey in main process
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.registerGlobalHotkey) {
|
||||
if (toggleWindowHotkey) {
|
||||
setHotkeyRegistrationError(null);
|
||||
bridge
|
||||
.registerGlobalHotkey(toggleWindowHotkey)
|
||||
.then((result) => {
|
||||
if (result?.success === false) {
|
||||
console.warn('[GlobalHotkey] Hotkey registration failed:', result.error);
|
||||
setHotkeyRegistrationError(result.error || 'Failed to register hotkey');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[GlobalHotkey] Failed to register hotkey:', err);
|
||||
setHotkeyRegistrationError(err?.message || 'Failed to register hotkey');
|
||||
});
|
||||
} else {
|
||||
setHotkeyRegistrationError(null);
|
||||
bridge.unregisterGlobalHotkey?.().catch((err) => {
|
||||
console.warn('[GlobalHotkey] Failed to unregister hotkey:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [toggleWindowHotkey, notifySettingsChanged]);
|
||||
|
||||
// Persist and sync close to tray setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
// Update main process tray behavior
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.setCloseToTray) {
|
||||
bridge.setCloseToTray(closeToTray).catch((err) => {
|
||||
console.warn('[SystemTray] Failed to set close-to-tray:', err);
|
||||
});
|
||||
}
|
||||
}, [closeToTray, notifySettingsChanged]);
|
||||
|
||||
// Get merged key bindings (defaults + custom overrides)
|
||||
const keyBindings = useMemo((): KeyBinding[] => {
|
||||
return DEFAULT_KEY_BINDINGS.map(binding => {
|
||||
@@ -630,9 +822,14 @@ export const useSettingsState = () => {
|
||||
localStorageAdapter.write(STORAGE_KEY_SYNC, config);
|
||||
}, []);
|
||||
|
||||
// Subscribe to custom theme changes so editing in-place triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
const currentTerminalTheme = useMemo(
|
||||
() => TERMINAL_THEMES.find(t => t.id === terminalThemeId) || TERMINAL_THEMES[0],
|
||||
[terminalThemeId]
|
||||
() => TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|
||||
|| customThemes.find(t => t.id === terminalThemeId)
|
||||
|| TERMINAL_THEMES[0],
|
||||
[terminalThemeId, customThemes]
|
||||
);
|
||||
|
||||
const currentTerminalFont = useMemo(
|
||||
@@ -645,11 +842,12 @@ export const useSettingsState = () => {
|
||||
value: TerminalSettings[K]
|
||||
) => {
|
||||
setTerminalSettings(prev => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
}, [setTerminalSettings]);
|
||||
|
||||
return {
|
||||
theme,
|
||||
setTheme,
|
||||
resolvedTheme,
|
||||
lightUiThemeId,
|
||||
setLightUiThemeId,
|
||||
darkUiThemeId,
|
||||
@@ -694,6 +892,13 @@ export const useSettingsState = () => {
|
||||
setSftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
setSftpUseCompressedUpload,
|
||||
// Editor Settings
|
||||
editorWordWrap,
|
||||
setEditorWordWrap: useCallback((enabled: boolean) => {
|
||||
setEditorWordWrapState(enabled);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(enabled));
|
||||
notifySettingsChanged(STORAGE_KEY_EDITOR_WORD_WRAP, enabled);
|
||||
}, [notifySettingsChanged]),
|
||||
availableFonts,
|
||||
// Session Logs
|
||||
sessionLogsEnabled,
|
||||
@@ -702,5 +907,11 @@ export const useSettingsState = () => {
|
||||
setSessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
setSessionLogsFormat,
|
||||
// Global Toggle Window (Quake Mode)
|
||||
toggleWindowHotkey,
|
||||
setToggleWindowHotkey,
|
||||
closeToTray,
|
||||
setCloseToTray,
|
||||
hotkeyRegistrationError,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -36,7 +36,15 @@ export const useSftpState = (
|
||||
identities: Identity[],
|
||||
options?: SftpStateOptions
|
||||
) => {
|
||||
const tabsState = useSftpTabsState();
|
||||
const createPane = useCallback(
|
||||
(id?: string, showHiddenFiles = options?.defaultShowHiddenFiles ?? false) =>
|
||||
createEmptyPane(id, showHiddenFiles),
|
||||
[options?.defaultShowHiddenFiles],
|
||||
);
|
||||
|
||||
const tabsState = useSftpTabsState({
|
||||
defaultShowHiddenFiles: options?.defaultShowHiddenFiles,
|
||||
});
|
||||
const {
|
||||
leftTabs,
|
||||
rightTabs,
|
||||
@@ -49,6 +57,7 @@ export const useSftpState = (
|
||||
getActivePane,
|
||||
updateTab,
|
||||
updateActiveTab,
|
||||
setTabShowHiddenFiles,
|
||||
addTab,
|
||||
closeTab,
|
||||
selectTab,
|
||||
@@ -92,12 +101,26 @@ export const useSftpState = (
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearDirCacheEntry = useCallback((connectionId: string, path: string) => {
|
||||
// Remove all encoding variants of this path from the cache
|
||||
for (const key of dirCacheRef.current.keys()) {
|
||||
if (key.startsWith(`${connectionId}::`) && key.endsWith(`::${path}`)) {
|
||||
dirCacheRef.current.delete(key);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Ref to track pending reconnections to avoid multiple reconnect attempts
|
||||
const reconnectingRef = useRef<{ left: boolean; right: boolean }>({
|
||||
left: false,
|
||||
right: false,
|
||||
});
|
||||
|
||||
// Map connectionId → cache key, set at connect time so each tab's
|
||||
// navigateTo can use the correct cache key even when multiple tabs
|
||||
// share the same hostId with different session-time overrides.
|
||||
const connectionCacheKeyMapRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
// Store last connected host info for reconnection
|
||||
const lastConnectedHostRef = useRef<{
|
||||
left: Host | "local" | null;
|
||||
@@ -140,10 +163,12 @@ export const useSftpState = (
|
||||
dirCacheRef,
|
||||
sftpSessionsRef,
|
||||
lastConnectedHostRef,
|
||||
connectionCacheKeyMapRef,
|
||||
reconnectingRef,
|
||||
makeCacheKey,
|
||||
clearCacheForConnection,
|
||||
createEmptyPane,
|
||||
createEmptyPane: createPane,
|
||||
autoConnectLocalOnMount: options?.autoConnectLocalOnMount,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -160,9 +185,11 @@ export const useSftpState = (
|
||||
createDirectory,
|
||||
createFile,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
renameFile,
|
||||
changePermissions,
|
||||
} = useSftpPaneActions({
|
||||
hosts,
|
||||
getActivePane,
|
||||
updateTab,
|
||||
updateActiveTab,
|
||||
@@ -172,6 +199,7 @@ export const useSftpState = (
|
||||
dirCacheRef,
|
||||
sftpSessionsRef,
|
||||
lastConnectedHostRef,
|
||||
connectionCacheKeyMapRef,
|
||||
reconnectingRef,
|
||||
makeCacheKey,
|
||||
clearCacheForConnection,
|
||||
@@ -204,6 +232,13 @@ export const useSftpState = (
|
||||
[clearCacheForConnection, getActivePane, navigateTo, updateActiveTab],
|
||||
);
|
||||
|
||||
const setShowHiddenFiles = useCallback(
|
||||
(side: "left" | "right", tabId: string, showHiddenFiles: boolean) => {
|
||||
setTabShowHiddenFiles(side, tabId, showHiddenFiles);
|
||||
},
|
||||
[setTabShowHiddenFiles],
|
||||
);
|
||||
|
||||
const {
|
||||
transfers,
|
||||
conflicts,
|
||||
@@ -212,6 +247,7 @@ export const useSftpState = (
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
cancelTransfer,
|
||||
isTransferCancelled,
|
||||
retryTransfer,
|
||||
clearCompletedTransfers,
|
||||
dismissTransfer,
|
||||
@@ -231,14 +267,20 @@ export const useSftpState = (
|
||||
writeTextFile,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalEntries,
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
activeFileWatchCountRef,
|
||||
} = useSftpExternalOperations({
|
||||
getActivePane,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
connectionCacheKeyMapRef,
|
||||
clearDirCacheEntry,
|
||||
useCompressedUpload: options?.useCompressedUpload,
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
isTransferCancelled,
|
||||
dismissExternalUpload: dismissTransfer,
|
||||
});
|
||||
|
||||
@@ -266,9 +308,11 @@ export const useSftpState = (
|
||||
selectAll,
|
||||
setFilter,
|
||||
setFilenameEncoding,
|
||||
setShowHiddenFiles,
|
||||
createDirectory,
|
||||
createFile,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
renameFile,
|
||||
changePermissions,
|
||||
readTextFile,
|
||||
@@ -276,6 +320,7 @@ export const useSftpState = (
|
||||
writeTextFile,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalEntries,
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
startTransfer,
|
||||
@@ -310,9 +355,11 @@ export const useSftpState = (
|
||||
selectAll,
|
||||
setFilter,
|
||||
setFilenameEncoding,
|
||||
setShowHiddenFiles,
|
||||
createDirectory,
|
||||
createFile,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
renameFile,
|
||||
changePermissions,
|
||||
readTextFile,
|
||||
@@ -320,6 +367,7 @@ export const useSftpState = (
|
||||
writeTextFile,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
uploadExternalEntries,
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
startTransfer,
|
||||
@@ -358,9 +406,13 @@ export const useSftpState = (
|
||||
setFilter: (...args: Parameters<typeof setFilter>) => methodsRef.current.setFilter(...args),
|
||||
setFilenameEncoding: (...args: Parameters<typeof setFilenameEncoding>) =>
|
||||
methodsRef.current.setFilenameEncoding(...args),
|
||||
setShowHiddenFiles: (...args: Parameters<typeof setShowHiddenFiles>) =>
|
||||
methodsRef.current.setShowHiddenFiles(...args),
|
||||
createDirectory: (...args: Parameters<typeof createDirectory>) => methodsRef.current.createDirectory(...args),
|
||||
createFile: (...args: Parameters<typeof createFile>) => methodsRef.current.createFile(...args),
|
||||
deleteFiles: (...args: Parameters<typeof deleteFiles>) => methodsRef.current.deleteFiles(...args),
|
||||
deleteFilesAtPath: (...args: Parameters<typeof deleteFilesAtPath>) =>
|
||||
methodsRef.current.deleteFilesAtPath(...args),
|
||||
renameFile: (...args: Parameters<typeof renameFile>) => methodsRef.current.renameFile(...args),
|
||||
changePermissions: (...args: Parameters<typeof changePermissions>) => methodsRef.current.changePermissions(...args),
|
||||
readTextFile: (...args: Parameters<typeof readTextFile>) => methodsRef.current.readTextFile(...args),
|
||||
@@ -368,6 +420,8 @@ export const useSftpState = (
|
||||
writeTextFile: (...args: Parameters<typeof writeTextFile>) => methodsRef.current.writeTextFile(...args),
|
||||
downloadToTempAndOpen: (...args: Parameters<typeof downloadToTempAndOpen>) => methodsRef.current.downloadToTempAndOpen(...args),
|
||||
uploadExternalFiles: (...args: Parameters<typeof uploadExternalFiles>) => methodsRef.current.uploadExternalFiles(...args),
|
||||
uploadExternalEntries: (...args: Parameters<typeof uploadExternalEntries>) =>
|
||||
methodsRef.current.uploadExternalEntries(...args),
|
||||
cancelExternalUpload: () => methodsRef.current.cancelExternalUpload(),
|
||||
selectApplication: () => methodsRef.current.selectApplication(),
|
||||
startTransfer: (...args: Parameters<typeof startTransfer>) => methodsRef.current.startTransfer(...args),
|
||||
@@ -379,7 +433,8 @@ export const useSftpState = (
|
||||
dismissTransfer: (...args: Parameters<typeof dismissTransfer>) => methodsRef.current.dismissTransfer(...args),
|
||||
resolveConflict: (...args: Parameters<typeof resolveConflict>) => methodsRef.current.resolveConflict(...args),
|
||||
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
|
||||
}), []); // Empty deps - these wrappers never change
|
||||
activeFileWatchCountRef,
|
||||
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref
|
||||
|
||||
// Return object with stable method references but reactive state
|
||||
// State changes will cause re-renders, but method references stay stable
|
||||
|
||||
24
application/state/useStoredBoolean.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
/**
|
||||
* Hook for persisting a boolean value to localStorage.
|
||||
* @param storageKey - The key to use for localStorage
|
||||
* @param fallback - The default value if no stored value exists (defaults to false)
|
||||
* @returns A tuple of [value, setValue] similar to useState
|
||||
*/
|
||||
export const useStoredBoolean = (
|
||||
storageKey: string,
|
||||
fallback: boolean = false,
|
||||
) => {
|
||||
const [value, setValue] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(storageKey);
|
||||
return stored ?? fallback;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeBoolean(storageKey, value);
|
||||
}, [storageKey, value]);
|
||||
|
||||
return [value, setValue] as const;
|
||||
};
|
||||
28
application/state/useStoredString.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
/**
|
||||
* Hook for persisting a string value to localStorage.
|
||||
* @param storageKey - The key to use for localStorage
|
||||
* @param fallback - The default value if no stored value exists
|
||||
* @param validate - Optional function to validate stored value; returns fallback if invalid
|
||||
* @returns A tuple of [value, setValue] similar to useState
|
||||
*/
|
||||
export const useStoredString = <T extends string = string>(
|
||||
storageKey: string,
|
||||
fallback: T,
|
||||
validate?: (value: string) => value is T,
|
||||
) => {
|
||||
const [value, setValue] = useState<T>(() => {
|
||||
const stored = localStorageAdapter.readString(storageKey);
|
||||
if (stored === null) return fallback;
|
||||
if (validate) return validate(stored) ? stored : fallback;
|
||||
return stored as T;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(storageKey, value);
|
||||
}, [storageKey, value]);
|
||||
|
||||
return [value, setValue] as const;
|
||||
};
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { loadFromGist, syncToGist } from "../../infrastructure/services/syncService";
|
||||
|
||||
export type SyncStatus = "idle" | "success" | "error";
|
||||
|
||||
export const useSyncState = () => {
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [syncStatus, setSyncStatus] = useState<SyncStatus>("idle");
|
||||
|
||||
const resetSyncStatus = useCallback(() => {
|
||||
setSyncStatus("idle");
|
||||
}, []);
|
||||
|
||||
const verify = useCallback(async (token: string, gistId?: string) => {
|
||||
setIsSyncing(true);
|
||||
setSyncStatus("idle");
|
||||
try {
|
||||
if (gistId) {
|
||||
await loadFromGist(token, gistId);
|
||||
}
|
||||
setSyncStatus("success");
|
||||
} catch (err) {
|
||||
setSyncStatus("error");
|
||||
throw err;
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const upload = useCallback(
|
||||
async (
|
||||
token: string,
|
||||
gistId: string | undefined,
|
||||
data: Parameters<typeof syncToGist>[2],
|
||||
) => {
|
||||
setIsSyncing(true);
|
||||
setSyncStatus("idle");
|
||||
try {
|
||||
const newGistId = await syncToGist(token, gistId, data);
|
||||
setSyncStatus("success");
|
||||
return newGistId;
|
||||
} catch (err) {
|
||||
setSyncStatus("error");
|
||||
throw err;
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const download = useCallback(async (token: string, gistId: string) => {
|
||||
setIsSyncing(true);
|
||||
setSyncStatus("idle");
|
||||
try {
|
||||
const data = await loadFromGist(token, gistId);
|
||||
setSyncStatus("success");
|
||||
return data;
|
||||
} catch (err) {
|
||||
setSyncStatus("error");
|
||||
throw err;
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { isSyncing, syncStatus, resetSyncStatus, verify, upload, download };
|
||||
};
|
||||
@@ -78,6 +78,12 @@ export const useTerminalBackend = () => {
|
||||
bridge?.closeSession?.(sessionId);
|
||||
}, []);
|
||||
|
||||
const setSessionEncoding = useCallback(async (sessionId: string, encoding: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.setSessionEncoding) return { ok: false, encoding };
|
||||
return bridge.setSessionEncoding(sessionId, encoding);
|
||||
}, []);
|
||||
|
||||
const onSessionData = useCallback((sessionId: string, cb: (data: string) => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onSessionData) throw new Error("onSessionData unavailable");
|
||||
@@ -148,6 +154,7 @@ export const useTerminalBackend = () => {
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
closeSession,
|
||||
setSessionEncoding,
|
||||
onSessionData,
|
||||
onSessionExit,
|
||||
onChainProgress,
|
||||
|
||||
72
application/state/useTrayPanelBackend.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useCallback } from "react";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
|
||||
export const useTrayPanelBackend = () => {
|
||||
const hideTrayPanel = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.hideTrayPanel?.();
|
||||
}, []);
|
||||
|
||||
const openMainWindow = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.openMainWindow?.();
|
||||
}, []);
|
||||
|
||||
const quitApp = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.quitApp?.();
|
||||
}, []);
|
||||
|
||||
const jumpToSession = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.jumpToSessionFromTrayPanel?.(sessionId);
|
||||
}, []);
|
||||
|
||||
const connectToHostFromTrayPanel = useCallback(async (hostId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.connectToHostFromTrayPanel?.(hostId);
|
||||
}, []);
|
||||
|
||||
const onTrayPanelCloseRequest = useCallback((callback: () => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onTrayPanelCloseRequest?.(callback);
|
||||
}, []);
|
||||
|
||||
const onTrayPanelRefresh = useCallback((callback: () => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onTrayPanelRefresh?.(callback);
|
||||
}, []);
|
||||
|
||||
const onTrayPanelMenuData = useCallback(
|
||||
(
|
||||
callback: (data: {
|
||||
sessions?: Array<{ id: string; label: string; hostLabel: string; status: "connecting" | "connected" | "disconnected"; workspaceId?: string; workspaceTitle?: string }>;
|
||||
portForwardRules?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
type: "local" | "remote" | "dynamic";
|
||||
localPort: number;
|
||||
remoteHost?: string;
|
||||
remotePort?: number;
|
||||
status: "inactive" | "connecting" | "active" | "error";
|
||||
hostId?: string;
|
||||
}>;
|
||||
}) => void,
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onTrayPanelMenuData?.(callback);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
hideTrayPanel,
|
||||
openMainWindow,
|
||||
quitApp,
|
||||
jumpToSession,
|
||||
connectToHostFromTrayPanel,
|
||||
onTrayPanelCloseRequest,
|
||||
onTrayPanelRefresh,
|
||||
onTrayPanelMenuData,
|
||||
};
|
||||
};
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { checkForUpdates, getReleaseUrl, type ReleaseInfo, type UpdateCheckResult } from '../../infrastructure/services/updateService';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK } from '../../infrastructure/config/storageKeys';
|
||||
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE } from '../../infrastructure/config/storageKeys';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
// Check for updates at most once per hour
|
||||
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
||||
// Delay startup check to avoid slowing down app launch
|
||||
const STARTUP_CHECK_DELAY_MS = 5000;
|
||||
// Delay startup check to avoid slowing down app launch.
|
||||
// 8s gives electron-updater's startAutoCheck(5000) time to emit
|
||||
// 'update-available' first. The `onUpdateAvailable` handler also cancels
|
||||
// any pending startup timeout, so even on slow networks where the event
|
||||
// arrives after 8s the duplicate check is avoided.
|
||||
const STARTUP_CHECK_DELAY_MS = 8000;
|
||||
// Enable demo mode for development (set via localStorage: localStorage.setItem('debug.updateDemo', '1'))
|
||||
const IS_UPDATE_DEMO_MODE = typeof window !== 'undefined' &&
|
||||
window.localStorage?.getItem('debug.updateDemo') === '1';
|
||||
@@ -19,6 +23,10 @@ const debugLog = (...args: unknown[]) => {
|
||||
}
|
||||
};
|
||||
|
||||
export type AutoDownloadStatus = 'idle' | 'downloading' | 'ready' | 'error';
|
||||
|
||||
export type ManualCheckStatus = 'idle' | 'checking' | 'available' | 'up-to-date' | 'error';
|
||||
|
||||
export interface UpdateState {
|
||||
isChecking: boolean;
|
||||
hasUpdate: boolean;
|
||||
@@ -26,6 +34,12 @@ export interface UpdateState {
|
||||
latestRelease: ReleaseInfo | null;
|
||||
error: string | null;
|
||||
lastCheckedAt: number | null;
|
||||
// Auto-download state — driven by electron-updater IPC events
|
||||
autoDownloadStatus: AutoDownloadStatus;
|
||||
downloadPercent: number;
|
||||
downloadError: string | null;
|
||||
/** Manual check state — driven by user clicking "Check for Updates" */
|
||||
manualCheckStatus: ManualCheckStatus;
|
||||
}
|
||||
|
||||
export interface UseUpdateCheckResult {
|
||||
@@ -33,6 +47,7 @@ export interface UseUpdateCheckResult {
|
||||
checkNow: () => Promise<UpdateCheckResult | null>;
|
||||
dismissUpdate: () => void;
|
||||
openReleasePage: () => void;
|
||||
installUpdate: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,11 +64,44 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
latestRelease: null,
|
||||
error: null,
|
||||
lastCheckedAt: null,
|
||||
autoDownloadStatus: 'idle',
|
||||
downloadPercent: 0,
|
||||
downloadError: null,
|
||||
manualCheckStatus: 'idle',
|
||||
});
|
||||
|
||||
const hasCheckedOnStartupRef = useRef(false);
|
||||
const isCheckingRef = useRef(false);
|
||||
const startupCheckTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Track current version in a ref to avoid stale closure in checkNow
|
||||
const currentVersionRef = useRef(updateState.currentVersion);
|
||||
// Track autoDownloadStatus in a ref so checkNow always reads the latest value
|
||||
const autoDownloadStatusRef = useRef<AutoDownloadStatus>('idle');
|
||||
// Timer ref for auto-resetting manualCheckStatus='up-to-date' back to 'idle'
|
||||
const manualCheckResetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Flag: true when we suppressed auto-download because the version was dismissed.
|
||||
// Used to distinguish "idle because dismissed" from "idle because not hydrated yet"
|
||||
// in the progress/downloaded/error callbacks.
|
||||
const dismissedAutoDownloadRef = useRef(false);
|
||||
|
||||
// Keep currentVersionRef in sync so checkNow always reads the latest version
|
||||
useEffect(() => {
|
||||
currentVersionRef.current = updateState.currentVersion;
|
||||
}, [updateState.currentVersion]);
|
||||
|
||||
// Keep autoDownloadStatusRef in sync so checkNow always reads the latest download state
|
||||
useEffect(() => {
|
||||
autoDownloadStatusRef.current = updateState.autoDownloadStatus;
|
||||
}, [updateState.autoDownloadStatus]);
|
||||
|
||||
// Cleanup: clear any pending manualCheckStatus reset timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (manualCheckResetTimeoutRef.current) {
|
||||
clearTimeout(manualCheckResetTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get current app version
|
||||
useEffect(() => {
|
||||
@@ -71,6 +119,136 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
void loadVersion();
|
||||
}, []);
|
||||
|
||||
// Hydrate auto-download status from the main process so windows opened
|
||||
// after the download started (e.g. Settings) immediately reflect the
|
||||
// current state instead of showing stale 'idle'.
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
void bridge?.getUpdateStatus?.().then((snapshot) => {
|
||||
if (!snapshot || snapshot.status === 'idle') return;
|
||||
|
||||
// Respect dismissed versions: if the user dismissed this release,
|
||||
// don't surface download progress/ready state in late-opening windows.
|
||||
// Also set the dismissed ref so subsequent IPC events are suppressed.
|
||||
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
|
||||
if (snapshot.version && snapshot.version === dismissedVersion) {
|
||||
dismissedAutoDownloadRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
setUpdateState((prev) => {
|
||||
// Don't overwrite if the renderer already has a newer state
|
||||
if (prev.autoDownloadStatus !== 'idle') return prev;
|
||||
return {
|
||||
...prev,
|
||||
autoDownloadStatus: snapshot.status,
|
||||
downloadPercent: snapshot.percent,
|
||||
downloadError: snapshot.error,
|
||||
// Use snapshot version if no release data or if versions differ
|
||||
latestRelease: (!prev.latestRelease || (snapshot.version && prev.latestRelease.version !== snapshot.version)) ? (snapshot.version ? {
|
||||
version: snapshot.version,
|
||||
tagName: `v${snapshot.version}`,
|
||||
name: `v${snapshot.version}`,
|
||||
body: '',
|
||||
htmlUrl: '',
|
||||
publishedAt: new Date().toISOString(),
|
||||
assets: [],
|
||||
} : prev.latestRelease) : prev.latestRelease,
|
||||
};
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Subscribe to electron-updater auto-download IPC events.
|
||||
// These fire automatically when autoDownload=true in the main process.
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
|
||||
// When electron-updater confirms no update in its feed, don't write
|
||||
// STORAGE_KEY_UPDATE_LAST_CHECK — that would throttle the GitHub API
|
||||
// fallback for an hour. Let performCheck write it on success so the
|
||||
// GitHub check can still discover releases not yet in the updater feed.
|
||||
const cleanupNotAvailable = bridge?.onUpdateNotAvailable?.(() => {
|
||||
// No-op for now — the GitHub fallback will handle lastCheckedAt.
|
||||
});
|
||||
|
||||
const cleanupAvailable = bridge?.onUpdateAvailable?.((info) => {
|
||||
// Cancel any pending startup GitHub API check — electron-updater is
|
||||
// now authoritative and we don't want a duplicate toast.
|
||||
if (startupCheckTimeoutRef.current) {
|
||||
clearTimeout(startupCheckTimeoutRef.current);
|
||||
startupCheckTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Check if this version was dismissed by the user
|
||||
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
|
||||
const isDismissed = dismissedVersion === info.version;
|
||||
if (isDismissed) {
|
||||
dismissedAutoDownloadRef.current = true;
|
||||
}
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
hasUpdate: !isDismissed,
|
||||
// Only transition to 'downloading' if the user hasn't dismissed this
|
||||
// version — otherwise leave the status at 'idle' so no download
|
||||
// progress/ready toast appears for a release they don't want.
|
||||
autoDownloadStatus: isDismissed ? prev.autoDownloadStatus : 'downloading',
|
||||
downloadPercent: isDismissed ? prev.downloadPercent : 0,
|
||||
downloadError: isDismissed ? prev.downloadError : null,
|
||||
// Use electron-updater's version if GitHub API hasn't resolved yet or
|
||||
// if the updater reports a different version than the cached release.
|
||||
latestRelease: (!prev.latestRelease || prev.latestRelease.version !== info.version) ? {
|
||||
version: info.version,
|
||||
tagName: `v${info.version}`,
|
||||
name: `v${info.version}`,
|
||||
body: info.releaseNotes || '',
|
||||
htmlUrl: '',
|
||||
publishedAt: info.releaseDate || new Date().toISOString(),
|
||||
assets: [],
|
||||
} : prev.latestRelease,
|
||||
}));
|
||||
});
|
||||
|
||||
const cleanupProgress = bridge?.onUpdateDownloadProgress?.((p) => {
|
||||
// If we suppressed the download for a dismissed version, ignore progress.
|
||||
if (dismissedAutoDownloadRef.current) return;
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
autoDownloadStatus: 'downloading',
|
||||
downloadPercent: Math.round(p.percent),
|
||||
}));
|
||||
});
|
||||
|
||||
const cleanupDownloaded = bridge?.onUpdateDownloaded?.(() => {
|
||||
// If the download was for a dismissed version, don't transition to
|
||||
// 'ready' — that would trigger the "Update ready" toast.
|
||||
if (dismissedAutoDownloadRef.current) return;
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
autoDownloadStatus: 'ready',
|
||||
downloadPercent: 100,
|
||||
}));
|
||||
});
|
||||
|
||||
const cleanupError = bridge?.onUpdateError?.((payload) => {
|
||||
// If we suppressed the download for a dismissed version, ignore errors.
|
||||
if (dismissedAutoDownloadRef.current) return;
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
autoDownloadStatus: 'error',
|
||||
downloadError: payload.error,
|
||||
}));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cleanupNotAvailable?.();
|
||||
cleanupAvailable?.();
|
||||
cleanupProgress?.();
|
||||
cleanupDownloaded?.();
|
||||
cleanupError?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const performCheck = useCallback(async (currentVersion: string): Promise<UpdateCheckResult | null> => {
|
||||
debugLog('performCheck called', { currentVersion, IS_UPDATE_DEMO_MODE });
|
||||
|
||||
@@ -119,8 +297,16 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
debugLog('Latest release version:', result.latestRelease?.version);
|
||||
const now = Date.now();
|
||||
|
||||
// Save last check time
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_UPDATE_LAST_CHECK, now);
|
||||
// Only advance last-check time and cache release on successful checks.
|
||||
// Failed checks (result.error set, no latestRelease) must not update
|
||||
// the timestamp — otherwise stale cached release data persists for an
|
||||
// hour while the throttle prevents re-checking.
|
||||
if (!result.error) {
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_UPDATE_LAST_CHECK, now);
|
||||
if (result.latestRelease) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UPDATE_LATEST_RELEASE, JSON.stringify(result.latestRelease));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this version was dismissed
|
||||
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
|
||||
@@ -156,11 +342,121 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkNow = useCallback(async () => {
|
||||
// In demo mode, use fake version to allow checking
|
||||
const version = IS_UPDATE_DEMO_MODE ? '0.0.1' : updateState.currentVersion;
|
||||
return performCheck(version);
|
||||
}, [performCheck, updateState.currentVersion]);
|
||||
const checkNow = useCallback(async (): Promise<UpdateCheckResult | null> => {
|
||||
// Prevent concurrent checks (performCheck owns isCheckingRef)
|
||||
if (isCheckingRef.current) {
|
||||
debugLog('checkNow: already checking, skipping');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cancel any pending startup auto-check to avoid racing with
|
||||
// electron-updater's startAutoCheck — concurrent checkForUpdates()
|
||||
// calls are rejected by electron-updater and would surface a false error.
|
||||
if (startupCheckTimeoutRef.current) {
|
||||
clearTimeout(startupCheckTimeoutRef.current);
|
||||
startupCheckTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Clear any pending "up-to-date" auto-reset timer
|
||||
if (manualCheckResetTimeoutRef.current) {
|
||||
clearTimeout(manualCheckResetTimeoutRef.current);
|
||||
manualCheckResetTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Reset dismissed flag so a manual retry can surface download events again
|
||||
dismissedAutoDownloadRef.current = false;
|
||||
|
||||
// Immediately reflect 'checking' in the UI; reset download error so the user can retry
|
||||
setUpdateState((prev) => {
|
||||
// Eagerly sync the ref so the checkForUpdate gate below reads the updated value
|
||||
if (prev.autoDownloadStatus === 'error') {
|
||||
autoDownloadStatusRef.current = 'idle';
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
manualCheckStatus: 'checking',
|
||||
error: null,
|
||||
// P2: reset download error state so auto-download can retry on next available update
|
||||
autoDownloadStatus: prev.autoDownloadStatus === 'error' ? 'idle' : prev.autoDownloadStatus,
|
||||
downloadError: prev.autoDownloadStatus === 'error' ? null : prev.downloadError,
|
||||
};
|
||||
});
|
||||
|
||||
// Skip check for dev/invalid builds (demo mode overrides to '0.0.1' inside performCheck)
|
||||
const effectiveVersion = IS_UPDATE_DEMO_MODE ? '0.0.1' : currentVersionRef.current;
|
||||
if (!effectiveVersion || effectiveVersion === '0.0.0') {
|
||||
// Dev/invalid build — can't determine update status, reset to idle
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
manualCheckStatus: 'idle',
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
|
||||
// Delegate to performCheck (GitHub API) — completely independent of
|
||||
// electron-updater's startAutoCheck() in the main process.
|
||||
// performCheck sets isCheckingRef, isChecking, hasUpdate, latestRelease.
|
||||
const result = await performCheck(effectiveVersion);
|
||||
|
||||
// Determine manual check status. performCheck already suppressed dismissed
|
||||
// versions in state (hasUpdate=false), so we must respect that here too —
|
||||
// otherwise a dismissed release would be reported as 'available' and could
|
||||
// trigger a background download via checkForUpdate below.
|
||||
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
|
||||
const isAvailable = result !== null && !result.error && result.hasUpdate &&
|
||||
result.latestRelease?.version !== dismissedVersion;
|
||||
const nextStatus: ManualCheckStatus =
|
||||
result === null || result.error ? 'error' : isAvailable ? 'available' : 'up-to-date';
|
||||
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
manualCheckStatus: nextStatus,
|
||||
}));
|
||||
|
||||
if (nextStatus === 'up-to-date') {
|
||||
// Auto-reset "up-to-date" badge back to idle after 5s
|
||||
manualCheckResetTimeoutRef.current = setTimeout(() => {
|
||||
setUpdateState((prev) => ({ ...prev, manualCheckStatus: 'idle' }));
|
||||
}, 5000);
|
||||
} else if ((nextStatus === 'available' || nextStatus === 'error') && autoDownloadStatusRef.current === 'idle') {
|
||||
// Trigger electron-updater as a fallback. This covers two cases:
|
||||
// 1. 'available': GitHub found an update but electron-updater hasn't
|
||||
// started a download yet — kick it off.
|
||||
// 2. 'error': GitHub API failed (blocked/rate-limited), but the
|
||||
// electron-updater feed may still be reachable. Without this,
|
||||
// environments where api.github.com is blocked would never attempt
|
||||
// the auto-download path.
|
||||
void netcattyBridge.get()?.checkForUpdate?.().then((res) => {
|
||||
if (res?.error && res?.supported !== false) {
|
||||
// Surface actual download-feed errors; unsupported platforms
|
||||
// (res.supported === false) should keep autoDownloadStatus at
|
||||
// 'idle' so the manual download link shows.
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
autoDownloadStatus: 'error',
|
||||
downloadError: res.error,
|
||||
}));
|
||||
} else if (res?.checking) {
|
||||
// Another check is already in flight — don't change status; the
|
||||
// in-flight check will resolve via IPC events.
|
||||
} else if (nextStatus === 'error' && !res?.error && !res?.available) {
|
||||
// GitHub API failed but electron-updater says no update available.
|
||||
// Clear the error status so Settings doesn't stay stuck in error state.
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
manualCheckStatus: 'up-to-date',
|
||||
}));
|
||||
manualCheckResetTimeoutRef.current = setTimeout(() => {
|
||||
setUpdateState((prev) => ({ ...prev, manualCheckStatus: 'idle' }));
|
||||
}, 5000);
|
||||
}
|
||||
}).catch(() => {
|
||||
// Bridge unavailable — ignore; the manual download link remains visible
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [performCheck]);
|
||||
|
||||
const dismissUpdate = useCallback(() => {
|
||||
if (updateState.latestRelease?.version) {
|
||||
@@ -189,6 +485,10 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}, [updateState.latestRelease]);
|
||||
|
||||
const installUpdate = useCallback(() => {
|
||||
netcattyBridge.get()?.installUpdate?.();
|
||||
}, []);
|
||||
|
||||
// Startup check with delay - runs once on mount
|
||||
useEffect(() => {
|
||||
debugLog('Startup check effect mounted, IS_UPDATE_DEMO_MODE:', IS_UPDATE_DEMO_MODE);
|
||||
@@ -238,13 +538,60 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
const now = Date.now();
|
||||
if (lastCheck && now - lastCheck < UPDATE_CHECK_INTERVAL_MS) {
|
||||
hasCheckedOnStartupRef.current = true;
|
||||
// Hydrate cached release info so late-opening windows show the result
|
||||
const cachedRelease = localStorageAdapter.readString(STORAGE_KEY_UPDATE_LATEST_RELEASE);
|
||||
if (cachedRelease) {
|
||||
try {
|
||||
const release = JSON.parse(cachedRelease) as ReleaseInfo;
|
||||
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
|
||||
const isNewer = updateState.currentVersion.localeCompare(release.version, undefined, { numeric: true, sensitivity: 'base' }) < 0;
|
||||
const showUpdate = isNewer && release.version !== dismissedVersion;
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
latestRelease: prev.latestRelease ?? release,
|
||||
hasUpdate: prev.hasUpdate || showUpdate,
|
||||
lastCheckedAt: lastCheck,
|
||||
}));
|
||||
} catch {
|
||||
// Ignore corrupted cache
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
hasCheckedOnStartupRef.current = true;
|
||||
debugLog('Starting delayed update check for version:', updateState.currentVersion);
|
||||
|
||||
startupCheckTimeoutRef.current = setTimeout(() => {
|
||||
startupCheckTimeoutRef.current = setTimeout(async () => {
|
||||
// If electron-updater's auto-check already started a download, skip the
|
||||
// redundant GitHub API check to avoid duplicate toast notifications.
|
||||
if (autoDownloadStatusRef.current !== 'idle') {
|
||||
debugLog('Skipping startup check — auto-download already active');
|
||||
return;
|
||||
}
|
||||
// If the main process check is still in flight, reschedule the
|
||||
// fallback instead of permanently skipping it — the auto-check may
|
||||
// fail silently (check-phase errors aren't broadcast to the renderer).
|
||||
try {
|
||||
const snapshot = await netcattyBridge.get()?.getUpdateStatus?.();
|
||||
if (snapshot?.isChecking) {
|
||||
debugLog('Main process check still in flight — rescheduling fallback');
|
||||
startupCheckTimeoutRef.current = setTimeout(async () => {
|
||||
if (autoDownloadStatusRef.current !== 'idle') return;
|
||||
// Re-check if the main process check is still running to avoid
|
||||
// duplicate notifications on very slow networks.
|
||||
try {
|
||||
const snap = await netcattyBridge.get()?.getUpdateStatus?.();
|
||||
if (snap?.isChecking || (snap?.status && snap.status !== 'idle')) return;
|
||||
} catch { /* fall through */ }
|
||||
debugLog('=== Rescheduled fallback check triggered ===');
|
||||
void performCheck(updateState.currentVersion);
|
||||
}, 5000);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Bridge unavailable — fall through to GitHub check
|
||||
}
|
||||
debugLog('=== Delayed check triggered ===');
|
||||
void performCheck(updateState.currentVersion);
|
||||
}, STARTUP_CHECK_DELAY_MS);
|
||||
@@ -261,5 +608,6 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
checkNow,
|
||||
dismissUpdate,
|
||||
openReleasePage,
|
||||
installUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
|
||||
import {
|
||||
ConnectionLog,
|
||||
@@ -29,6 +29,14 @@ import {
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
decryptHosts,
|
||||
decryptIdentities,
|
||||
decryptKeys,
|
||||
encryptHosts,
|
||||
encryptIdentities,
|
||||
encryptKeys,
|
||||
} from "../../infrastructure/persistence/secureFieldAdapter";
|
||||
|
||||
type ExportableVaultData = {
|
||||
hosts: Host[];
|
||||
@@ -36,6 +44,7 @@ type ExportableVaultData = {
|
||||
identities?: Identity[];
|
||||
snippets: Snippet[];
|
||||
customGroups: string[];
|
||||
snippetPackages?: string[];
|
||||
knownHosts?: KnownHost[];
|
||||
};
|
||||
|
||||
@@ -99,20 +108,47 @@ export const useVaultState = () => {
|
||||
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
|
||||
const [managedSources, setManagedSources] = useState<ManagedSource[]>([]);
|
||||
|
||||
// Write-version counters prevent out-of-order async writes from overwriting
|
||||
// newer data. Each update bumps the counter; the .then() callback only
|
||||
// persists if its version still matches the latest.
|
||||
const hostsWriteVersion = useRef(0);
|
||||
const keysWriteVersion = useRef(0);
|
||||
const identitiesWriteVersion = useRef(0);
|
||||
|
||||
// Read-sequence counters for cross-window storage events. Each incoming
|
||||
// event bumps the counter; the async decrypt callback only applies state if
|
||||
// its sequence still matches, preventing stale decrypts from overwriting
|
||||
// newer data when multiple events arrive in quick succession.
|
||||
const hostsReadSeq = useRef(0);
|
||||
const keysReadSeq = useRef(0);
|
||||
const identitiesReadSeq = useRef(0);
|
||||
|
||||
const updateHosts = useCallback((data: Host[]) => {
|
||||
const cleaned = data.map(sanitizeHost);
|
||||
setHosts(cleaned);
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, cleaned);
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
encryptHosts(cleaned).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateKeys = useCallback((data: SSHKey[]) => {
|
||||
setKeys(data);
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, data);
|
||||
const ver = ++keysWriteVersion.current;
|
||||
encryptKeys(data).then((enc) => {
|
||||
if (ver === keysWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateIdentities = useCallback((data: Identity[]) => {
|
||||
setIdentities(data);
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, data);
|
||||
const ver = ++identitiesWriteVersion.current;
|
||||
encryptIdentities(data).then((enc) => {
|
||||
if (ver === identitiesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateSnippets = useCallback((data: Snippet[]) => {
|
||||
@@ -271,7 +307,11 @@ export const useVaultState = () => {
|
||||
// Add to hosts using functional update
|
||||
setHosts((prevHosts) => {
|
||||
const updated = [...prevHosts, sanitizeHost(newHost)];
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, updated);
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
encryptHosts(updated).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
|
||||
@@ -279,82 +319,120 @@ export const useVaultState = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
|
||||
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
|
||||
const savedIdentities =
|
||||
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
|
||||
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
|
||||
const savedSnippets =
|
||||
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
|
||||
const savedSnippetPackages = localStorageAdapter.read<string[]>(
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
);
|
||||
const init = async () => {
|
||||
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
|
||||
|
||||
if (savedHosts) {
|
||||
const sanitized = savedHosts.map(sanitizeHost);
|
||||
setHosts(sanitized);
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, sanitized);
|
||||
} else {
|
||||
updateHosts(INITIAL_HOSTS);
|
||||
}
|
||||
if (savedHosts) {
|
||||
// Capture version before the async gap so that any write occurring
|
||||
// during decryption (storage event, user edit) advances the counter
|
||||
// and causes this stale result to be discarded.
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
const decrypted = await decryptHosts(savedHosts);
|
||||
if (ver === hostsWriteVersion.current) {
|
||||
const sanitized = decrypted.map(sanitizeHost);
|
||||
setHosts(sanitized);
|
||||
encryptHosts(sanitized).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
updateHosts(INITIAL_HOSTS);
|
||||
}
|
||||
|
||||
// Migrate old keys to new format with source/category fields
|
||||
if (savedKeysRaw?.length) {
|
||||
const migratedKeys: SSHKey[] = [];
|
||||
const legacyKeys: LegacyKeyRecord[] = [];
|
||||
// Read keys fresh here (not before the hosts await) so we don't apply
|
||||
// a stale snapshot if keys were updated during host decryption.
|
||||
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
|
||||
|
||||
for (const entry of savedKeysRaw) {
|
||||
const record =
|
||||
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
|
||||
if (!record) continue;
|
||||
// Migrate old keys to new format with source/category fields
|
||||
if (savedKeysRaw?.length) {
|
||||
const migratedKeys: SSHKey[] = [];
|
||||
const legacyKeys: LegacyKeyRecord[] = [];
|
||||
|
||||
if (isLegacyUnsupportedKey(record)) {
|
||||
legacyKeys.push(record);
|
||||
continue;
|
||||
for (const entry of savedKeysRaw) {
|
||||
const record =
|
||||
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
|
||||
if (!record) continue;
|
||||
|
||||
if (isLegacyUnsupportedKey(record)) {
|
||||
legacyKeys.push(record);
|
||||
continue;
|
||||
}
|
||||
|
||||
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
|
||||
}
|
||||
|
||||
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
|
||||
// Decrypt sensitive fields (passphrase, privateKey)
|
||||
const keyVer = ++keysWriteVersion.current;
|
||||
const decryptedKeys = await decryptKeys(migratedKeys);
|
||||
if (keyVer === keysWriteVersion.current) {
|
||||
setKeys(decryptedKeys);
|
||||
encryptKeys(decryptedKeys).then((enc) => {
|
||||
if (keyVer === keysWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||||
});
|
||||
}
|
||||
if (legacyKeys.length) {
|
||||
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
|
||||
}
|
||||
}
|
||||
|
||||
setKeys(migratedKeys);
|
||||
// Persist migrated keys
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, migratedKeys);
|
||||
if (legacyKeys.length) {
|
||||
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
|
||||
// Read identities fresh here (not before the hosts/keys awaits) so we
|
||||
// don't apply a stale snapshot if identities were updated during prior decryption.
|
||||
const savedIdentities =
|
||||
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
|
||||
if (savedIdentities) {
|
||||
const idVer = ++identitiesWriteVersion.current;
|
||||
const decryptedIds = await decryptIdentities(savedIdentities);
|
||||
if (idVer === identitiesWriteVersion.current) {
|
||||
setIdentities(decryptedIds);
|
||||
encryptIdentities(decryptedIds).then((enc) => {
|
||||
if (idVer === identitiesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (savedIdentities) setIdentities(savedIdentities);
|
||||
// Read remaining non-encrypted data fresh after all async gaps above
|
||||
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
|
||||
const savedSnippets =
|
||||
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
|
||||
const savedSnippetPackages = localStorageAdapter.read<string[]>(
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
);
|
||||
|
||||
if (savedSnippets) setSnippets(savedSnippets);
|
||||
else updateSnippets(INITIAL_SNIPPETS);
|
||||
if (savedSnippets) setSnippets(savedSnippets);
|
||||
else updateSnippets(INITIAL_SNIPPETS);
|
||||
|
||||
if (savedGroups) setCustomGroups(savedGroups);
|
||||
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
|
||||
if (savedGroups) setCustomGroups(savedGroups);
|
||||
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
|
||||
|
||||
// Load known hosts
|
||||
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
|
||||
STORAGE_KEY_KNOWN_HOSTS,
|
||||
);
|
||||
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
|
||||
// Load known hosts
|
||||
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
|
||||
STORAGE_KEY_KNOWN_HOSTS,
|
||||
);
|
||||
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
|
||||
|
||||
// Load shell history
|
||||
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
);
|
||||
if (savedShellHistory) setShellHistory(savedShellHistory);
|
||||
// Load shell history
|
||||
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
);
|
||||
if (savedShellHistory) setShellHistory(savedShellHistory);
|
||||
|
||||
// Load connection logs
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
|
||||
// Load connection logs
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
|
||||
|
||||
// Load managed sources
|
||||
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
);
|
||||
if (savedManagedSources) setManagedSources(savedManagedSources);
|
||||
// Load managed sources
|
||||
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
);
|
||||
if (savedManagedSources) setManagedSources(savedManagedSources);
|
||||
};
|
||||
|
||||
init();
|
||||
}, [updateHosts, updateSnippets]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -367,7 +445,17 @@ export const useVaultState = () => {
|
||||
|
||||
if (key === STORAGE_KEY_HOSTS) {
|
||||
const next = safeParse<Host[]>(event.newValue) ?? [];
|
||||
setHosts(next.map(sanitizeHost));
|
||||
// Bump write version to invalidate any in-flight encrypt from this
|
||||
// window — the cross-window data is newer and must not be overwritten.
|
||||
++hostsWriteVersion.current;
|
||||
const seq = ++hostsReadSeq.current;
|
||||
const writeAtStart = hostsWriteVersion.current;
|
||||
decryptHosts(next).then((dec) => {
|
||||
// Discard if a newer storage event arrived OR a local write occurred
|
||||
// during the decrypt (writeVersion would have advanced).
|
||||
if (seq === hostsReadSeq.current && writeAtStart === hostsWriteVersion.current)
|
||||
setHosts(dec.map(sanitizeHost));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -380,13 +468,25 @@ export const useVaultState = () => {
|
||||
if (!record || isLegacyUnsupportedKey(record)) continue;
|
||||
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
|
||||
}
|
||||
setKeys(migratedKeys);
|
||||
++keysWriteVersion.current;
|
||||
const seq = ++keysReadSeq.current;
|
||||
const writeAtStart = keysWriteVersion.current;
|
||||
decryptKeys(migratedKeys).then((dec) => {
|
||||
if (seq === keysReadSeq.current && writeAtStart === keysWriteVersion.current)
|
||||
setKeys(dec);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_IDENTITIES) {
|
||||
const next = safeParse<Identity[]>(event.newValue) ?? [];
|
||||
setIdentities(next);
|
||||
++identitiesWriteVersion.current;
|
||||
const seq = ++identitiesReadSeq.current;
|
||||
const writeAtStart = identitiesWriteVersion.current;
|
||||
decryptIdentities(next).then((dec) => {
|
||||
if (seq === identitiesReadSeq.current && writeAtStart === identitiesWriteVersion.current)
|
||||
setIdentities(dec);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -442,7 +542,11 @@ export const useVaultState = () => {
|
||||
const next = prev.map((h) =>
|
||||
h.id === hostId ? { ...h, distro: normalized } : h,
|
||||
);
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, next);
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
encryptHosts(next).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
@@ -454,9 +558,10 @@ export const useVaultState = () => {
|
||||
identities,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
knownHosts,
|
||||
}),
|
||||
[hosts, keys, identities, snippets, customGroups, knownHosts],
|
||||
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts],
|
||||
);
|
||||
|
||||
const importData = useCallback(
|
||||
@@ -466,6 +571,7 @@ export const useVaultState = () => {
|
||||
if (payload.identities) updateIdentities(payload.identities);
|
||||
if (payload.snippets) updateSnippets(payload.snippets);
|
||||
if (payload.customGroups) updateCustomGroups(payload.customGroups);
|
||||
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
|
||||
if (payload.knownHosts) updateKnownHosts(payload.knownHosts);
|
||||
},
|
||||
[
|
||||
@@ -474,6 +580,7 @@ export const useVaultState = () => {
|
||||
updateIdentities,
|
||||
updateSnippets,
|
||||
updateCustomGroups,
|
||||
updateSnippetPackages,
|
||||
updateKnownHosts,
|
||||
],
|
||||
);
|
||||
|
||||
BIN
build/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
build/icons/16x16.png
Normal file
|
After Width: | Height: | Size: 645 B |
BIN
build/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
build/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
build/icons/48x48.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
build/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
build/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
760
components/AIChatSidePanel.tsx
Normal file
@@ -0,0 +1,760 @@
|
||||
/**
|
||||
* AIChatSidePanel - Main AI chat interface side panel
|
||||
*
|
||||
* Zed-style agent panel with agent selector, scoped chat sessions,
|
||||
* message list, input area, and session history drawer.
|
||||
*
|
||||
* Core logic is decomposed into focused hooks:
|
||||
* - useAIChatStreaming: stream processing, abort management, agent sub-flows
|
||||
* - useToolApproval: tool approval workflow, timeouts, resume logic
|
||||
* - useConversationExport: export formats & object URL lifecycle
|
||||
*/
|
||||
|
||||
import {
|
||||
History,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import { useImageUpload } from '../application/state/useImageUpload';
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
AISession,
|
||||
AISessionScope,
|
||||
ChatMessage,
|
||||
DiscoveredAgent,
|
||||
ExternalAgentConfig,
|
||||
ProviderConfig,
|
||||
} from '../infrastructure/ai/types';
|
||||
import { getAgentModelPresets } from '../infrastructure/ai/types';
|
||||
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
|
||||
import { Button } from './ui/button';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import AgentSelector from './ai/AgentSelector';
|
||||
import ChatInput from './ai/ChatInput';
|
||||
import ChatMessageList from './ai/ChatMessageList';
|
||||
import ConversationExport from './ai/ConversationExport';
|
||||
import { useAIChatStreaming, getNetcattyBridge } from './ai/hooks/useAIChatStreaming';
|
||||
import { useToolApproval } from './ai/hooks/useToolApproval';
|
||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Props
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface AIChatSidePanelProps {
|
||||
// Session state (per-scope)
|
||||
sessions: AISession[];
|
||||
activeSessionIdMap: Record<string, string | null>;
|
||||
setActiveSessionId: (scopeKey: string, id: string | null) => void;
|
||||
createSession: (scope: AISessionScope, agentId?: string) => AISession;
|
||||
deleteSession: (sessionId: string, scopeKey?: string) => void;
|
||||
updateSessionTitle: (sessionId: string, title: string) => void;
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateLastMessage: (
|
||||
sessionId: string,
|
||||
updater: (msg: ChatMessage) => ChatMessage,
|
||||
) => void;
|
||||
updateMessageById: (
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
updater: (msg: ChatMessage) => ChatMessage,
|
||||
) => void;
|
||||
// Provider config
|
||||
providers: ProviderConfig[];
|
||||
activeProviderId: string;
|
||||
activeModelId: string;
|
||||
|
||||
// Agent info
|
||||
defaultAgentId: string;
|
||||
externalAgents: ExternalAgentConfig[];
|
||||
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
|
||||
agentModelMap: Record<string, string>;
|
||||
setAgentModel: (agentId: string, modelId: string) => void;
|
||||
|
||||
// Safety
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
setGlobalPermissionMode?: (mode: AIPermissionMode) => void;
|
||||
commandBlocklist?: string[];
|
||||
maxIterations?: number;
|
||||
|
||||
// Context
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeTargetId?: string;
|
||||
scopeHostIds?: string[];
|
||||
scopeLabel?: string;
|
||||
|
||||
// Terminal session context (from parent)
|
||||
terminalSessions?: Array<{
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
hostname: string;
|
||||
label: string;
|
||||
os?: string;
|
||||
username?: string;
|
||||
connected: boolean;
|
||||
}>;
|
||||
|
||||
// Visibility
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
sessions,
|
||||
activeSessionIdMap,
|
||||
setActiveSessionId: setActiveSessionIdForScope,
|
||||
createSession,
|
||||
deleteSession,
|
||||
updateSessionTitle,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
providers,
|
||||
activeProviderId,
|
||||
activeModelId,
|
||||
defaultAgentId,
|
||||
externalAgents,
|
||||
setExternalAgents,
|
||||
agentModelMap,
|
||||
setAgentModel,
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
commandBlocklist,
|
||||
maxIterations = 20,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
scopeLabel,
|
||||
terminalSessions = [],
|
||||
isVisible = true,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
// ── Per-scope state ──
|
||||
// Derive scope key for per-scope isolation
|
||||
const scopeKey = `${scopeType}:${scopeTargetId ?? ''}`;
|
||||
|
||||
// Per-scope input values
|
||||
const [inputValueMap, setInputValueMap] = useState<Record<string, string>>({});
|
||||
const inputValue = inputValueMap[scopeKey] ?? '';
|
||||
const setInputValue = useCallback((val: string) => {
|
||||
setInputValueMap(prev => ({ ...prev, [scopeKey]: val }));
|
||||
}, [scopeKey]);
|
||||
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [currentAgentId, setCurrentAgentId] = useState(defaultAgentId);
|
||||
|
||||
const { images, addImages, removeImage, clearImages } = useImageUpload();
|
||||
const { openSettingsWindow } = useWindowControls();
|
||||
|
||||
// ── Streaming hook ──
|
||||
const {
|
||||
streamingSessionIds,
|
||||
setStreamingForScope,
|
||||
abortControllersRef,
|
||||
processCattyStream,
|
||||
sendToCattyAgent,
|
||||
sendToExternalAgent,
|
||||
reportStreamError,
|
||||
} = useAIChatStreaming({
|
||||
maxIterations,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
});
|
||||
|
||||
// ── Tool approval hook ──
|
||||
const {
|
||||
pendingApprovalContextRef,
|
||||
setPendingApproval,
|
||||
handleApprovalResponse,
|
||||
} = useToolApproval({
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
setStreamingForScope,
|
||||
abortControllersRef,
|
||||
processCattyStream,
|
||||
t,
|
||||
});
|
||||
|
||||
// Per-scope active session ID
|
||||
const activeSessionId = activeSessionIdMap[scopeKey] ?? null;
|
||||
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
|
||||
const setActiveSessionId = useCallback((id: string | null) => {
|
||||
setActiveSessionIdForScope(scopeKey, id);
|
||||
}, [scopeKey, setActiveSessionIdForScope]);
|
||||
|
||||
// Restore agent selector from active session when scope changes
|
||||
useEffect(() => {
|
||||
if (activeSessionId) {
|
||||
const session = sessions.find((s) => s.id === activeSessionId);
|
||||
if (session) {
|
||||
setCurrentAgentId(session.agentId);
|
||||
}
|
||||
}
|
||||
}, [scopeKey, activeSessionId, sessions]);
|
||||
|
||||
// Proactively sync terminal session metadata to main process whenever scope or sessions change
|
||||
useEffect(() => {
|
||||
const bridge = getNetcattyBridge();
|
||||
if (bridge?.aiMcpUpdateSessions && terminalSessions.length > 0) {
|
||||
void bridge.aiMcpUpdateSessions(terminalSessions, activeSessionId ?? undefined);
|
||||
}
|
||||
}, [terminalSessions, scopeKey, activeSessionId]);
|
||||
|
||||
// Sync provider configs to main process so it can decrypt API keys server-side.
|
||||
// Keys stay encrypted in transit; main process decrypts only when making HTTP requests.
|
||||
useEffect(() => {
|
||||
const bridge = getNetcattyBridge();
|
||||
if (bridge?.aiSyncProviders && providers.length > 0) {
|
||||
void bridge.aiSyncProviders(providers);
|
||||
}
|
||||
}, [providers]);
|
||||
|
||||
// Abort all active streams and clean up on unmount
|
||||
useEffect(() => {
|
||||
const controllers = abortControllersRef.current;
|
||||
return () => {
|
||||
controllers.forEach(c => c.abort());
|
||||
controllers.clear();
|
||||
// Clear pending approval (clears timeout too via setPendingApproval)
|
||||
setPendingApproval(null);
|
||||
};
|
||||
}, [abortControllersRef, setPendingApproval]);
|
||||
|
||||
// Agent discovery
|
||||
const {
|
||||
discoveredAgents,
|
||||
isDiscovering,
|
||||
rediscover,
|
||||
enableAgent,
|
||||
} = useAgentDiscovery(externalAgents, setExternalAgents);
|
||||
|
||||
const handleEnableDiscoveredAgent = useCallback(
|
||||
(agent: DiscoveredAgent) => {
|
||||
const config = enableAgent(agent);
|
||||
setExternalAgents?.((prev) => [...prev, config]);
|
||||
},
|
||||
[enableAgent, setExternalAgents],
|
||||
);
|
||||
|
||||
// Active session (scoped)
|
||||
const activeSession = useMemo(
|
||||
() => sessions.find((s) => s.id === activeSessionId) ?? null,
|
||||
[sessions, activeSessionId],
|
||||
);
|
||||
|
||||
const messages = activeSession?.messages ?? [];
|
||||
|
||||
// ── Export hook ──
|
||||
const { handleExport } = useConversationExport(activeSession);
|
||||
|
||||
// Active provider info
|
||||
const activeProvider = useMemo(
|
||||
() => providers.find((p) => p.id === activeProviderId),
|
||||
[providers, activeProviderId],
|
||||
);
|
||||
|
||||
const providerDisplayName = activeProvider?.name ?? '';
|
||||
const modelDisplayName = activeModelId || activeProvider?.defaultModel || '';
|
||||
|
||||
// Agent model presets for the current external agent
|
||||
const currentAgentConfig = useMemo(
|
||||
() => currentAgentId !== 'catty' ? externalAgents.find(a => a.id === currentAgentId) : undefined,
|
||||
[currentAgentId, externalAgents],
|
||||
);
|
||||
const agentModelPresets = useMemo(
|
||||
() => getAgentModelPresets(currentAgentConfig?.command),
|
||||
[currentAgentConfig?.command],
|
||||
);
|
||||
|
||||
// Per-agent model: recall last selection or use first preset as default
|
||||
const selectedAgentModel = useMemo(() => {
|
||||
const stored = agentModelMap[currentAgentId];
|
||||
if (stored && agentModelPresets.some(p => stored === p.id || stored.startsWith(p.id + '/'))) {
|
||||
return stored;
|
||||
}
|
||||
// Default to first preset; for models with thinking levels, use the default level
|
||||
if (agentModelPresets.length > 0) {
|
||||
const first = agentModelPresets[0];
|
||||
if (first.thinkingLevels?.length) {
|
||||
return `${first.id}/${first.thinkingLevels[first.thinkingLevels.length - 1]}`;
|
||||
}
|
||||
return first.id;
|
||||
}
|
||||
return undefined;
|
||||
}, [currentAgentId, agentModelMap, agentModelPresets]);
|
||||
|
||||
const handleAgentModelSelect = useCallback((modelId: string) => {
|
||||
setAgentModel(currentAgentId, modelId);
|
||||
}, [currentAgentId, setAgentModel]);
|
||||
|
||||
// Filtered sessions for history (matching current scope type)
|
||||
const historySessions = useMemo(
|
||||
() =>
|
||||
sessions
|
||||
.filter((s) => s.scope.type === scopeType && s.scope.targetId === scopeTargetId)
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt),
|
||||
[sessions, scopeType, scopeTargetId],
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Handlers
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
const scope: AISessionScope = {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
hostIds: scopeHostIds,
|
||||
};
|
||||
const session = createSession(scope, currentAgentId);
|
||||
setActiveSessionId(session.id);
|
||||
setShowHistory(false);
|
||||
setInputValue('');
|
||||
}, [
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
currentAgentId,
|
||||
createSession,
|
||||
setActiveSessionId,
|
||||
setInputValue,
|
||||
]);
|
||||
|
||||
const handleOpenSettings = useCallback(() => {
|
||||
void openSettingsWindow();
|
||||
}, [openSettingsWindow]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Shared helpers for handleSend sub-flows
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/** Ref to always access latest sessions (avoids stale closure in autoTitleSession). */
|
||||
const sessionsRef = useRef(sessions);
|
||||
sessionsRef.current = sessions;
|
||||
|
||||
/** Refs to avoid re-creating handleSend on every keystroke / image change. */
|
||||
const inputValueRef = useRef(inputValue);
|
||||
inputValueRef.current = inputValue;
|
||||
const imagesRef = useRef(images);
|
||||
imagesRef.current = images;
|
||||
|
||||
/** Auto-title a session from the first user message if untitled. */
|
||||
const autoTitleSession = useCallback((sessionId: string, text: string) => {
|
||||
const s = sessionsRef.current.find(x => x.id === sessionId);
|
||||
if (s && (!s.title || s.title === 'New Chat')) {
|
||||
updateSessionTitle(sessionId, text.length > 50 ? text.slice(0, 50) + '...' : text);
|
||||
}
|
||||
}, [updateSessionTitle]);
|
||||
|
||||
/** Ensure a session exists for the current scope and return its ID. */
|
||||
const ensureSession = useCallback((): string => {
|
||||
if (activeSessionId) return activeSessionId;
|
||||
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
|
||||
const session = createSession(scope, currentAgentId);
|
||||
setActiveSessionId(session.id);
|
||||
return session.id;
|
||||
}, [activeSessionId, scopeType, scopeTargetId, scopeHostIds, currentAgentId, createSession, setActiveSessionId]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Main send handler (thin orchestrator)
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const trimmed = inputValueRef.current.trim();
|
||||
const sendScopeKey = scopeKey;
|
||||
if (!trimmed || isStreaming) return;
|
||||
|
||||
const isExternalAgent = currentAgentId !== 'catty';
|
||||
|
||||
// No provider configured for built-in agent
|
||||
if (!isExternalAgent && !activeProvider) {
|
||||
const errSessionId = ensureSession();
|
||||
addMessageToSession(errSessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
|
||||
addMessageToSession(errSessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
|
||||
setInputValue('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure session exists
|
||||
const sessionId = ensureSession();
|
||||
|
||||
// Capture images before clearing
|
||||
const attachedImages = imagesRef.current.map(img => ({ base64Data: img.base64Data, mediaType: img.mediaType, filename: img.filename }));
|
||||
|
||||
// Add user message
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
...(attachedImages.length > 0 ? { images: attachedImages } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setInputValue('');
|
||||
clearImages();
|
||||
setStreamingForScope(sessionId, true);
|
||||
|
||||
// Create assistant message placeholder with a tracked ID
|
||||
const agentConfig = isExternalAgent ? externalAgents.find(a => a.id === currentAgentId) : undefined;
|
||||
const assistantMsgId = generateId();
|
||||
addMessageToSession(sessionId, {
|
||||
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
|
||||
model: isExternalAgent ? (agentConfig?.name || 'external') : (activeModelId || activeProvider?.defaultModel || ''),
|
||||
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllersRef.current.set(sessionId, abortController);
|
||||
const currentSession = sessionsRef.current.find(s => s.id === sessionId);
|
||||
|
||||
if (isExternalAgent) {
|
||||
if (!agentConfig) {
|
||||
updateMessageById(sessionId, assistantMsgId, msg => ({ ...msg, content: 'External agent not found. Please check settings.', executionStatus: 'failed' }));
|
||||
setStreamingForScope(sessionId, false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachedImages, {
|
||||
terminalSessions,
|
||||
providers,
|
||||
selectedAgentModel,
|
||||
});
|
||||
} catch (err) {
|
||||
reportStreamError(sessionId, abortController.signal, err);
|
||||
}
|
||||
// Clear any lingering statusText when the external agent stream finishes
|
||||
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
|
||||
setStreamingForScope(sessionId, false);
|
||||
abortControllersRef.current.delete(sessionId);
|
||||
autoTitleSession(sessionId, trimmed);
|
||||
} else {
|
||||
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
|
||||
activeProvider,
|
||||
activeModelId,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
terminalSessions,
|
||||
setPendingApproval,
|
||||
autoTitleSession,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
isStreaming, activeProvider, scopeKey, currentAgentId,
|
||||
activeModelId, externalAgents,
|
||||
ensureSession, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
setStreamingForScope, setInputValue, clearImages,
|
||||
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
|
||||
abortControllersRef, terminalSessions, providers, selectedAgentModel,
|
||||
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, setPendingApproval,
|
||||
]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (!activeSessionId) return;
|
||||
const controller = abortControllersRef.current.get(activeSessionId);
|
||||
controller?.abort();
|
||||
abortControllersRef.current.delete(activeSessionId);
|
||||
setStreamingForScope(activeSessionId, false);
|
||||
// Clear statusText on the last message so stale status indicators disappear
|
||||
updateLastMessage(activeSessionId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'completed' : msg.executionStatus,
|
||||
}));
|
||||
// Also clear any pending approval (clears timeout too via setPendingApproval)
|
||||
if (pendingApprovalContextRef.current?.sessionId === activeSessionId) {
|
||||
setPendingApproval(null);
|
||||
}
|
||||
}, [activeSessionId, setStreamingForScope, updateLastMessage, setPendingApproval, abortControllersRef, pendingApprovalContextRef]);
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
(sessionId: string) => {
|
||||
setActiveSessionId(sessionId);
|
||||
// Restore agent selector to match the session's bound agent
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (session) {
|
||||
setCurrentAgentId(session.agentId);
|
||||
}
|
||||
setShowHistory(false);
|
||||
},
|
||||
[setActiveSessionId, sessions],
|
||||
);
|
||||
|
||||
const handleDeleteSession = useCallback(
|
||||
(e: React.MouseEvent, sessionId: string) => {
|
||||
e.stopPropagation();
|
||||
const bridge = getNetcattyBridge();
|
||||
void bridge?.aiAcpCleanup?.(sessionId).catch(() => {});
|
||||
deleteSession(sessionId, scopeKey);
|
||||
// Active session clearing is handled by deleteSession with scopeKey
|
||||
},
|
||||
[deleteSession, scopeKey],
|
||||
);
|
||||
|
||||
const handleAgentChange = useCallback((agentId: string) => {
|
||||
setCurrentAgentId(agentId);
|
||||
// Preserve the current session in history and start a new one with the selected agent
|
||||
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
|
||||
const session = createSession(scope, agentId);
|
||||
setActiveSessionId(session.id);
|
||||
}, [scopeType, scopeTargetId, scopeHostIds, createSession, setActiveSessionId]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Render
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
{/* ── Header ── */}
|
||||
<div className="px-2.5 py-1.5 flex items-center justify-between border-b border-border/50 shrink-0">
|
||||
<AgentSelector
|
||||
currentAgentId={currentAgentId}
|
||||
externalAgents={externalAgents}
|
||||
discoveredAgents={discoveredAgents}
|
||||
isDiscovering={isDiscovering}
|
||||
onSelectAgent={handleAgentChange}
|
||||
onEnableDiscoveredAgent={handleEnableDiscoveredAgent}
|
||||
onRediscover={rediscover}
|
||||
onManageAgents={handleOpenSettings}
|
||||
/>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<ConversationExport
|
||||
session={activeSession}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
title="Session history"
|
||||
>
|
||||
<History size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
|
||||
onClick={handleNewChat}
|
||||
title="New chat"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Main content ── */}
|
||||
{showHistory ? (
|
||||
<SessionHistoryDrawer
|
||||
sessions={historySessions}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelect={handleSelectSession}
|
||||
onDelete={handleDeleteSession}
|
||||
onClose={() => setShowHistory(false)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Chat messages */}
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
onApprove={(messageId) => void handleApprovalResponse(messageId, true, {
|
||||
terminalSessions,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
})}
|
||||
onReject={(messageId) => void handleApprovalResponse(messageId, false, {
|
||||
terminalSessions,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Recent sessions (Zed-style, shown when no messages) */}
|
||||
{messages.length === 0 && historySessions.length > 0 && (
|
||||
<div className="shrink-0 px-4 pb-1">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<span className="text-[11px] text-muted-foreground/30 tracking-wide">{t('ai.chat.recent')}</span>
|
||||
<button
|
||||
onClick={() => setShowHistory(true)}
|
||||
className="text-[11px] text-muted-foreground/30 hover:text-muted-foreground/50 transition-colors cursor-pointer"
|
||||
>
|
||||
{t('ai.chat.viewAll')}
|
||||
</button>
|
||||
</div>
|
||||
{historySessions.slice(0, 3).map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => handleSelectSession(session.id)}
|
||||
className="w-full flex items-baseline justify-between py-1.5 text-left hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-[13px] text-foreground/60 truncate pr-4">
|
||||
{session.title || t('ai.chat.untitled')}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground/25 shrink-0">
|
||||
{formatRelativeTime(new Date(session.updatedAt), t)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<ChatInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
isStreaming={isStreaming}
|
||||
providerName={providerDisplayName}
|
||||
modelName={modelDisplayName}
|
||||
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
|
||||
modelPresets={agentModelPresets}
|
||||
selectedModelId={selectedAgentModel}
|
||||
onModelSelect={handleAgentModelSelect}
|
||||
images={images}
|
||||
onAddImages={addImages}
|
||||
onRemoveImage={removeImage}
|
||||
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
|
||||
permissionMode={globalPermissionMode}
|
||||
onPermissionModeChange={setGlobalPermissionMode}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Session History Drawer
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface SessionHistoryDrawerProps {
|
||||
sessions: AISession[];
|
||||
activeSessionId: string | null;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onDelete: (e: React.MouseEvent, sessionId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="px-4 py-2.5 flex items-center justify-between shrink-0 border-b border-border/30">
|
||||
<span className="text-[13px] font-medium text-foreground/80">{t('ai.chat.allSessions')}</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="px-3">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-[13px] text-muted-foreground/40">
|
||||
{t('ai.chat.noSessions')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((session) => {
|
||||
const isActive = session.id === activeSessionId;
|
||||
const time = new Date(session.updatedAt);
|
||||
const timeStr = formatRelativeTime(time, t);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => onSelect(session.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between py-2.5 border-b border-border/20 text-left transition-colors cursor-pointer group',
|
||||
isActive ? 'text-foreground' : 'text-foreground/70 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="text-[13px] truncate pr-3 flex-1 min-w-0">
|
||||
{session.title || t('ai.chat.untitled')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[12px] text-muted-foreground/50">
|
||||
{timeStr}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => onDelete(e, session.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-all cursor-pointer"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function formatRelativeTime(date: Date, t: (key: string) => string): string {
|
||||
const now = Date.now();
|
||||
const diff = now - date.getTime();
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
const hours = Math.floor(diff / 3_600_000);
|
||||
const days = Math.floor(diff / 86_400_000);
|
||||
|
||||
if (minutes < 1) return t('ai.chat.justNow');
|
||||
if (minutes < 60) return t('ai.chat.minutesAgo').replace('{n}', String(minutes));
|
||||
if (hours < 24) return t('ai.chat.hoursAgo').replace('{n}', String(hours));
|
||||
if (days < 7) return t('ai.chat.daysAgo').replace('{n}', String(days));
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Export
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const AIChatSidePanel = React.memo(AIChatSidePanelInner);
|
||||
AIChatSidePanel.displayName = 'AIChatSidePanel';
|
||||
|
||||
export default AIChatSidePanel;
|
||||
export { AIChatSidePanel };
|
||||
export type { AIChatSidePanelProps };
|
||||
@@ -1,309 +0,0 @@
|
||||
import { ChevronDown, Eye, EyeOff, Key, Lock, User } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, SSHKey } from "../types";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
|
||||
interface AuthDialogProps {
|
||||
host: Host;
|
||||
keys: SSHKey[];
|
||||
onSubmit: (auth: {
|
||||
username: string;
|
||||
authMethod: "password" | "key";
|
||||
password?: string;
|
||||
keyId?: string;
|
||||
saveCredentials: boolean;
|
||||
}) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const AuthDialog: React.FC<AuthDialogProps> = ({
|
||||
host,
|
||||
keys,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [username, setUsername] = useState(host.username || "root");
|
||||
const [authMethod, setAuthMethod] = useState<"password" | "key">("password");
|
||||
const [password, setPassword] = useState("");
|
||||
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [saveCredentials, setSaveCredentials] = useState(true);
|
||||
const [isKeySelectOpen, setIsKeySelectOpen] = useState(false);
|
||||
|
||||
const _selectedKey = keys.find((k) => k.id === selectedKeyId);
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit({
|
||||
username,
|
||||
authMethod,
|
||||
password: authMethod === "password" ? password : undefined,
|
||||
keyId: authMethod === "key" ? (selectedKeyId ?? undefined) : undefined,
|
||||
saveCredentials,
|
||||
});
|
||||
};
|
||||
|
||||
const isValid =
|
||||
username.trim() &&
|
||||
((authMethod === "password" && password.trim()) ||
|
||||
(authMethod === "key" && selectedKeyId));
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="w-[420px] max-w-[90vw] bg-background border border-border/60 rounded-2xl shadow-2xl animate-in fade-in-0 zoom-in-95 duration-200">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-5 border-b border-border/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.label.slice(0, 2).toUpperCase()}
|
||||
className="h-12 w-12"
|
||||
/>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">{host.label}</h2>
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
SSH {host.hostname}:{host.port || 22}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator */}
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center">
|
||||
<User size={14} />
|
||||
</div>
|
||||
<div className="flex-1 h-0.5 bg-muted" />
|
||||
<div
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-full flex items-center justify-center transition-colors",
|
||||
username.trim()
|
||||
? "bg-primary/20 text-primary"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{authMethod === "password" ? (
|
||||
<Lock size={14} />
|
||||
) : (
|
||||
<Key size={14} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 h-0.5 bg-muted" />
|
||||
<div className="h-8 w-8 rounded-full bg-muted text-muted-foreground flex items-center justify-center text-xs font-mono">
|
||||
{">_"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth method tabs */}
|
||||
<div className="px-6">
|
||||
<div className="flex gap-1 p-1 bg-secondary/80 rounded-lg border border-border/60">
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all",
|
||||
authMethod === "password"
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-secondary",
|
||||
)}
|
||||
onClick={() => setAuthMethod("password")}
|
||||
>
|
||||
<Lock size={14} />
|
||||
{t("terminal.auth.password")}
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all",
|
||||
authMethod === "key"
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-secondary",
|
||||
)}
|
||||
onClick={() => setAuthMethod("key")}
|
||||
>
|
||||
<Key size={14} />
|
||||
{t("terminal.auth.sshKey")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
{/* Username field (shown when no username on host) */}
|
||||
{!host.username && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auth-username">{t("terminal.auth.username")}</Label>
|
||||
<Input
|
||||
id="auth-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={t("terminal.auth.username.placeholder")}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password field */}
|
||||
{authMethod === "password" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auth-password">
|
||||
{t("terminal.auth.passwordLabel")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="auth-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("terminal.auth.password.placeholder")}
|
||||
className="pr-10"
|
||||
autoFocus={!!host.username}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && isValid) {
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key selection */}
|
||||
{authMethod === "key" && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("terminal.auth.selectKey")}</Label>
|
||||
{keys.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground p-3 border border-dashed border-border/60 rounded-lg text-center">
|
||||
{t("terminal.auth.noKeysHint")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{keys
|
||||
.filter((k) => k.category === "key")
|
||||
.slice(0, 5)
|
||||
.map((key) => (
|
||||
<button
|
||||
key={key.id}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-colors text-left",
|
||||
selectedKeyId === key.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border/50 hover:bg-secondary/50",
|
||||
)}
|
||||
onClick={() => setSelectedKeyId(key.id)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-lg flex items-center justify-center",
|
||||
"bg-primary/20 text-primary",
|
||||
)}
|
||||
>
|
||||
<Key size={14} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{key.label}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("auth.keyType", { type: key.type })}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{keys.filter((k) => k.category === "key").length > 5 && (
|
||||
<Popover
|
||||
open={isKeySelectOpen}
|
||||
onOpenChange={setIsKeySelectOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full">
|
||||
{t("auth.showAllKeys")}
|
||||
<ChevronDown size={14} className="ml-2" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0">
|
||||
<ScrollArea className="h-64">
|
||||
<div className="p-2 space-y-1">
|
||||
{keys
|
||||
.filter((k) => k.category === "key")
|
||||
.map((key) => (
|
||||
<button
|
||||
key={key.id}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-2 rounded-md text-left transition-colors",
|
||||
selectedKeyId === key.id
|
||||
? "bg-primary/10"
|
||||
: "hover:bg-secondary",
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedKeyId(key.id);
|
||||
setIsKeySelectOpen(false);
|
||||
}}
|
||||
>
|
||||
<Key size={14} className="text-primary" />
|
||||
<span className="text-sm truncate">
|
||||
{key.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{key.type}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-border/50 flex items-center justify-between">
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button disabled={!isValid} onClick={handleSubmit}>
|
||||
{t("terminal.auth.continueSave")}
|
||||
<ChevronDown size={14} className="ml-2" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-40 p-1" align="end">
|
||||
<button
|
||||
className="w-full px-3 py-2 text-sm text-left hover:bg-secondary rounded-md"
|
||||
onClick={() => {
|
||||
setSaveCredentials(false);
|
||||
handleSubmit();
|
||||
}}
|
||||
disabled={!isValid}
|
||||
>
|
||||
{t("common.continue")}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthDialog;
|
||||
@@ -32,7 +32,10 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useCloudSync } from '../application/state/useCloudSync';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import type { CloudProvider, ConflictInfo, SyncPayload, WebDAVAuthType, WebDAVConfig, S3Config } from '../domain/sync';
|
||||
import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type ConflictInfo, type SyncPayload, type WebDAVAuthType, type WebDAVConfig, type S3Config } from '../domain/sync';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
@@ -482,7 +485,7 @@ const GitHubDeviceFlowModal: React.FC<GitHubDeviceFlowModalProps> = ({
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => window.open(verificationUri, '_blank')}
|
||||
onClick={() => window.open(verificationUri, "_blank", "noopener,noreferrer")}
|
||||
className="w-full gap-2 mb-4"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
@@ -678,10 +681,9 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
|
||||
const disconnectOtherProviders = async (current: CloudProvider) => {
|
||||
const providers: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
const isActive = (status: string) => status === 'connected' || status === 'syncing';
|
||||
for (const provider of providers) {
|
||||
if (provider === current) continue;
|
||||
if (isActive(sync.providers[provider].status)) {
|
||||
if (isProviderReadyForSync(sync.providers[provider])) {
|
||||
await sync.disconnectProvider(provider);
|
||||
}
|
||||
}
|
||||
@@ -729,6 +731,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
const [webdavPassword, setWebdavPassword] = useState('');
|
||||
const [webdavToken, setWebdavToken] = useState('');
|
||||
const [showWebdavSecret, setShowWebdavSecret] = useState(false);
|
||||
const [webdavAllowInsecure, setWebdavAllowInsecure] = useState(false);
|
||||
const [webdavError, setWebdavError] = useState<string | null>(null);
|
||||
const [webdavErrorDetail, setWebdavErrorDetail] = useState<string | null>(null);
|
||||
const [isSavingWebdav, setIsSavingWebdav] = useState(false);
|
||||
@@ -751,6 +754,17 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// Clear local data dialog
|
||||
const [showClearLocalDialog, setShowClearLocalDialog] = useState(false);
|
||||
|
||||
const ensureSyncablePayload = useCallback(
|
||||
(payload: SyncPayload): boolean => {
|
||||
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
|
||||
if (encryptedCredentialPaths.length === 0) return true;
|
||||
|
||||
toast.error(t('sync.credentialsUnavailable'), t('sync.toast.errorTitle'));
|
||||
return false;
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// Handle conflict detection
|
||||
useEffect(() => {
|
||||
if (sync.currentConflict) {
|
||||
@@ -840,6 +854,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
setWebdavUsername(config?.username || '');
|
||||
setWebdavPassword(config?.password || '');
|
||||
setWebdavToken(config?.token || '');
|
||||
setWebdavAllowInsecure(config?.allowInsecure || false);
|
||||
setShowWebdavSecret(false);
|
||||
setWebdavError(null);
|
||||
setWebdavErrorDetail(null);
|
||||
@@ -890,6 +905,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
username: webdavAuthType === 'token' ? undefined : webdavUsername.trim(),
|
||||
password: webdavAuthType === 'token' ? undefined : webdavPassword,
|
||||
token: webdavAuthType === 'token' ? webdavToken.trim() : undefined,
|
||||
allowInsecure: webdavAllowInsecure ? true : undefined,
|
||||
};
|
||||
|
||||
setIsSavingWebdav(true);
|
||||
@@ -958,6 +974,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
const handleSync = async (provider: CloudProvider) => {
|
||||
try {
|
||||
const payload = onBuildPayload();
|
||||
if (!ensureSyncablePayload(payload)) return;
|
||||
const result = await sync.syncToProvider(provider, payload);
|
||||
|
||||
if (result.success) {
|
||||
@@ -982,6 +999,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
} else if (resolution === 'USE_LOCAL') {
|
||||
// Re-sync with local data
|
||||
const localPayload = onBuildPayload();
|
||||
if (!ensureSyncablePayload(localPayload)) return;
|
||||
await sync.syncNow(localPayload);
|
||||
toast.success(t('cloudSync.resolve.uploaded'));
|
||||
}
|
||||
@@ -1045,13 +1063,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
provider="github"
|
||||
name="GitHub Gist"
|
||||
icon={<Github size={24} />}
|
||||
isConnected={sync.providers.github.status === 'connected' || sync.providers.github.status === 'syncing'}
|
||||
isConnected={isProviderReadyForSync(sync.providers.github)}
|
||||
isSyncing={sync.providers.github.status === 'syncing'}
|
||||
isConnecting={sync.providers.github.status === 'connecting'}
|
||||
account={sync.providers.github.account}
|
||||
lastSync={sync.providers.github.lastSync}
|
||||
error={sync.providers.github.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.github.status !== 'connected' && sync.providers.github.status !== 'syncing'}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.github)}
|
||||
onConnect={handleConnectGitHub}
|
||||
onDisconnect={() => sync.disconnectProvider('github')}
|
||||
onSync={() => handleSync('github')}
|
||||
@@ -1061,13 +1079,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
provider="google"
|
||||
name="Google Drive"
|
||||
icon={<GoogleDriveIcon className="w-6 h-6" />}
|
||||
isConnected={sync.providers.google.status === 'connected' || sync.providers.google.status === 'syncing'}
|
||||
isConnected={isProviderReadyForSync(sync.providers.google)}
|
||||
isSyncing={sync.providers.google.status === 'syncing'}
|
||||
isConnecting={sync.providers.google.status === 'connecting'}
|
||||
account={sync.providers.google.account}
|
||||
lastSync={sync.providers.google.lastSync}
|
||||
error={sync.providers.google.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.google.status !== 'connected' && sync.providers.google.status !== 'syncing'}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.google)}
|
||||
onConnect={handleConnectGoogle}
|
||||
onDisconnect={() => sync.disconnectProvider('google')}
|
||||
onSync={() => handleSync('google')}
|
||||
@@ -1077,13 +1095,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
provider="onedrive"
|
||||
name="Microsoft OneDrive"
|
||||
icon={<OneDriveIcon className="w-6 h-6" />}
|
||||
isConnected={sync.providers.onedrive.status === 'connected' || sync.providers.onedrive.status === 'syncing'}
|
||||
isConnected={isProviderReadyForSync(sync.providers.onedrive)}
|
||||
isSyncing={sync.providers.onedrive.status === 'syncing'}
|
||||
isConnecting={sync.providers.onedrive.status === 'connecting'}
|
||||
account={sync.providers.onedrive.account}
|
||||
lastSync={sync.providers.onedrive.lastSync}
|
||||
error={sync.providers.onedrive.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.onedrive.status !== 'connected' && sync.providers.onedrive.status !== 'syncing'}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.onedrive)}
|
||||
onConnect={handleConnectOneDrive}
|
||||
onDisconnect={() => sync.disconnectProvider('onedrive')}
|
||||
onSync={() => handleSync('onedrive')}
|
||||
@@ -1093,13 +1111,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
provider="webdav"
|
||||
name={t('cloudSync.provider.webdav')}
|
||||
icon={<Server size={24} />}
|
||||
isConnected={sync.providers.webdav.status === 'connected' || sync.providers.webdav.status === 'syncing'}
|
||||
isConnected={isProviderReadyForSync(sync.providers.webdav)}
|
||||
isSyncing={sync.providers.webdav.status === 'syncing'}
|
||||
isConnecting={sync.providers.webdav.status === 'connecting'}
|
||||
account={sync.providers.webdav.account}
|
||||
lastSync={sync.providers.webdav.lastSync}
|
||||
error={sync.providers.webdav.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.webdav.status !== 'connected' && sync.providers.webdav.status !== 'syncing'}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.webdav)}
|
||||
onEdit={openWebdavDialog}
|
||||
onConnect={openWebdavDialog}
|
||||
onDisconnect={() => sync.disconnectProvider('webdav')}
|
||||
@@ -1110,13 +1128,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
provider="s3"
|
||||
name={t('cloudSync.provider.s3')}
|
||||
icon={<Database size={24} />}
|
||||
isConnected={sync.providers.s3.status === 'connected' || sync.providers.s3.status === 'syncing'}
|
||||
isConnected={isProviderReadyForSync(sync.providers.s3)}
|
||||
isSyncing={sync.providers.s3.status === 'syncing'}
|
||||
isConnecting={sync.providers.s3.status === 'connecting'}
|
||||
account={sync.providers.s3.account}
|
||||
lastSync={sync.providers.s3.lastSync}
|
||||
error={sync.providers.s3.error}
|
||||
disabled={sync.hasAnyConnectedProvider && sync.providers.s3.status !== 'connected' && sync.providers.s3.status !== 'syncing'}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.s3)}
|
||||
onEdit={openS3Dialog}
|
||||
onConnect={openS3Dialog}
|
||||
onDisconnect={() => sync.disconnectProvider('s3')}
|
||||
@@ -1322,6 +1340,16 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
{t('cloudSync.webdav.showSecret')}
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={webdavAllowInsecure}
|
||||
onChange={(e) => setWebdavAllowInsecure(e.target.checked)}
|
||||
className="accent-primary"
|
||||
/>
|
||||
{t('cloudSync.webdav.allowInsecure')}
|
||||
</label>
|
||||
|
||||
{webdavError && (
|
||||
<p className="text-sm text-red-500">{webdavError}</p>
|
||||
)}
|
||||
@@ -1556,6 +1584,16 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
let payloadForReencrypt: SyncPayload | null = null;
|
||||
if (sync.hasAnyConnectedProvider) {
|
||||
const payload = onBuildPayload();
|
||||
if (!ensureSyncablePayload(payload)) {
|
||||
setChangeKeyError(t('sync.credentialsUnavailable'));
|
||||
return;
|
||||
}
|
||||
payloadForReencrypt = payload;
|
||||
}
|
||||
|
||||
setIsChangingKey(true);
|
||||
try {
|
||||
const ok = await sync.changeMasterKey(currentMasterKey, newMasterKey);
|
||||
@@ -1564,9 +1602,8 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (sync.hasAnyConnectedProvider) {
|
||||
const payload = onBuildPayload();
|
||||
await sync.syncNow(payload);
|
||||
if (payloadForReencrypt) {
|
||||
await sync.syncNow(payloadForReencrypt);
|
||||
}
|
||||
|
||||
toast.success(t('cloudSync.changeKey.updatedToast'));
|
||||
|
||||
@@ -17,6 +17,11 @@ export const DISTRO_LOGOS: Record<string, string> = {
|
||||
redhat: "/distro/redhat.svg",
|
||||
oracle: "/distro/oracle.svg",
|
||||
kali: "/distro/kali.svg",
|
||||
almalinux: "/distro/almalinux.svg",
|
||||
// OS-level logos (used by local terminal tab icons)
|
||||
macos: "/distro/macos.svg",
|
||||
windows: "/distro/windows.svg",
|
||||
linux: "/distro/linux.svg",
|
||||
};
|
||||
|
||||
export const DISTRO_COLORS: Record<string, string> = {
|
||||
@@ -32,6 +37,11 @@ export const DISTRO_COLORS: Record<string, string> = {
|
||||
redhat: "bg-[#EE0000]",
|
||||
oracle: "bg-[#C74634]",
|
||||
kali: "bg-[#0F6DB3]",
|
||||
almalinux: "bg-[#173B66]",
|
||||
// OS-level colors
|
||||
macos: "bg-[#333333]",
|
||||
windows: "bg-[#0078D4]",
|
||||
linux: "bg-[#333333]",
|
||||
default: "bg-slate-600",
|
||||
};
|
||||
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { ChevronRight,Folder,FolderOpen,FolderPlus,Plus } from 'lucide-react';
|
||||
import React,{ useMemo } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { cn } from '../lib/utils';
|
||||
import { GroupNode } from '../types';
|
||||
import { Collapsible,CollapsibleContent,CollapsibleTrigger } from './ui/collapsible';
|
||||
import { ContextMenu,ContextMenuContent,ContextMenuItem,ContextMenuTrigger } from './ui/context-menu';
|
||||
|
||||
interface GroupTreeItemProps {
|
||||
node: GroupNode;
|
||||
depth: number;
|
||||
expandedPaths: Set<string>;
|
||||
onToggle: (path: string) => void;
|
||||
onSelectGroup: (path: string) => void;
|
||||
selectedGroup: string | null;
|
||||
onEditGroup: (path: string) => void;
|
||||
onNewHost: (path: string) => void;
|
||||
onNewSubfolder: (path: string) => void;
|
||||
isManagedGroup?: (path: string) => boolean;
|
||||
}
|
||||
|
||||
export const GroupTreeItem: React.FC<GroupTreeItemProps> = ({
|
||||
node,
|
||||
depth,
|
||||
expandedPaths,
|
||||
onToggle,
|
||||
onSelectGroup,
|
||||
selectedGroup,
|
||||
onEditGroup,
|
||||
onNewHost,
|
||||
onNewSubfolder,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
const hasChildren = node.children && Object.keys(node.children).length > 0;
|
||||
const paddingLeft = `${depth * 12 + 12}px`;
|
||||
const isSelected = selectedGroup === node.path;
|
||||
|
||||
const childNodes = useMemo(() => {
|
||||
return node.children
|
||||
? (Object.values(node.children) as unknown as GroupNode[]).sort((a, b) => a.name.localeCompare(b.name))
|
||||
: [];
|
||||
}, [node.children]);
|
||||
|
||||
return (
|
||||
<Collapsible open={isExpanded} onOpenChange={() => onToggle(node.path)}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center py-1.5 pr-2 text-sm font-medium cursor-pointer transition-colors select-none group relative rounded-r-md",
|
||||
isSelected ? "bg-primary/10 text-primary border-l-2 border-primary" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
onClick={() => onSelectGroup(node.path)}
|
||||
>
|
||||
<div className="mr-1.5 flex-shrink-0 w-4 h-4 flex items-center justify-center">
|
||||
{hasChildren && (
|
||||
<div className={cn("transition-transform duration-200", isExpanded ? "rotate-90" : "")}>
|
||||
<ChevronRight size={12} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mr-2 text-primary/80 group-hover:text-primary transition-colors">
|
||||
{isExpanded ? <FolderOpen size={16} /> : <Folder size={16} />}
|
||||
</div>
|
||||
<span className="truncate flex-1">{node.name}</span>
|
||||
{node.hosts.length > 0 && (
|
||||
<span className="text-[10px] opacity-70 bg-background/50 px-1.5 rounded-full border border-border">
|
||||
{node.hosts.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => onNewHost(node.path)}>
|
||||
<Plus className="mr-2 h-4 w-4" /> {t("action.newHost")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onNewSubfolder(node.path)}>
|
||||
<FolderPlus className="mr-2 h-4 w-4" /> {t("action.newSubfolder")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
{hasChildren && (
|
||||
<CollapsibleContent>
|
||||
{childNodes.map((child) => (
|
||||
<GroupTreeItem
|
||||
key={child.path}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggle={onToggle}
|
||||
onSelectGroup={onSelectGroup}
|
||||
selectedGroup={selectedGroup}
|
||||
onEditGroup={onEditGroup}
|
||||
onNewHost={onNewHost}
|
||||
onNewSubfolder={onNewSubfolder}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
)}
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Plus,
|
||||
Settings2,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
Tag,
|
||||
TerminalSquare,
|
||||
User,
|
||||
@@ -26,7 +27,8 @@ import {
|
||||
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 { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
|
||||
import { cn } from "../lib/utils";
|
||||
import { EnvVar, Host, Identity, ManagedSource, ProxyConfig, SSHKey } from "../types";
|
||||
@@ -38,6 +40,7 @@ import {
|
||||
AsidePanelFooter,
|
||||
} from "./ui/aside-panel";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
|
||||
import { Button } from "./ui/button";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { Card } from "./ui/card";
|
||||
@@ -97,6 +100,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
const { terminalThemeId, terminalFontSize } = useSettingsState();
|
||||
const [form, setForm] = useState<Host>(
|
||||
() =>
|
||||
initialData ||
|
||||
@@ -111,7 +115,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
os: "linux",
|
||||
authMethod: "password",
|
||||
charset: "UTF-8",
|
||||
theme: "Flexoki Dark",
|
||||
theme: terminalThemeId,
|
||||
fontSize: terminalFontSize,
|
||||
createdAt: Date.now(),
|
||||
group: defaultGroup || undefined, // Pre-fill with current navigation group
|
||||
} as Host),
|
||||
@@ -154,13 +159,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
// Group input state for inline creation suggestion
|
||||
const [groupInputValue, setGroupInputValue] = useState(form.group || "");
|
||||
|
||||
// Check if the entered group is new (doesn't exist)
|
||||
// Reserved for future use: showing inline "create new group" suggestion
|
||||
const _isNewGroup = useMemo(() => {
|
||||
const trimmed = groupInputValue.trim();
|
||||
return trimmed.length > 0 && !groups.includes(trimmed);
|
||||
}, [groupInputValue, groups]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
// Ensure telnetEnabled is set when protocol is telnet
|
||||
@@ -1115,21 +1113,15 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden"
|
||||
style={{
|
||||
backgroundColor:
|
||||
TERMINAL_THEMES.find(
|
||||
(t) => t.id === (form.theme || "flexoki-dark"),
|
||||
)?.colors.background || "#100F0F",
|
||||
customThemeStore.getThemeById(form.theme || "flexoki-dark")?.colors.background || "#100F0F",
|
||||
color:
|
||||
TERMINAL_THEMES.find(
|
||||
(t) => t.id === (form.theme || "flexoki-dark"),
|
||||
)?.colors.foreground || "#CECDC3",
|
||||
customThemeStore.getThemeById(form.theme || "flexoki-dark")?.colors.foreground || "#CECDC3",
|
||||
}}
|
||||
>
|
||||
<div className="p-0.5">
|
||||
<div
|
||||
style={{
|
||||
color: TERMINAL_THEMES.find(
|
||||
(t) => t.id === (form.theme || "flexoki-dark"),
|
||||
)?.colors.green,
|
||||
color: customThemeStore.getThemeById(form.theme || "flexoki-dark")?.colors.green,
|
||||
}}
|
||||
>
|
||||
$
|
||||
@@ -1137,9 +1129,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm flex-1">
|
||||
{TERMINAL_THEMES.find(
|
||||
(t) => t.id === (form.theme || "flexoki-dark"),
|
||||
)?.name || "Flexoki Dark"}
|
||||
{customThemeStore.getThemeById(form.theme || "flexoki-dark")?.name || "Flexoki Dark"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1230,6 +1220,30 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Legacy Algorithms */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.legacyAlgorithms")}</p>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.legacyAlgorithms")}
|
||||
enabled={!!form.legacyAlgorithms}
|
||||
onToggle={() => update("legacyAlgorithms", !form.legacyAlgorithms)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground break-words">
|
||||
{t("hostDetails.legacyAlgorithms.desc")}
|
||||
</p>
|
||||
{form.legacyAlgorithms && (
|
||||
<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" />
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
|
||||
{t("hostDetails.legacyAlgorithms.warning")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
@@ -1306,36 +1320,50 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</Card>
|
||||
|
||||
{/* Proxy Configuration */}
|
||||
<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">
|
||||
<Globe size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.proxy")}</p>
|
||||
</div>
|
||||
{form.proxyConfig?.host ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{form.proxyConfig.type?.toUpperCase()} {form.proxyConfig.host}:
|
||||
{form.proxyConfig.port}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{t("hostDetails.proxy.none")}
|
||||
</Badge>
|
||||
)}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80 overflow-hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.proxy")}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full h-9 justify-start gap-2 text-sm"
|
||||
onClick={() => setActiveSubPanel("proxy")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{form.proxyConfig?.host
|
||||
? t("hostDetails.proxy.edit")
|
||||
: t("hostDetails.proxy.configure")}
|
||||
</Button>
|
||||
{form.proxyConfig?.host ? (
|
||||
<button
|
||||
className="w-full min-w-0 grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer overflow-hidden"
|
||||
onClick={() => setActiveSubPanel("proxy")}
|
||||
>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{form.proxyConfig.type?.toUpperCase()}
|
||||
</Badge>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
{form.proxyConfig.host}:{form.proxyConfig.port}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-xs break-all">
|
||||
{form.proxyConfig.type?.toUpperCase()} {form.proxyConfig.host}:{form.proxyConfig.port}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<X
|
||||
size={14}
|
||||
className="text-muted-foreground hover:text-destructive flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearProxyConfig();
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full h-9 justify-start gap-2 text-sm"
|
||||
onClick={() => setActiveSubPanel("proxy")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("hostDetails.proxy.configure")}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Environment Variables */}
|
||||
@@ -1466,35 +1494,20 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden"
|
||||
style={{
|
||||
backgroundColor:
|
||||
TERMINAL_THEMES.find(
|
||||
(t) =>
|
||||
t.id ===
|
||||
(form.protocols?.find((p) => p.protocol === "telnet")
|
||||
?.theme ||
|
||||
form.theme ||
|
||||
"flexoki-dark"),
|
||||
customThemeStore.getThemeById(
|
||||
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
|
||||
)?.colors.background || "#100F0F",
|
||||
color:
|
||||
TERMINAL_THEMES.find(
|
||||
(t) =>
|
||||
t.id ===
|
||||
(form.protocols?.find((p) => p.protocol === "telnet")
|
||||
?.theme ||
|
||||
form.theme ||
|
||||
"flexoki-dark"),
|
||||
customThemeStore.getThemeById(
|
||||
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
|
||||
)?.colors.foreground || "#CECDC3",
|
||||
}}
|
||||
>
|
||||
<div className="p-0.5">
|
||||
<div
|
||||
style={{
|
||||
color: TERMINAL_THEMES.find(
|
||||
(t) =>
|
||||
t.id ===
|
||||
(form.protocols?.find((p) => p.protocol === "telnet")
|
||||
?.theme ||
|
||||
form.theme ||
|
||||
"flexoki-dark"),
|
||||
color: customThemeStore.getThemeById(
|
||||
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
|
||||
)?.colors.green,
|
||||
}}
|
||||
>
|
||||
@@ -1503,13 +1516,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm flex-1">
|
||||
{TERMINAL_THEMES.find(
|
||||
(t) =>
|
||||
t.id ===
|
||||
(form.protocols?.find((p) => p.protocol === "telnet")
|
||||
?.theme ||
|
||||
form.theme ||
|
||||
"flexoki-dark"),
|
||||
{customThemeStore.getThemeById(
|
||||
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
|
||||
)?.name || "Flexoki Dark"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
import { Key, Lock, Plus, Save, Server, X } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, SSHKey } from "../types";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Switch } from "./ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "./ui/select";
|
||||
|
||||
interface HostFormProps {
|
||||
initialData?: Host | null;
|
||||
availableKeys: SSHKey[];
|
||||
groups: string[];
|
||||
onSave: (host: Host) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const HostForm: React.FC<HostFormProps> = ({
|
||||
initialData,
|
||||
availableKeys,
|
||||
groups,
|
||||
onSave,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [formData, setFormData] = useState<Partial<Host>>(
|
||||
initialData || {
|
||||
label: "",
|
||||
hostname: "",
|
||||
port: 22,
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
group: "General",
|
||||
identityFileId: "",
|
||||
},
|
||||
);
|
||||
|
||||
const [authType, setAuthType] = useState<"password" | "key">(
|
||||
initialData?.identityFileId ? "key" : "password",
|
||||
);
|
||||
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
|
||||
const handleAddTag = () => {
|
||||
const tag = tagInput.trim();
|
||||
if (tag && !formData.tags?.includes(tag)) {
|
||||
setFormData((prev) => ({ ...prev, tags: [...(prev.tags || []), tag] }));
|
||||
setTagInput("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTag = (tagToRemove: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
tags: (prev.tags || []).filter((t) => t !== tagToRemove),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleTagKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAddTag();
|
||||
}
|
||||
};
|
||||
|
||||
// Effect to ensure we have a valid auth state if switching back and forth
|
||||
useEffect(() => {
|
||||
if (authType === "password") {
|
||||
setFormData((prev) => ({ ...prev, identityFileId: "" }));
|
||||
} else if (
|
||||
authType === "key" &&
|
||||
!formData.identityFileId &&
|
||||
availableKeys.length > 0
|
||||
) {
|
||||
// Default to first key if none selected
|
||||
setFormData((prev) => ({ ...prev, identityFileId: availableKeys[0].id }));
|
||||
}
|
||||
}, [authType, availableKeys, formData.identityFileId]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (formData.label && formData.hostname && formData.username) {
|
||||
onSave({
|
||||
...formData,
|
||||
id: initialData?.id || crypto.randomUUID(),
|
||||
tags: formData.tags || [],
|
||||
port: formData.port || 22,
|
||||
group: formData.group || "General",
|
||||
identityFileId:
|
||||
authType === "key" ? formData.identityFileId : undefined,
|
||||
createdAt: initialData?.createdAt || Date.now(),
|
||||
} as Host);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={() => onCancel()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-primary" />
|
||||
{initialData ? t("hostForm.title.edit") : t("hostForm.title.new")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{initialData ? t("hostForm.desc.edit") : t("hostForm.desc.new")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="label">{t("hostForm.field.label")}</Label>
|
||||
<Input
|
||||
id="label"
|
||||
placeholder={t("hostForm.placeholder.label")}
|
||||
value={formData.label}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, label: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 grid gap-2">
|
||||
<Label htmlFor="hostname">{t("hostForm.field.hostname")}</Label>
|
||||
<Input
|
||||
id="hostname"
|
||||
placeholder={t("hostForm.placeholder.hostname")}
|
||||
value={formData.hostname}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, hostname: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="port">{t("hostForm.field.port")}</Label>
|
||||
<Input
|
||||
id="port"
|
||||
type="number"
|
||||
value={formData.port}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, port: parseInt(e.target.value) })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">{t("hostForm.field.username")}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={formData.username}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, username: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="os">{t("hostForm.field.osType")}</Label>
|
||||
<Select
|
||||
value={formData.os}
|
||||
onValueChange={(val: "linux" | "windows" | "macos") =>
|
||||
setFormData({ ...formData, os: val })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("hostForm.placeholder.selectOs")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="linux">Linux</SelectItem>
|
||||
<SelectItem value="windows">Windows</SelectItem>
|
||||
<SelectItem value="macos">macOS</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="group">{t("hostForm.field.group")}</Label>
|
||||
<Input
|
||||
id="group"
|
||||
placeholder={t("hostForm.placeholder.group")}
|
||||
value={formData.group}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, group: e.target.value })
|
||||
}
|
||||
list="group-suggestions"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<datalist id="group-suggestions">
|
||||
{groups.map((g) => (
|
||||
<option key={g} value={g} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="tags">{t("hostForm.field.tags")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="tags"
|
||||
placeholder={t("hostForm.placeholder.addTag")}
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={handleTagKeyDown}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={handleAddTag}
|
||||
disabled={!tagInput.trim()}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
{formData.tags && formData.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{formData.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="hover:bg-primary/20 rounded-full p-0.5"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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
|
||||
className={cn(
|
||||
"border rounded-md p-3 flex flex-col items-center justify-center gap-2 cursor-pointer transition-all hover:bg-accent/50",
|
||||
authType === "password"
|
||||
? "border-primary bg-primary/5 text-primary ring-1 ring-primary"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
onClick={() => setAuthType("password")}
|
||||
>
|
||||
<Lock size={20} />
|
||||
<span className="text-xs font-medium">{t("hostForm.auth.password")}</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"border rounded-md p-3 flex flex-col items-center justify-center gap-2 cursor-pointer transition-all hover:bg-accent/50",
|
||||
authType === "key"
|
||||
? "border-primary bg-primary/5 text-primary ring-1 ring-primary"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
onClick={() => setAuthType("key")}
|
||||
>
|
||||
<Key size={20} />
|
||||
<span className="text-xs font-medium">{t("hostForm.auth.sshKey")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{authType === "key" && (
|
||||
<div className="animate-in fade-in zoom-in-95 duration-200">
|
||||
<Select
|
||||
value={formData.identityFileId || ""}
|
||||
onValueChange={(val) =>
|
||||
setFormData({ ...formData, identityFileId: val })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("hostForm.auth.selectKey")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableKeys.map((key) => (
|
||||
<SelectItem key={key.id} value={key.id}>
|
||||
{key.label} ({key.type})
|
||||
</SelectItem>
|
||||
))}
|
||||
{availableKeys.length === 0 && (
|
||||
<SelectItem value="none" disabled>
|
||||
{t("hostForm.auth.noKeys")}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{availableKeys.length === 0 && (
|
||||
<p className="text-[10px] text-destructive mt-1">
|
||||
{t("hostForm.auth.noKeysHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Save className="mr-2 h-4 w-4" /> {t("hostForm.saveHost")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default HostForm;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronRight, FileSymlink, Folder, FolderOpen, Monitor, Server, Expand, Minimize2 } from 'lucide-react';
|
||||
import { CheckSquare, ChevronRight, FileSymlink, Folder, FolderOpen, Monitor, Server, Square, Expand, Minimize2 } from 'lucide-react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
|
||||
@@ -32,6 +32,9 @@ interface HostTreeViewProps {
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
selectedHostIds?: Set<string>;
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
@@ -53,6 +56,9 @@ interface TreeNodeProps {
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
selectedHostIds?: Set<string>;
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
}
|
||||
|
||||
const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
@@ -74,6 +80,9 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
toggleHostSelection,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
@@ -215,9 +224,12 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
{/* Hosts in this group */}
|
||||
{sortedHosts.map((host) => (
|
||||
<HostTreeItem
|
||||
@@ -230,6 +242,9 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
@@ -247,6 +262,9 @@ interface HostTreeItemProps {
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
selectedHostIds?: Set<string>;
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
}
|
||||
|
||||
const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
@@ -258,6 +276,9 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
moveHostToGroup: _moveHostToGroup,
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
toggleHostSelection,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const paddingLeft = `${depth * 20 + 12}px`;
|
||||
@@ -270,18 +291,40 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
const displayPort = isTelnet
|
||||
? (host.telnetPort ?? host.port ?? 23)
|
||||
: (host.port ?? 22);
|
||||
const isSelected = isMultiSelectMode && selectedHostIds?.has(host.id);
|
||||
|
||||
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"
|
||||
className={cn(
|
||||
"flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg",
|
||||
isSelected ? "bg-primary/10" : "",
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
draggable
|
||||
draggable={!isMultiSelectMode}
|
||||
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
|
||||
onClick={() => onConnect(safeHost)}
|
||||
onClick={() => {
|
||||
if (isMultiSelectMode && toggleHostSelection) {
|
||||
toggleHostSelection(host.id);
|
||||
} else {
|
||||
onConnect(safeHost);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="mr-2 flex-shrink-0 w-4 h-4" />
|
||||
{isMultiSelectMode && (
|
||||
<div className="mr-2 flex-shrink-0" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleHostSelection?.(host.id);
|
||||
}}>
|
||||
{isSelected ? (
|
||||
<CheckSquare size={18} className="text-primary" />
|
||||
) : (
|
||||
<Square size={18} className="text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isMultiSelectMode && <div className="mr-2 flex-shrink-0 w-4 h-4" />}
|
||||
<div className="mr-3 flex-shrink-0">
|
||||
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="sm" />
|
||||
</div>
|
||||
@@ -351,6 +394,9 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
toggleHostSelection,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -471,6 +517,9 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -486,6 +535,9 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -1,411 +0,0 @@
|
||||
import {
|
||||
Key,
|
||||
LayoutGrid,
|
||||
List as ListIcon,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Shield,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { KeyType } from "../domain/models";
|
||||
import { cn } from "../lib/utils";
|
||||
import { SSHKey } from "../types";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card, CardDescription, CardTitle } from "./ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
|
||||
interface KeyManagerProps {
|
||||
keys: SSHKey[];
|
||||
onSave: (key: SSHKey) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
const KeyManager: React.FC<KeyManagerProps> = ({ keys, onSave, onDelete }) => {
|
||||
const { t } = useI18n();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [panelMode, setPanelMode] = useState<"new" | "edit">("new");
|
||||
const [draftKey, setDraftKey] = useState<Partial<SSHKey>>({
|
||||
id: "",
|
||||
label: "",
|
||||
type: "RSA",
|
||||
privateKey: "",
|
||||
publicKey: "",
|
||||
created: Date.now(),
|
||||
});
|
||||
const [generateMode, setGenerateMode] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||
|
||||
const handleGenerate = () => {
|
||||
// Simulate Key Generation
|
||||
const mockKey =
|
||||
`-----BEGIN ${draftKey.type} PRIVATE KEY-----\n` +
|
||||
`MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC${Math.random().toString(36).substring(7)}\n` +
|
||||
`... (simulated generated content) ...\n` +
|
||||
`-----END ${draftKey.type} PRIVATE KEY-----`;
|
||||
|
||||
setDraftKey({ ...draftKey, privateKey: mockKey });
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!draftKey.label || !draftKey.privateKey) return;
|
||||
|
||||
const payload: SSHKey = {
|
||||
id: draftKey.id || crypto.randomUUID(),
|
||||
label: draftKey.label,
|
||||
type: (draftKey.type as KeyType) || "RSA",
|
||||
privateKey: draftKey.privateKey,
|
||||
publicKey: draftKey.publicKey?.trim() || undefined,
|
||||
created: draftKey.created || Date.now(),
|
||||
source: draftKey.source || (generateMode ? "generated" : "imported"),
|
||||
category: draftKey.category || "key",
|
||||
};
|
||||
onSave(payload);
|
||||
setIsDialogOpen(false);
|
||||
setGenerateMode(false);
|
||||
};
|
||||
|
||||
const openPanelForKey = (key: SSHKey) => {
|
||||
setPanelMode("edit");
|
||||
setDraftKey({ ...key });
|
||||
setIsDialogOpen(true);
|
||||
setGenerateMode(false);
|
||||
};
|
||||
|
||||
const openPanelNew = (isGenerate = false) => {
|
||||
setPanelMode("new");
|
||||
setGenerateMode(isGenerate);
|
||||
setDraftKey({
|
||||
id: "",
|
||||
label: "",
|
||||
type: "RSA",
|
||||
privateKey: isGenerate
|
||||
? "Click generate to create a new key pair..."
|
||||
: "",
|
||||
publicKey: "",
|
||||
created: Date.now(),
|
||||
});
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
onDelete(id);
|
||||
if (draftKey.id === id) {
|
||||
setIsDialogOpen(false);
|
||||
setDraftKey({
|
||||
id: "",
|
||||
label: "",
|
||||
type: "RSA",
|
||||
privateKey: "",
|
||||
publicKey: "",
|
||||
created: Date.now(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const filteredKeys = useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
return keys.filter((k) => {
|
||||
if (!term) return true;
|
||||
return (
|
||||
k.label.toLowerCase().includes(term) ||
|
||||
(k.type || "").toString().toLowerCase().includes(term)
|
||||
);
|
||||
});
|
||||
}, [keys, search]);
|
||||
|
||||
const derivedPublicKey = useMemo(() => {
|
||||
if (draftKey.publicKey) return draftKey.publicKey;
|
||||
if (!draftKey.label) return "Generated By netcatty";
|
||||
return `ssh-${(draftKey.type || "ed25519").toLowerCase()} AAAAC3NzaC1lZDI1NTE5AAAA${(
|
||||
draftKey.label || "netcatty"
|
||||
)
|
||||
.replace(/\s+/g, "")
|
||||
.slice(0, 8)} Generated By netcatty`;
|
||||
}, [draftKey.label, draftKey.type, draftKey.publicKey]);
|
||||
|
||||
return (
|
||||
<div className="px-2.5 py-2.5 lg:px-3 lg:py-3 h-full overflow-y-auto space-y-3.5 relative">
|
||||
<div className="flex flex-wrap items-center gap-3 bg-secondary/60 border border-border/70 rounded-xl px-2 py-1.5 shadow-sm">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 px-3 gap-2"
|
||||
disabled
|
||||
>
|
||||
Key
|
||||
<span className="text-[10px] px-2 rounded-full h-5 min-w-[22px] flex items-center justify-center bg-primary/10 text-primary border border-border/70">
|
||||
{keys.length}
|
||||
</span>
|
||||
</Button>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search keys..."
|
||||
className="h-9 pl-8 w-44 md:w-56"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||||
className="h-9 w-9"
|
||||
onClick={() => setViewMode("grid")}
|
||||
>
|
||||
<LayoutGrid size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={viewMode === "list" ? "secondary" : "ghost"}
|
||||
className="h-9 w-9"
|
||||
onClick={() => setViewMode("list")}
|
||||
>
|
||||
<ListIcon size={16} />
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => openPanelNew(false)}>
|
||||
<Plus size={14} className="mr-2" /> Import
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => openPanelNew(true)}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<h2 className="text-base font-semibold text-muted-foreground">
|
||||
Keys
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{filteredKeys.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
|
||||
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
||||
<Shield size={32} className="opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
Set up your keys
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm">
|
||||
Import or generate SSH keys for secure authentication.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
: "flex flex-col gap-0"
|
||||
}
|
||||
>
|
||||
{filteredKeys.map((key) => (
|
||||
<Card
|
||||
key={key.id}
|
||||
className={cn(
|
||||
"group cursor-pointer soft-card elevate rounded-xl",
|
||||
viewMode === "grid"
|
||||
? "h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
||||
)}
|
||||
onClick={() => openPanelForKey(key)}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div className="h-9 w-9 rounded-md bg-primary/15 text-primary flex items-center justify-center">
|
||||
<Key size={16} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-sm font-semibold truncate">
|
||||
{key.label}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-[11px] font-mono text-muted-foreground truncate">
|
||||
Type {key.type}
|
||||
</CardDescription>
|
||||
<div className="text-[10px] text-muted-foreground/80 font-mono truncate">
|
||||
SHA256:{key.id.substring(0, 16)}...
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openPanelForKey(key);
|
||||
}}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(key.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{panelMode === "new"
|
||||
? t("keychain.keyDialog.newTitle")
|
||||
: t("keychain.keyDialog.editTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{panelMode === "new"
|
||||
? t("keychain.keyDialog.newDesc")
|
||||
: t("keychain.keyDialog.editDesc")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("keychain.field.label")}</Label>
|
||||
<Input
|
||||
value={draftKey.label}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, label: e.target.value })
|
||||
}
|
||||
placeholder={t("keychain.field.labelPlaceholder")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("keychain.field.privateKeyRequired")}</Label>
|
||||
<Textarea
|
||||
value={draftKey.privateKey}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, privateKey: e.target.value })
|
||||
}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
className="min-h-[160px] font-mono text-xs"
|
||||
required
|
||||
/>
|
||||
{generateMode && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleGenerate}
|
||||
>
|
||||
{t("keychain.generate.generate")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("keychain.field.publicKey")}</Label>
|
||||
<Textarea
|
||||
value={derivedPublicKey}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, publicKey: e.target.value })
|
||||
}
|
||||
placeholder="ssh-ed25519 AAAAC3... user@host"
|
||||
className="min-h-[90px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
{t("terminal.auth.certificate")}{" "}
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
|
||||
{t("common.optional")}
|
||||
</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
placeholder={t("keychain.field.certificatePlaceholder")}
|
||||
className="min-h-[80px] text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border border-dashed border-border/80 rounded-xl p-4 text-center space-y-2 bg-background/60">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("keychain.import.dropHint")}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
// mock file import
|
||||
setDraftKey({
|
||||
...draftKey,
|
||||
label: draftKey.label || t("keychain.import.importedKeyLabel"),
|
||||
privateKey:
|
||||
draftKey.privateKey ||
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAC3NzaC1lZDI1NTE5AAAA\n-----END OPENSSH PRIVATE KEY-----",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("keychain.import.importFromFile")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{panelMode === "edit" && draftKey.id && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-destructive mr-auto"
|
||||
onClick={() => handleDelete(draftKey.id!)}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
{panelMode === "new"
|
||||
? t("keychain.import.saveKey")
|
||||
: t("keychain.keyDialog.updateKey")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyManager;
|
||||
@@ -450,11 +450,6 @@ echo $3 >> "$FILE"`);
|
||||
[onDeleteIdentity, panel, closePanel],
|
||||
);
|
||||
|
||||
// Copy to clipboard
|
||||
const _copyToClipboard = useCallback((_text: string) => {
|
||||
navigator.clipboard.writeText(_text);
|
||||
}, []);
|
||||
|
||||
// Get icon for key source
|
||||
const getKeyIcon = (key: SSHKey) => {
|
||||
if (key.certificate) return <BadgeCheck size={16} />;
|
||||
@@ -506,46 +501,6 @@ echo $3 >> "$FILE"`);
|
||||
[],
|
||||
);
|
||||
|
||||
// Handle drag and drop
|
||||
const _handleDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const file = event.dataTransfer.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
if (content) {
|
||||
let detectedType: KeyType = "ED25519";
|
||||
const lc = content.toLowerCase();
|
||||
if (lc.includes("rsa")) detectedType = "RSA";
|
||||
else if (lc.includes("ecdsa") || lc.includes("ec private"))
|
||||
detectedType = "ECDSA";
|
||||
else if (lc.includes("ed25519")) detectedType = "ED25519";
|
||||
|
||||
const label = file.name.replace(/\.(pem|key|pub|ppk)$/i, "");
|
||||
|
||||
setDraftKey((prev) => ({
|
||||
...prev,
|
||||
privateKey: content,
|
||||
label: prev.label || label,
|
||||
type: detectedType,
|
||||
}));
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}, []);
|
||||
|
||||
const _handleDragOver = useCallback(
|
||||
(event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex relative">
|
||||
{/* Hidden file input */}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { cn } from "../lib/utils";
|
||||
import { ConnectionLog, TerminalTheme } from "../types";
|
||||
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
|
||||
import { useCustomThemes } from "../application/state/customThemeStore";
|
||||
import { Button } from "./ui/button";
|
||||
import ThemeCustomizeModal from "./terminal/ThemeCustomizeModal";
|
||||
|
||||
@@ -36,13 +37,18 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
// Use log's saved theme/fontSize or fall back to defaults
|
||||
const currentTheme = useMemo(() => {
|
||||
if (log.themeId) {
|
||||
return TERMINAL_THEMES.find(t => t.id === log.themeId) || defaultTerminalTheme;
|
||||
return TERMINAL_THEMES.find(t => t.id === log.themeId)
|
||||
|| customThemes.find(t => t.id === log.themeId)
|
||||
|| defaultTerminalTheme;
|
||||
}
|
||||
return defaultTerminalTheme;
|
||||
}, [log.themeId, defaultTerminalTheme]);
|
||||
}, [log.themeId, defaultTerminalTheme, customThemes]);
|
||||
|
||||
const currentFontSize = log.fontSize ?? defaultFontSize;
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
const result = await startTunnel(
|
||||
rule,
|
||||
_host,
|
||||
keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
|
||||
keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase })),
|
||||
(status, error) => {
|
||||
// Show toast on error (only once)
|
||||
if (status === "error" && error && !errorShown) {
|
||||
@@ -307,8 +307,6 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
const label =
|
||||
newFormDraft.label?.trim() ||
|
||||
(() => {
|
||||
// Host lookup reserved for future label enhancement (e.g., "Local:8080 → api.example.com:80 via server1")
|
||||
const _host = hosts.find((h) => h.id === newFormDraft.hostId);
|
||||
switch (newFormDraft.type) {
|
||||
case "local":
|
||||
return `Local:${newFormDraft.localPort} → ${newFormDraft.remoteHost}:${newFormDraft.remotePort}`;
|
||||
@@ -546,12 +544,6 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Handle skip wizard (just save with defaults)
|
||||
const _skipWizard = () => {
|
||||
setShowWizard(false);
|
||||
resetWizard();
|
||||
};
|
||||
|
||||
// Render wizard panel content
|
||||
const hasRules = filteredRules.length > 0;
|
||||
|
||||
@@ -701,6 +693,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
<RuleCard
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
host={hosts.find((h) => h.id === rule.hostId)}
|
||||
viewMode={viewMode}
|
||||
isSelected={selectedRuleId === rule.id}
|
||||
isPending={pendingOperations.has(rule.id)}
|
||||
|
||||
@@ -14,7 +14,7 @@ import React, { useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import type { QuickConnectTarget } from "../domain/quickConnect";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, KnownHost, SSHKey } from "../types";
|
||||
import { Host, SSHKey } from "../types";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
@@ -30,7 +30,6 @@ interface QuickConnectWizardProps {
|
||||
open: boolean;
|
||||
target: QuickConnectTarget;
|
||||
keys: SSHKey[];
|
||||
knownHosts: KnownHost[];
|
||||
warnings?: string[];
|
||||
onConnect: (host: Host) => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
@@ -42,7 +41,6 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
open,
|
||||
target,
|
||||
keys,
|
||||
knownHosts,
|
||||
warnings,
|
||||
onConnect,
|
||||
onSaveHost,
|
||||
@@ -69,16 +67,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
const [password, setPassword] = useState("");
|
||||
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [saveCredentials, _setSaveCredentials] = useState(true);
|
||||
|
||||
// Check if host is in known hosts
|
||||
const _existingKnownHost = useMemo(() => {
|
||||
return knownHosts.find(
|
||||
(kh) =>
|
||||
kh.hostname === target.hostname &&
|
||||
(kh.port === port || (!kh.port && port === 22)),
|
||||
);
|
||||
}, [knownHosts, target.hostname, port]);
|
||||
const [saveCredentials] = useState(true);
|
||||
|
||||
// Reset state when target changes
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -17,7 +17,6 @@ type QuickSwitcherItem = {
|
||||
data?: Host | TerminalSession | Workspace;
|
||||
};
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
|
||||
@@ -68,7 +67,7 @@ interface QuickSwitcherProps {
|
||||
onSelectTab: (tabId: string) => void;
|
||||
onClose: () => void;
|
||||
onCreateLocalTerminal?: () => void;
|
||||
onCreateWorkspace?: () => void;
|
||||
// onCreateWorkspace removed - feature not currently used
|
||||
keyBindings?: KeyBinding[];
|
||||
}
|
||||
|
||||
@@ -83,7 +82,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
onSelectTab,
|
||||
onClose,
|
||||
onCreateLocalTerminal,
|
||||
onCreateWorkspace,
|
||||
keyBindings,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -132,7 +130,8 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
[sessions]
|
||||
);
|
||||
|
||||
const showCategorized = query.trim().length > 0;
|
||||
// Always show categorized view (Hosts/Tabs/Quick connect)
|
||||
const showCategorized = true;
|
||||
|
||||
// Memoize flat items list and index map
|
||||
const { flatItems, itemIndexMap } = useMemo(() => {
|
||||
@@ -242,209 +241,163 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 h-full">
|
||||
{!showCategorized ? (
|
||||
/* Default view: Recent connections with header */
|
||||
<div>
|
||||
<div className="px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("qs.recentConnections")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 px-2 text-[11px]"
|
||||
onClick={() => onCreateWorkspace?.()}
|
||||
>
|
||||
{t("qs.createWorkspace")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 px-2 text-[11px]"
|
||||
disabled
|
||||
>
|
||||
{t("qs.restore")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{results.length > 0 ? (
|
||||
results.map((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
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Categorized view: Hosts/Tabs/Quick connect */}
|
||||
<div>
|
||||
{/* Jump To hint */}
|
||||
<div className="px-4 py-2 flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{t("qs.jumpTo")}</span>
|
||||
{quickSwitchKey && (
|
||||
<kbd className="text-[10px] text-muted-foreground bg-muted px-1 py-0.5 rounded">
|
||||
{quickSwitchKey.replace(/ \+ /g, '+')}
|
||||
</kbd>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Focused/searching view: Categorized items */
|
||||
|
||||
{/* Hosts section */}
|
||||
{results.length > 0 && (
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Hosts
|
||||
</span>
|
||||
</div>
|
||||
{results.map((host) => (
|
||||
<HostItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
isSelected={getItemIndex("host", host.id) === selectedIndex}
|
||||
onSelect={onSelect}
|
||||
onMouseEnter={() => setSelectedIndex(getItemIndex("host", host.id))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs section */}
|
||||
<div>
|
||||
{/* Jump To hint */}
|
||||
<div className="px-4 py-2 flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{t("qs.jumpTo")}</span>
|
||||
{quickSwitchKey && (
|
||||
<kbd className="text-[10px] text-muted-foreground bg-muted px-1 py-0.5 rounded">
|
||||
{quickSwitchKey.replace(/ \+ /g, '+')}
|
||||
</kbd>
|
||||
)}
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Tabs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Hosts section */}
|
||||
{results.length > 0 && (
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Hosts
|
||||
{/* Built-in tabs */}
|
||||
{["vault", "sftp"].map((tabId) => {
|
||||
const idx = getItemIndex("tab", tabId);
|
||||
const isSelected = idx === selectedIndex;
|
||||
const icon =
|
||||
tabId === "vault" ? (
|
||||
<Shield size={16} />
|
||||
) : (
|
||||
<Folder size={16} />
|
||||
);
|
||||
const label = tabId === "vault" ? "Vaults" : "SFTP";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tabId}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectTab(tabId);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
{icon}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Workspaces */}
|
||||
{workspaces.map((workspace) => {
|
||||
const idx = getItemIndex("workspace", workspace.id);
|
||||
const isSelected = idx === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workspace.id}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectTab(workspace.id);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
<LayoutGrid size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{workspace.title}
|
||||
</span>
|
||||
</div>
|
||||
{results.map((host) => (
|
||||
<HostItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
isSelected={getItemIndex("host", host.id) === selectedIndex}
|
||||
onSelect={onSelect}
|
||||
onMouseEnter={() => setSelectedIndex(getItemIndex("host", host.id))}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Orphan sessions */}
|
||||
{orphanSessions.map((session) => {
|
||||
const idx = getItemIndex("tab", session.id);
|
||||
const isSelected = idx === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectTab(session.id);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
<TerminalSquare size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{session.hostLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Quick connect section */}
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Quick connect
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Local Terminal */}
|
||||
{onCreateLocalTerminal && (
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${getItemIndex("action", "local-terminal") === selectedIndex
|
||||
? "bg-primary/15"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onCreateLocalTerminal();
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setSelectedIndex(getItemIndex("action", "local-terminal"))
|
||||
}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
<Terminal size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">{t("qs.localTerminal")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs section */}
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Tabs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Built-in tabs */}
|
||||
{["vault", "sftp"].map((tabId) => {
|
||||
const idx = getItemIndex("tab", tabId);
|
||||
const isSelected = idx === selectedIndex;
|
||||
const icon =
|
||||
tabId === "vault" ? (
|
||||
<Shield size={16} />
|
||||
) : (
|
||||
<Folder size={16} />
|
||||
);
|
||||
const label = tabId === "vault" ? "Vaults" : "SFTP";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tabId}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectTab(tabId);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
{icon}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Workspaces */}
|
||||
{workspaces.map((workspace) => {
|
||||
const idx = getItemIndex("workspace", workspace.id);
|
||||
const isSelected = idx === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workspace.id}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectTab(workspace.id);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
<LayoutGrid size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{workspace.title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Orphan sessions */}
|
||||
{orphanSessions.map((session) => {
|
||||
const idx = getItemIndex("tab", session.id);
|
||||
const isSelected = idx === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectTab(session.id);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
<TerminalSquare size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{session.hostLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Quick connect section */}
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Quick connect
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Local Terminal */}
|
||||
{onCreateLocalTerminal && (
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${getItemIndex("action", "local-terminal") === selectedIndex
|
||||
? "bg-primary/15"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onCreateLocalTerminal();
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setSelectedIndex(getItemIndex("action", "local-terminal"))
|
||||
}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
<Terminal size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">{t("qs.localTerminal")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Serial removed (not supported) */}
|
||||
</div>
|
||||
{/* Serial removed (not supported) */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,9 +21,9 @@ import { useSftpModalPath } from "./sftp-modal/hooks/useSftpModalPath";
|
||||
import { useSftpModalSelection } from "./sftp-modal/hooks/useSftpModalSelection";
|
||||
import { useSftpModalSession } from "./sftp-modal/hooks/useSftpModalSession";
|
||||
import { useSftpModalFileActions } from "./sftp-modal/hooks/useSftpModalFileActions";
|
||||
import { useSftpModalKeyboardShortcuts } from "./sftp-modal/hooks/useSftpModalKeyboardShortcuts";
|
||||
import { joinPath, isRootPath, getParentPath } from "./sftp-modal/pathUtils";
|
||||
import { toast } from "./ui/toast";
|
||||
import { Dialog, DialogContent } from "./ui/dialog";
|
||||
|
||||
interface SFTPModalProps {
|
||||
host: Host;
|
||||
@@ -41,6 +41,7 @@ interface SFTPModalProps {
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
sftpSudo?: boolean;
|
||||
legacyAlgorithms?: boolean;
|
||||
};
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -48,6 +49,8 @@ interface SFTPModalProps {
|
||||
initialPath?: string;
|
||||
/** Initial entries to upload when SFTP modal opens. Used for drag-and-drop to terminal. */
|
||||
initialEntriesToUpload?: DropEntry[];
|
||||
/** Callback to update the host (e.g. for bookmark persistence). */
|
||||
onUpdateHost?: (host: Host) => void;
|
||||
}
|
||||
|
||||
const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
@@ -57,6 +60,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
onClose,
|
||||
initialPath,
|
||||
initialEntriesToUpload,
|
||||
onUpdateHost,
|
||||
}) => {
|
||||
const {
|
||||
openSftp,
|
||||
@@ -85,7 +89,16 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
showSaveDialog,
|
||||
} = useSftpBackend();
|
||||
const { t } = useI18n();
|
||||
const { sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload } = useSettingsState();
|
||||
const {
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
setSftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
} = useSettingsState();
|
||||
const isLocalSession = host.protocol === "local";
|
||||
const [filenameEncoding, setFilenameEncoding] = useState<SftpFilenameEncoding>(
|
||||
host.sftpEncoding ?? "auto"
|
||||
@@ -191,10 +204,12 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
const {
|
||||
currentPath,
|
||||
setCurrentPath,
|
||||
currentPathRef,
|
||||
files,
|
||||
loading,
|
||||
setLoading,
|
||||
reconnecting,
|
||||
sessionVersion,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
closeSftpSession,
|
||||
@@ -287,6 +302,13 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
handleDelete,
|
||||
handleCreateFolder,
|
||||
handleCreateFile,
|
||||
showCreateDialog,
|
||||
setShowCreateDialog,
|
||||
createType,
|
||||
createName,
|
||||
setCreateName,
|
||||
isCreating,
|
||||
handleCreateSubmit,
|
||||
showRenameDialog,
|
||||
setShowRenameDialog,
|
||||
renameTarget,
|
||||
@@ -363,6 +385,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
dismissTask,
|
||||
} = useSftpModalTransfers({
|
||||
currentPath,
|
||||
currentPathRef,
|
||||
isLocalSession,
|
||||
joinPath: joinPathForSession,
|
||||
ensureSftp,
|
||||
@@ -382,9 +405,40 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
setLoading,
|
||||
t,
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
listSftp: listSftpWithEncoding,
|
||||
deleteLocalFile,
|
||||
});
|
||||
const hasEverOpenedRef = useRef(false);
|
||||
|
||||
const hasActiveTransferTasks = useMemo(
|
||||
() =>
|
||||
uploadTasks.some(
|
||||
(task) =>
|
||||
task.status === "pending" ||
|
||||
task.status === "uploading" ||
|
||||
task.status === "downloading",
|
||||
),
|
||||
[uploadTasks],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
hasEverOpenedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasEverOpenedRef.current) return;
|
||||
if (uploading || hasActiveTransferTasks) return;
|
||||
|
||||
void closeSftpSession();
|
||||
}, [closeSftpSession, hasActiveTransferTasks, open, sessionVersion, uploading]);
|
||||
|
||||
const handleClose = async () => {
|
||||
if (uploading || hasActiveTransferTasks) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
await closeSftpSession();
|
||||
onClose();
|
||||
};
|
||||
@@ -423,8 +477,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
initialUploadTriggeredRef.current = true;
|
||||
|
||||
// Trigger upload with full DropEntry data (preserves directory structure)
|
||||
handleUploadEntries(initialEntriesToUpload);
|
||||
}, [initialEntriesToUpload, open, loading, handleUploadEntries]);
|
||||
void handleUploadEntries(initialEntriesToUpload);
|
||||
}, [handleUploadEntries, initialEntriesToUpload, loading, open]);
|
||||
|
||||
// Display files with parent entry (like SftpView)
|
||||
const displayFiles = useMemo(() => {
|
||||
@@ -489,6 +543,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
|
||||
return parentEntry ? [parentEntry, ...sorted] : sorted;
|
||||
}, [displayFiles, sortField, sortOrder]);
|
||||
const hasFiles = files.length > 0;
|
||||
const hasDisplayFiles = sortedFiles.length > 0;
|
||||
const {
|
||||
fileListRef,
|
||||
handleFileListScroll,
|
||||
@@ -508,6 +564,56 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
onNavigateUp: handleUp,
|
||||
});
|
||||
|
||||
// Keyboard shortcuts for modal
|
||||
const handleKeyboardRename = useCallback((file: RemoteFile) => {
|
||||
openRenameDialog(file);
|
||||
}, [openRenameDialog]);
|
||||
|
||||
const handleKeyboardDelete = useCallback((fileNames: string[]) => {
|
||||
// Find the files to pass to confirm dialog
|
||||
if (fileNames.length === 0) return;
|
||||
if (!confirm(t("sftp.deleteConfirm.title", { count: fileNames.length }))) return;
|
||||
|
||||
// Delete files
|
||||
(async () => {
|
||||
try {
|
||||
for (const fileName of fileNames) {
|
||||
const fullPath = joinPathForSession(currentPath, fileName);
|
||||
if (isLocalSession) {
|
||||
await deleteLocalFile(fullPath);
|
||||
} else {
|
||||
await deleteSftpWithEncoding(await ensureSftp(), fullPath);
|
||||
}
|
||||
}
|
||||
await loadFiles(currentPath, { force: true });
|
||||
setSelectedFiles(new Set());
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.deleteFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
})();
|
||||
}, [currentPath, isLocalSession, deleteLocalFile, deleteSftpWithEncoding, ensureSftp, loadFiles, setSelectedFiles, t, joinPathForSession]);
|
||||
|
||||
const handleKeyboardNewFolder = useCallback(() => {
|
||||
handleCreateFolder();
|
||||
}, [handleCreateFolder]);
|
||||
|
||||
useSftpModalKeyboardShortcuts({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
open,
|
||||
files,
|
||||
visibleFiles: displayFiles,
|
||||
selectedFiles,
|
||||
setSelectedFiles,
|
||||
onRefresh: () => loadFiles(currentPath, { force: true }),
|
||||
onRename: handleKeyboardRename,
|
||||
onDelete: handleKeyboardDelete,
|
||||
onNewFolder: handleKeyboardNewFolder,
|
||||
});
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
if (selectedFiles.size === 0) return;
|
||||
const fileNames = Array.from(selectedFiles);
|
||||
@@ -542,10 +648,13 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent className="max-w-4xl h-[80vh] flex flex-col p-0 gap-0">
|
||||
<>
|
||||
<div className="h-full flex flex-col bg-background border-r border-border/60 overflow-hidden">
|
||||
<SftpModalHeader
|
||||
onClose={handleClose}
|
||||
t={t}
|
||||
host={host}
|
||||
credentials={credentials}
|
||||
@@ -583,13 +692,20 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
onCreateFile={handleCreateFile}
|
||||
onFileSelect={handleFileSelect}
|
||||
onFolderSelect={handleFolderSelect}
|
||||
showHiddenFiles={sftpShowHiddenFiles}
|
||||
onToggleShowHiddenFiles={() =>
|
||||
setSftpShowHiddenFiles(!sftpShowHiddenFiles)
|
||||
}
|
||||
onUpdateHost={onUpdateHost}
|
||||
onNavigateToBookmark={(path) => setCurrentPath(path)}
|
||||
/>
|
||||
|
||||
<SftpModalFileList
|
||||
t={t}
|
||||
currentPath={currentPath}
|
||||
isLocalSession={isLocalSession}
|
||||
files={files}
|
||||
hasFiles={hasFiles}
|
||||
hasDisplayFiles={hasDisplayFiles}
|
||||
selectedFiles={selectedFiles}
|
||||
dragActive={dragActive}
|
||||
loading={loading}
|
||||
@@ -639,7 +755,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
onDownloadSelected={handleDownloadSelected}
|
||||
onDeleteSelected={handleDeleteSelected}
|
||||
/>
|
||||
</DialogContent>
|
||||
</div>
|
||||
|
||||
<SftpModalDialogs
|
||||
t={t}
|
||||
@@ -659,6 +775,13 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
getSymbolicPermissions={getSymbolicPermissions}
|
||||
handleSavePermissions={handleSavePermissions}
|
||||
isChangingPermissions={isChangingPermissions}
|
||||
showCreateDialog={showCreateDialog}
|
||||
setShowCreateDialog={setShowCreateDialog}
|
||||
createType={createType}
|
||||
createName={createName}
|
||||
setCreateName={setCreateName}
|
||||
isCreating={isCreating}
|
||||
handleCreateSubmit={handleCreateSubmit}
|
||||
/>
|
||||
|
||||
{/* File Opener Dialog */}
|
||||
@@ -684,8 +807,10 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
fileName={textEditorTarget?.name || ""}
|
||||
initialContent={textEditorContent}
|
||||
onSave={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
|
||||
/>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
221
components/ScriptsSidePanel.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* ScriptsSidePanel - Lightweight scripts browser for the terminal side panel
|
||||
*
|
||||
* Shows snippets organized by package hierarchy with breadcrumb navigation.
|
||||
* Clicking a snippet executes it in the focused terminal session.
|
||||
*/
|
||||
|
||||
import { ChevronRight, Package, Search, Zap } from 'lucide-react';
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Snippet } from '../types';
|
||||
import { Input } from './ui/input';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
|
||||
interface ScriptsSidePanelProps {
|
||||
snippets: Snippet[];
|
||||
packages: string[];
|
||||
onSnippetClick: (command: string) => void;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
snippets,
|
||||
packages,
|
||||
onSnippetClick,
|
||||
isVisible = true,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [selectedPackage, setSelectedPackage] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const displayedPackages = useMemo(() => {
|
||||
if (!selectedPackage) {
|
||||
const absolutePaths = packages.filter(p => p.startsWith('/'));
|
||||
const relativePaths = packages.filter(p => !p.startsWith('/'));
|
||||
|
||||
const results: { name: string; path: string; count: number }[] = [];
|
||||
|
||||
const relativeRoots = relativePaths
|
||||
.map((p) => p.split('/')[0])
|
||||
.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 });
|
||||
});
|
||||
|
||||
const absoluteRoots = absolutePaths
|
||||
.map((p) => {
|
||||
const cleanPath = p.substring(1);
|
||||
return cleanPath.split('/')[0];
|
||||
})
|
||||
.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}`;
|
||||
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((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) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
return { name, path, count };
|
||||
});
|
||||
}, [packages, selectedPackage, snippets]);
|
||||
|
||||
const displayedSnippets = useMemo(() => {
|
||||
let result = snippets.filter((s) => (s.package || '') === (selectedPackage || ''));
|
||||
if (search.trim()) {
|
||||
const s = search.toLowerCase();
|
||||
result = result.filter(sn =>
|
||||
sn.label.toLowerCase().includes(s) ||
|
||||
sn.command.toLowerCase().includes(s)
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [snippets, selectedPackage, search]);
|
||||
|
||||
// Also filter packages by search when at root level
|
||||
const filteredPackages = useMemo(() => {
|
||||
if (!search.trim()) return displayedPackages;
|
||||
const s = search.toLowerCase();
|
||||
return displayedPackages.filter(pkg => pkg.name.toLowerCase().includes(s));
|
||||
}, [displayedPackages, search]);
|
||||
|
||||
const breadcrumb = useMemo(() => {
|
||||
if (!selectedPackage) return [];
|
||||
const isAbsolute = selectedPackage.startsWith('/');
|
||||
const parts = selectedPackage.split('/').filter(Boolean);
|
||||
return parts.map((name, idx) => {
|
||||
const pathSegments = parts.slice(0, idx + 1);
|
||||
const path = isAbsolute ? `/${pathSegments.join('/')}` : pathSegments.join('/');
|
||||
return { name, path };
|
||||
});
|
||||
}, [selectedPackage]);
|
||||
|
||||
const handleSnippetClick = useCallback((command: string) => {
|
||||
onSnippetClick(command);
|
||||
}, [onSnippetClick]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const hasAnyContent = snippets.length > 0 || packages.length > 0;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background overflow-hidden">
|
||||
{/* Search */}
|
||||
<div className="shrink-0 px-2 py-1.5 border-b border-border/50">
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('snippets.searchPlaceholder')}
|
||||
className="h-7 pl-7 text-xs bg-muted/30 border-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="shrink-0 flex items-center gap-1 px-3 py-1.5 text-[11px] border-b border-border/30 min-h-[28px]">
|
||||
<button
|
||||
className={cn(
|
||||
"hover:text-primary transition-colors truncate",
|
||||
!selectedPackage ? "text-foreground font-medium" : "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => setSelectedPackage(null)}
|
||||
>
|
||||
{t('terminal.toolbar.library')}
|
||||
</button>
|
||||
{breadcrumb.map((b) => (
|
||||
<React.Fragment key={b.path}>
|
||||
<ChevronRight size={10} className="text-muted-foreground shrink-0" />
|
||||
<button
|
||||
className="text-muted-foreground hover:text-primary transition-colors truncate"
|
||||
onClick={() => setSelectedPackage(b.path)}
|
||||
>
|
||||
{b.name}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="py-1">
|
||||
{!hasAnyContent && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<Zap size={24} className="opacity-40 mb-2" />
|
||||
<span className="text-xs">{t('terminal.toolbar.noSnippets')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Packages */}
|
||||
{filteredPackages.map((pkg) => (
|
||||
<button
|
||||
key={pkg.path}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-accent/50 transition-colors"
|
||||
onClick={() => { setSelectedPackage(pkg.path); setSearch(''); }}
|
||||
>
|
||||
<div className="w-6 h-6 rounded-md bg-primary/10 text-primary flex items-center justify-center shrink-0">
|
||||
<Package size={12} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium truncate">{pkg.name}</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{t('snippets.package.count', { count: pkg.count })}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={12} className="text-muted-foreground shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Snippets */}
|
||||
{displayedSnippets.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => handleSnippetClick(s.command)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-accent/50 transition-colors flex flex-col gap-0.5"
|
||||
>
|
||||
<span className="text-xs font-medium truncate">{s.label}</span>
|
||||
<span className="text-muted-foreground truncate font-mono text-[10px] max-w-full">
|
||||
{s.command}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{hasAnyContent && displayedSnippets.length === 0 && filteredPackages.length === 0 && search.trim() && (
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
|
||||
{t('common.noResultsFound')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ScriptsSidePanel = memo(ScriptsSidePanelInner);
|
||||
ScriptsSidePanel.displayName = 'ScriptsSidePanel';
|
||||
@@ -197,19 +197,6 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
}));
|
||||
}, [currentPath]);
|
||||
|
||||
const _handleBack = () => {
|
||||
if (currentPath) {
|
||||
const parts = currentPath.split("/");
|
||||
if (parts.length > 1) {
|
||||
setCurrentPath(parts.slice(0, -1).join("/"));
|
||||
} else {
|
||||
setCurrentPath(null);
|
||||
}
|
||||
} else {
|
||||
onBack();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -4,7 +4,7 @@ import AppLogo from "./AppLogo";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useApplicationBackend } from "../application/state/useApplicationBackend";
|
||||
import { useUpdateCheck } from "../application/state/useUpdateCheck";
|
||||
import type { UpdateState, UseUpdateCheckResult } from "../application/state/useUpdateCheck";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { SettingsTabContent } from "./settings/settings-ui";
|
||||
import { toast } from "./ui/toast";
|
||||
@@ -63,13 +63,18 @@ const ActionRow: React.FC<{
|
||||
</button>
|
||||
);
|
||||
|
||||
export default function SettingsApplicationTab() {
|
||||
interface SettingsApplicationTabProps {
|
||||
updateState: UpdateState;
|
||||
checkNow: UseUpdateCheckResult['checkNow'];
|
||||
openReleasePage: UseUpdateCheckResult['openReleasePage'];
|
||||
installUpdate: UseUpdateCheckResult['installUpdate'];
|
||||
}
|
||||
|
||||
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate }: SettingsApplicationTabProps) {
|
||||
const { t } = useI18n();
|
||||
const { openExternal, getApplicationInfo } = useApplicationBackend();
|
||||
const { updateState, checkNow, openReleasePage } = useUpdateCheck();
|
||||
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
|
||||
const [lastCheckResult, setLastCheckResult] = useState<'none' | 'available' | 'upToDate'>('none');
|
||||
const [hasAutoChecked, setHasAutoChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -93,19 +98,6 @@ export default function SettingsApplicationTab() {
|
||||
const isUpdateDemoMode = typeof window !== 'undefined' &&
|
||||
window.localStorage?.getItem('debug.updateDemo') === '1';
|
||||
|
||||
// Auto check for updates when entering this page
|
||||
useEffect(() => {
|
||||
if (hasAutoChecked) return;
|
||||
if (updateState.isChecking) return;
|
||||
|
||||
// In demo mode or when we have a valid version, auto-check
|
||||
const canCheck = isUpdateDemoMode || (appInfo.version && appInfo.version !== '0.0.0');
|
||||
if (!canCheck) return;
|
||||
|
||||
setHasAutoChecked(true);
|
||||
void checkNow();
|
||||
}, [hasAutoChecked, updateState.isChecking, isUpdateDemoMode, appInfo.version, checkNow]);
|
||||
|
||||
const handleCheckForUpdates = async () => {
|
||||
// In demo mode, allow checking even for dev builds
|
||||
if (!isUpdateDemoMode && (!appInfo.version || appInfo.version === '0.0.0')) {
|
||||
@@ -124,8 +116,9 @@ export default function SettingsApplicationTab() {
|
||||
t('update.available.message', { version: result.latestRelease.version }),
|
||||
t('update.available.title')
|
||||
);
|
||||
// Open the release page
|
||||
openReleasePage();
|
||||
// Don't auto-open the release page here — checkNow() already triggers
|
||||
// electron-updater on supported platforms, and the Settings > System tab
|
||||
// shows a "Manual Download" link on unsupported platforms.
|
||||
} else if (result) {
|
||||
setLastCheckResult('upToDate');
|
||||
toast.success(
|
||||
@@ -154,18 +147,25 @@ export default function SettingsApplicationTab() {
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{appInfo.version ? appInfo.version : " "}
|
||||
</span>
|
||||
{/* Update available badge - inline with version */}
|
||||
{updateState.hasUpdate && updateState.latestRelease && (
|
||||
{/* Update badge - reflects auto-download state */}
|
||||
{updateState.latestRelease && (updateState.hasUpdate || updateState.autoDownloadStatus === 'downloading' || updateState.autoDownloadStatus === 'ready') && (
|
||||
<button
|
||||
onClick={() => void openReleasePage()}
|
||||
onClick={() => updateState.autoDownloadStatus === 'ready' ? installUpdate() : void openReleasePage()}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
|
||||
"bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300",
|
||||
"hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors cursor-pointer"
|
||||
updateState.autoDownloadStatus === 'ready'
|
||||
? "bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-800"
|
||||
: "bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-800",
|
||||
"transition-colors cursor-pointer"
|
||||
)}
|
||||
>
|
||||
<ArrowUpCircle size={12} />
|
||||
v{updateState.latestRelease.version} {t('update.downloadNow')}
|
||||
v{updateState.latestRelease.version}{' '}
|
||||
{updateState.autoDownloadStatus === 'ready'
|
||||
? t('update.restartNow')
|
||||
: updateState.autoDownloadStatus === 'downloading'
|
||||
? `${updateState.downloadPercent}%`
|
||||
: t('update.downloadNow')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
* Settings Page - Standalone settings window content
|
||||
* This component is rendered in a separate Electron window
|
||||
*/
|
||||
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, TerminalSquare, X } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, Sparkles, TerminalSquare, X } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import { useVaultState } from "../application/state/useVaultState";
|
||||
import { useWindowControls } from "../application/state/useWindowControls";
|
||||
import { useUpdateCheck } from "../application/state/useUpdateCheck";
|
||||
import { useAIState } from "../application/state/useAIState";
|
||||
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
|
||||
import SettingsApplicationTab from "./SettingsApplicationTab";
|
||||
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
|
||||
@@ -14,11 +17,34 @@ import SettingsFileAssociationsTab from "./settings/tabs/SettingsFileAssociation
|
||||
import SettingsShortcutsTab from "./settings/tabs/SettingsShortcutsTab";
|
||||
import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
|
||||
import SettingsSystemTab from "./settings/tabs/SettingsSystemTab";
|
||||
const SettingsAITab = React.lazy(() => import("./settings/tabs/SettingsAITab"));
|
||||
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);
|
||||
|
||||
class AITabErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
{ error: Error | null }
|
||||
> {
|
||||
state: { error: Error | null } = { error: null };
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div style={{ padding: 32, color: "#f87171", fontFamily: "monospace", whiteSpace: "pre-wrap" }}>
|
||||
<h3 style={{ marginBottom: 8 }}>AI Settings Error</h3>
|
||||
<div>{this.state.error.message}</div>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: "#888" }}>{this.state.error.stack}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
type SettingsState = ReturnType<typeof useSettingsState> & {
|
||||
availableFonts: TerminalFont[];
|
||||
};
|
||||
@@ -31,17 +57,38 @@ const SettingsSyncTabWithVault: React.FC = () => {
|
||||
keys,
|
||||
identities,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
knownHosts,
|
||||
importDataFromString,
|
||||
clearVaultData,
|
||||
} = useVaultState();
|
||||
|
||||
const { rules: portForwardingRules, importRules: importPortForwardingRules } = usePortForwardingState();
|
||||
|
||||
// Strip transient runtime fields before passing to sync
|
||||
const portForwardingRulesForSync = useMemo(
|
||||
() =>
|
||||
portForwardingRules.map((rule) => ({
|
||||
...rule,
|
||||
status: "inactive" as const,
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
})),
|
||||
[portForwardingRules],
|
||||
);
|
||||
|
||||
const vault = useMemo(
|
||||
() => ({ hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts }),
|
||||
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsSyncTab
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
snippets={snippets}
|
||||
vault={vault}
|
||||
portForwardingRules={portForwardingRulesForSync}
|
||||
importDataFromString={importDataFromString}
|
||||
importPortForwardingRules={importPortForwardingRules}
|
||||
clearVaultData={clearVaultData}
|
||||
/>
|
||||
);
|
||||
@@ -50,6 +97,8 @@ const SettingsSyncTabWithVault: React.FC = () => {
|
||||
const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }) => {
|
||||
const { t } = useI18n();
|
||||
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
|
||||
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck();
|
||||
const aiState = useAIState();
|
||||
const [activeTab, setActiveTab] = useState("application");
|
||||
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
|
||||
|
||||
@@ -128,6 +177,12 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
>
|
||||
<FileType size={14} /> {t("settings.tab.sftpFileAssociations")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="ai"
|
||||
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"
|
||||
>
|
||||
<Sparkles size={14} /> AI
|
||||
</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"
|
||||
@@ -144,7 +199,14 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
</div>
|
||||
|
||||
<div className="flex-1 h-full flex flex-col min-h-0 bg-muted/10">
|
||||
{mountedTabs.has("application") && <SettingsApplicationTab />}
|
||||
{mountedTabs.has("application") && (
|
||||
<SettingsApplicationTab
|
||||
updateState={updateState}
|
||||
checkNow={checkNow}
|
||||
openReleasePage={openReleasePage}
|
||||
installUpdate={installUpdate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("appearance") && (
|
||||
<SettingsAppearanceTab
|
||||
@@ -197,6 +259,35 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
<SettingsFileAssociationsTab />
|
||||
)}
|
||||
|
||||
{mountedTabs.has("ai") && (
|
||||
<AITabErrorBoundary>
|
||||
<React.Suspense fallback={null}>
|
||||
<SettingsAITab
|
||||
providers={aiState.providers}
|
||||
addProvider={aiState.addProvider}
|
||||
updateProvider={aiState.updateProvider}
|
||||
removeProvider={aiState.removeProvider}
|
||||
activeProviderId={aiState.activeProviderId}
|
||||
setActiveProviderId={aiState.setActiveProviderId}
|
||||
activeModelId={aiState.activeModelId}
|
||||
setActiveModelId={aiState.setActiveModelId}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
setDefaultAgentId={aiState.setDefaultAgentId}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
setCommandBlocklist={aiState.setCommandBlocklist}
|
||||
commandTimeout={aiState.commandTimeout}
|
||||
setCommandTimeout={aiState.setCommandTimeout}
|
||||
maxIterations={aiState.maxIterations}
|
||||
setMaxIterations={aiState.setMaxIterations}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</AITabErrorBoundary>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("sync") && (
|
||||
<React.Suspense fallback={null}>
|
||||
<SettingsSyncTabWithVault />
|
||||
@@ -211,6 +302,15 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setSessionLogsDir={settings.setSessionLogsDir}
|
||||
sessionLogsFormat={settings.sessionLogsFormat}
|
||||
setSessionLogsFormat={settings.setSessionLogsFormat}
|
||||
toggleWindowHotkey={settings.toggleWindowHotkey}
|
||||
setToggleWindowHotkey={settings.setToggleWindowHotkey}
|
||||
closeToTray={settings.closeToTray}
|
||||
setCloseToTray={settings.setCloseToTray}
|
||||
hotkeyRegistrationError={settings.hotkeyRegistrationError}
|
||||
updateState={updateState}
|
||||
checkNow={checkNow}
|
||||
installUpdate={installUpdate}
|
||||
openReleasePage={openReleasePage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
618
components/SftpSidePanel.tsx
Normal file
@@ -0,0 +1,618 @@
|
||||
/**
|
||||
* SftpSidePanel - SFTP file browser rendered as a resizable side panel
|
||||
*
|
||||
* Reuses SftpView's components (SftpPaneView, SftpContextProvider, etc.)
|
||||
* to provide a unified SFTP experience. Renders a single pane (left side only).
|
||||
*
|
||||
* IMPORTANT: Does NOT use the global activeTabStore to avoid conflicts with
|
||||
* the main SftpView tab. Instead manages pane visibility internally.
|
||||
*
|
||||
* Used in TerminalLayer to provide SFTP alongside terminal sessions.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
import { useSftpBackend } from "../application/state/useSftpBackend";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
import { getParentPath } from "../application/state/sftp/utils";
|
||||
import { buildCacheKey } from "../application/state/sftp/sharedRemoteHostCache";
|
||||
import { logger } from "../lib/logger";
|
||||
import type { DropEntry } from "../lib/sftpFileUtils";
|
||||
import { Host, Identity, SSHKey } from "../types";
|
||||
import type { TransferTask } from "../types";
|
||||
import { toast } from "./ui/toast";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
|
||||
import { SftpPaneView } from "./sftp/SftpPaneView";
|
||||
import { SftpOverlays } from "./sftp/SftpOverlays";
|
||||
import { SftpTransferQueue } from "./sftp/SftpTransferQueue";
|
||||
import { SftpContextProvider } from "./sftp";
|
||||
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
|
||||
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
|
||||
|
||||
interface SftpSidePanelProps {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
/** The host to connect to (follows focused terminal) */
|
||||
activeHost: Host | null;
|
||||
initialLocation?: { hostId: string; path: string } | null;
|
||||
showWorkspaceHostHeader?: boolean;
|
||||
isVisible?: boolean;
|
||||
renderOverlays?: boolean;
|
||||
pendingUpload?: {
|
||||
requestId: string;
|
||||
hostId: string;
|
||||
connectionKey: string;
|
||||
targetPath?: string;
|
||||
entries: DropEntry[];
|
||||
} | null;
|
||||
onPendingUploadHandled?: (requestId: string) => void;
|
||||
sftpDoubleClickBehavior: "open" | "transfer";
|
||||
sftpAutoSync: boolean;
|
||||
sftpShowHiddenFiles: boolean;
|
||||
sftpUseCompressedUpload: boolean;
|
||||
editorWordWrap: boolean;
|
||||
setEditorWordWrap: (value: boolean) => void;
|
||||
onGetTerminalCwd?: () => Promise<string | null>;
|
||||
}
|
||||
|
||||
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
updateHosts,
|
||||
activeHost,
|
||||
initialLocation,
|
||||
showWorkspaceHostHeader = false,
|
||||
isVisible = true,
|
||||
renderOverlays = true,
|
||||
pendingUpload = null,
|
||||
onPendingUploadHandled,
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
onGetTerminalCwd,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const fileWatchHandlers = useMemo(() => ({
|
||||
onFileWatchSynced: (payload: { remotePath: string }) => {
|
||||
const fileName = payload.remotePath.split('/').pop() || payload.remotePath;
|
||||
toast.success(t('sftp.autoSync.success', { fileName }));
|
||||
logger.info("[SFTP] File auto-synced to remote", payload);
|
||||
},
|
||||
onFileWatchError: (payload: { error: string }) => {
|
||||
toast.error(t('sftp.autoSync.error', { error: payload.error }));
|
||||
logger.error("[SFTP] File auto-sync failed", payload);
|
||||
},
|
||||
}), [t]);
|
||||
|
||||
const sftpOptions = useMemo(() => ({
|
||||
...fileWatchHandlers,
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
defaultShowHiddenFiles: sftpShowHiddenFiles,
|
||||
autoConnectLocalOnMount: false,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
|
||||
const { showSaveDialog, startStreamTransfer } = useSftpBackend();
|
||||
|
||||
const sftpRef = useRef(sftp);
|
||||
sftpRef.current = sftp;
|
||||
|
||||
const behaviorRef = useRef(sftpDoubleClickBehavior);
|
||||
behaviorRef.current = sftpDoubleClickBehavior;
|
||||
|
||||
const autoSyncRef = useRef(sftpAutoSync);
|
||||
autoSyncRef.current = sftpAutoSync;
|
||||
|
||||
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
|
||||
const getOpenerForFileRef = useRef(getOpenerForFile);
|
||||
getOpenerForFileRef.current = getOpenerForFile;
|
||||
|
||||
const handleToggleHiddenFiles = useCallback((paneId: string) => {
|
||||
const pane = sftpRef.current.leftTabs.tabs.find((tab) => tab.id === paneId);
|
||||
if (!pane) return;
|
||||
sftpRef.current.setShowHiddenFiles("left", paneId, !pane.showHiddenFiles);
|
||||
}, []);
|
||||
|
||||
// NOTE: We intentionally do NOT sync to activeTabStore here.
|
||||
// activeTabStore is a global singleton shared with SftpView.
|
||||
// Writing to it here would corrupt SftpView's left pane visibility.
|
||||
|
||||
const {
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
dragCallbacks,
|
||||
draggedFiles,
|
||||
permissionsState,
|
||||
setPermissionsState,
|
||||
showTextEditor,
|
||||
setShowTextEditor,
|
||||
textEditorTarget,
|
||||
setTextEditorTarget,
|
||||
textEditorContent,
|
||||
setTextEditorContent,
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
handleSaveTextFile,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
} = useSftpViewPaneCallbacks({
|
||||
sftpRef,
|
||||
behaviorRef,
|
||||
autoSyncRef,
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
showSaveDialog,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
||||
});
|
||||
|
||||
const {
|
||||
leftPanes,
|
||||
showHostPickerLeft,
|
||||
showHostPickerRight,
|
||||
hostSearchLeft,
|
||||
hostSearchRight,
|
||||
setShowHostPickerLeft,
|
||||
setShowHostPickerRight,
|
||||
setHostSearchLeft,
|
||||
setHostSearchRight,
|
||||
handleHostSelectLeft,
|
||||
handleHostSelectRight,
|
||||
} = useSftpViewTabs({ sftp, sftpRef });
|
||||
|
||||
// Auto-connect when activeHost changes.
|
||||
// Uses sftpRef to avoid re-triggering on every sftp state change.
|
||||
const connectedKeyRef = useRef<string | null>(null);
|
||||
// Store the Host object used for the current connection so the header
|
||||
// can show session-time overrides even during deferred host switches.
|
||||
const connectedHostObjRef = useRef<Host | null>(null);
|
||||
const lastAppliedInitialLocationKeyRef = useRef<string | null>(null);
|
||||
const handledPendingUploadIdRef = useRef<string | null>(null);
|
||||
// Maps tab IDs to the connectionKey used to create them, so we can
|
||||
// correctly identify tabs when the same host ID has different overrides.
|
||||
const tabConnectionKeyMapRef = useRef<Map<string, string>>(new Map());
|
||||
const pendingConnectionKeyRef = useRef<string | null>(null);
|
||||
const prevIsVisibleRef = useRef(isVisible);
|
||||
|
||||
// Reset location guard when the panel is reopened so the terminal cwd
|
||||
// is re-applied even if it matches the previous session's path.
|
||||
useEffect(() => {
|
||||
if (isVisible && !prevIsVisibleRef.current) {
|
||||
lastAppliedInitialLocationKeyRef.current = null;
|
||||
}
|
||||
prevIsVisibleRef.current = isVisible;
|
||||
}, [isVisible]);
|
||||
|
||||
// Navigate SFTP to the terminal's current working directory
|
||||
const handleGoToTerminalCwd = useCallback(async () => {
|
||||
if (!onGetTerminalCwd) return;
|
||||
const cwd = await onGetTerminalCwd();
|
||||
if (cwd) {
|
||||
sftpRef.current.navigateTo("left", cwd);
|
||||
}
|
||||
}, [onGetTerminalCwd]);
|
||||
|
||||
// Track whether there's active work that should block connection switching.
|
||||
// Computed outside the effect so it can be in the dependency array.
|
||||
const hasActiveTransfers = useMemo(
|
||||
() => sftp.transfers.some((t) => t.status === "pending" || t.status === "transferring"),
|
||||
[sftp.transfers],
|
||||
);
|
||||
// Block host-following while any connection-sensitive UI or operation
|
||||
// is active: text editor, permissions dialog, file-opener dialog, or
|
||||
// auto-synced external file watches.
|
||||
const hasActiveWork = hasActiveTransfers || showTextEditor || !!permissionsState || showFileOpenerDialog
|
||||
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeHost) return;
|
||||
|
||||
const s = sftpRef.current;
|
||||
|
||||
// Serial terminals don't support SFTP — disconnect any existing
|
||||
// connection (remote or local) so the panel doesn't remain bound to
|
||||
// a previous host.
|
||||
const proto = activeHost.protocol;
|
||||
if (proto === 'serial' || activeHost.id?.startsWith('serial-')) {
|
||||
// Serial terminals don't support SFTP. Just clear the tracked
|
||||
// connection key so switching back to a remote terminal will
|
||||
// trigger auto-connect. Don't disconnect existing tabs — they
|
||||
// may be reused when focus returns.
|
||||
connectedKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
// Local terminals connect to the local file browser
|
||||
if (proto === 'local' || activeHost.id?.startsWith('local-')) {
|
||||
if (hasActiveWork) return;
|
||||
const leftConn = s.leftPane.connection;
|
||||
if (leftConn?.isLocal) {
|
||||
// Already connected locally
|
||||
connectedKeyRef.current = "local";
|
||||
return;
|
||||
}
|
||||
// Check for an existing local tab to reuse
|
||||
const existingLocalTab = s.leftTabs.tabs.find((tab) =>
|
||||
tab.connection?.isLocal && tab.connection.status === "connected",
|
||||
);
|
||||
if (existingLocalTab) {
|
||||
s.selectTab("left", existingLocalTab.id);
|
||||
connectedKeyRef.current = "local";
|
||||
return;
|
||||
}
|
||||
connectedKeyRef.current = "local";
|
||||
// Preserve existing remote tab when switching to local
|
||||
const needsNewTab = !!(leftConn && leftConn.status === "connected");
|
||||
if (needsNewTab) {
|
||||
s.connect("left", "local", { forceNewTab: true });
|
||||
} else if (leftConn) {
|
||||
// Await disconnect before connecting locally to avoid the async
|
||||
// disconnect wiping out the fresh local connection.
|
||||
void s.disconnect("left").then(() => s.connect("left", "local"));
|
||||
} else {
|
||||
s.connect("left", "local");
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Build a connection key that accounts for session-time overrides
|
||||
// (same host ID may have different port/protocol in different workspace panes).
|
||||
// Uses buildCacheKey to stay consistent with the key recorded on upload tasks.
|
||||
const connectionKey = buildCacheKey(activeHost.id, activeHost.hostname, activeHost.port, activeHost.protocol, activeHost.sftpSudo, activeHost.username);
|
||||
if (connectedKeyRef.current === connectionKey) return;
|
||||
|
||||
// Don't switch connections while transfers or editor are active
|
||||
if (hasActiveWork) return;
|
||||
logger.info("[SftpSidePanel] Auto-connect triggered", {
|
||||
hostId: activeHost.id,
|
||||
hostLabel: activeHost.label,
|
||||
protocol: activeHost.protocol,
|
||||
hostname: activeHost.hostname,
|
||||
});
|
||||
|
||||
// Check if an existing SFTP tab matches this exact endpoint.
|
||||
// We track which connectionKey was used to create each tab so that
|
||||
// tabs for the same host ID with different session-time overrides
|
||||
// (port/protocol) are not incorrectly reused.
|
||||
const tabs = s.leftTabs.tabs;
|
||||
const existingTab = tabs.find((tab) => {
|
||||
if (!tab.connection || tab.connection.hostId !== activeHost.id) return false;
|
||||
// Don't reuse errored tabs — they need a fresh connection
|
||||
if (tab.connection.status === "error" || tab.connection.status === "disconnected") return false;
|
||||
return tabConnectionKeyMapRef.current.get(tab.id) === connectionKey;
|
||||
});
|
||||
if (existingTab) {
|
||||
s.selectTab("left", existingTab.id);
|
||||
connectedKeyRef.current = connectionKey;
|
||||
connectedHostObjRef.current = activeHost;
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new tab when there's already an active connection to a different
|
||||
// host, so the previous tab is preserved for instant switching on focus change.
|
||||
const currentConn = s.leftPane.connection;
|
||||
const needsNewTab = !!(currentConn && currentConn.status === "connected" && currentConn.hostId !== activeHost.id);
|
||||
|
||||
connectedKeyRef.current = connectionKey;
|
||||
connectedHostObjRef.current = activeHost;
|
||||
// Store the pending key so the effect below can map it once the tab is created
|
||||
pendingConnectionKeyRef.current = connectionKey;
|
||||
s.connect("left", activeHost, needsNewTab ? { forceNewTab: true } : undefined);
|
||||
}, [activeHost, hasActiveWork]); // Re-evaluate when work finishes so deferred switch can proceed
|
||||
|
||||
// Track the active tab's connectionKey after connect() creates or reuses it.
|
||||
// Watches both activeTabId (new tab) and connection status (reused tab reconnecting).
|
||||
useEffect(() => {
|
||||
const activeTabId = sftp.leftTabs.activeTabId;
|
||||
if (activeTabId && pendingConnectionKeyRef.current) {
|
||||
tabConnectionKeyMapRef.current.set(activeTabId, pendingConnectionKeyRef.current);
|
||||
pendingConnectionKeyRef.current = null;
|
||||
}
|
||||
}, [sftp.leftTabs.activeTabId, sftp.leftPane.connection?.status]);
|
||||
|
||||
// Clear the remembered connection key when the pane disconnects or the
|
||||
// session is lost, so re-opening SFTP for the same terminal reconnects.
|
||||
// Also reset the file-watch counter — watches are bound to the SFTP session,
|
||||
// so they stop when the session disconnects.
|
||||
useEffect(() => {
|
||||
const connection = sftp.leftPane.connection;
|
||||
if (!connection || connection.status === "error" || connection.status === "disconnected") {
|
||||
connectedKeyRef.current = null;
|
||||
if (sftp.activeFileWatchCountRef) {
|
||||
sftp.activeFileWatchCountRef.current = 0;
|
||||
}
|
||||
}
|
||||
}, [sftp.leftPane.connection, sftp.leftPane.connection?.status, sftp.activeFileWatchCountRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeHost || !initialLocation) return;
|
||||
if (initialLocation.hostId !== activeHost.id || !initialLocation.path) return;
|
||||
|
||||
const activePane = sftpRef.current.leftPane;
|
||||
const connection = activePane.connection;
|
||||
if (!connection || connection.isLocal || connection.hostId !== activeHost.id) return;
|
||||
if (connection.status !== "connected") return;
|
||||
|
||||
// Include full endpoint key so that same-hostId sessions with
|
||||
// different overrides each get their initial location applied.
|
||||
const locationKey = `${connectedKeyRef.current}:${initialLocation.path}`;
|
||||
if (lastAppliedInitialLocationKeyRef.current === locationKey) return;
|
||||
|
||||
if (connection.currentPath === initialLocation.path) {
|
||||
lastAppliedInitialLocationKeyRef.current = locationKey;
|
||||
return;
|
||||
}
|
||||
|
||||
lastAppliedInitialLocationKeyRef.current = locationKey;
|
||||
sftpRef.current.navigateTo("left", initialLocation.path);
|
||||
}, [
|
||||
activeHost,
|
||||
initialLocation,
|
||||
sftp.leftPane,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingUpload || !activeHost) return;
|
||||
if (handledPendingUploadIdRef.current === pendingUpload.requestId) return;
|
||||
if (pendingUpload.hostId !== activeHost.id) return;
|
||||
|
||||
const activePane = sftp.leftPane;
|
||||
const connection = activePane.connection;
|
||||
if (!connection || connection.isLocal || connection.hostId !== activeHost.id) return;
|
||||
if (connection.status !== "connected") return;
|
||||
|
||||
handledPendingUploadIdRef.current = pendingUpload.requestId;
|
||||
|
||||
const runUpload = async () => {
|
||||
try {
|
||||
const results = await sftpRef.current.uploadExternalEntries("left", pendingUpload.entries, {
|
||||
targetPath: pendingUpload.targetPath,
|
||||
});
|
||||
if (results.some((result) => result.cancelled)) {
|
||||
toast.info(t("sftp.upload.cancelled"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
const failCount = results.filter((result) => !result.success && !result.cancelled).length;
|
||||
const successCount = results.filter((result) => result.success).length;
|
||||
|
||||
if (failCount === 0) {
|
||||
const message =
|
||||
successCount === 1
|
||||
? `${t("sftp.upload")}: ${results[0]?.fileName ?? ""}`
|
||||
: `${t("sftp.uploadFiles")}: ${successCount}`;
|
||||
toast.success(message, "SFTP");
|
||||
} else {
|
||||
const failedFiles = results.filter((result) => !result.success && !result.cancelled);
|
||||
failedFiles.forEach((failed) => {
|
||||
const errorMsg = failed.error ? ` - ${failed.error}` : "";
|
||||
toast.error(
|
||||
`${t("sftp.error.uploadFailed")}: ${failed.fileName}${errorMsg}`,
|
||||
"SFTP",
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[SftpSidePanel] Failed to upload dropped files:", error);
|
||||
handledPendingUploadIdRef.current = null;
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
return;
|
||||
} finally {
|
||||
onPendingUploadHandled?.(pendingUpload.requestId);
|
||||
}
|
||||
};
|
||||
|
||||
void runUpload();
|
||||
}, [
|
||||
activeHost,
|
||||
onPendingUploadHandled,
|
||||
pendingUpload,
|
||||
sftp.leftPane,
|
||||
t,
|
||||
]);
|
||||
|
||||
const MAX_VISIBLE_TRANSFERS = 5;
|
||||
const visibleTransfers = useMemo(
|
||||
() => [...sftp.transfers].reverse().slice(0, MAX_VISIBLE_TRANSFERS),
|
||||
[sftp.transfers],
|
||||
);
|
||||
|
||||
const handleRevealTransferTarget = useCallback(
|
||||
async (task: TransferTask) => {
|
||||
const connection = sftpRef.current.leftPane.connection;
|
||||
if (!connection || connection.isLocal) return;
|
||||
|
||||
const revealPath = task.isDirectory ? task.targetPath : getParentPath(task.targetPath);
|
||||
await sftpRef.current.navigateTo("left", revealPath, { force: true });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const canRevealTransferTarget = useCallback(
|
||||
(task: TransferTask) => {
|
||||
if (task.status !== "completed") return false;
|
||||
if (task.direction !== "upload" && task.direction !== "remote-to-remote") return false;
|
||||
|
||||
const connection = sftp.leftPane.connection;
|
||||
if (!connection || connection.isLocal) return false;
|
||||
|
||||
if (task.targetHostId) {
|
||||
if (connection.hostId !== task.targetHostId) return false;
|
||||
// If the transfer recorded a full endpoint key, use it to
|
||||
// distinguish same-hostId uploads with different session overrides.
|
||||
if (task.targetConnectionKey) {
|
||||
return connectedKeyRef.current === task.targetConnectionKey;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return connection.id === task.targetConnectionId;
|
||||
},
|
||||
[sftp.leftPane.connection],
|
||||
);
|
||||
|
||||
// When the auto-connect effect defers a switch (active transfers or open
|
||||
// editor), the panel still operates on the current connection, not
|
||||
// activeHost. Use the connected host for the header so the label matches
|
||||
// what browse/edit/delete actions actually target.
|
||||
const displayHost = useMemo(() => {
|
||||
const conn = sftp.leftPane.connection;
|
||||
if (conn && !conn.isLocal) {
|
||||
// Prefer the stored Host object from connect time — it preserves
|
||||
// session-time overrides that the vault host may lack.
|
||||
if (connectedHostObjRef.current && connectedHostObjRef.current.id === conn.hostId) {
|
||||
return connectedHostObjRef.current;
|
||||
}
|
||||
return hosts.find((h) => h.id === conn.hostId) ?? activeHost;
|
||||
}
|
||||
return activeHost;
|
||||
}, [sftp.leftPane.connection, hosts, activeHost]);
|
||||
|
||||
// Determine the active pane to render (without using global activeTabStore)
|
||||
const activeLeftPaneId = sftp.leftTabs.activeTabId;
|
||||
|
||||
return (
|
||||
<SftpContextProvider
|
||||
hosts={hosts}
|
||||
updateHosts={updateHosts}
|
||||
draggedFiles={draggedFiles}
|
||||
dragCallbacks={dragCallbacks}
|
||||
leftCallbacks={leftCallbacks}
|
||||
rightCallbacks={rightCallbacks}
|
||||
>
|
||||
<div
|
||||
className="h-full flex flex-col bg-background overflow-hidden"
|
||||
style={isVisible ? undefined : { display: "none" }}
|
||||
aria-hidden={!isVisible}
|
||||
>
|
||||
{showWorkspaceHostHeader && displayHost && (
|
||||
<div className="shrink-0 border-b border-border/50 bg-muted/20 px-3 py-1.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<DistroAvatar
|
||||
host={displayHost}
|
||||
fallback={displayHost.label.slice(0, 2).toUpperCase()}
|
||||
size="sm"
|
||||
className="h-5 w-5 rounded-sm shrink-0"
|
||||
/>
|
||||
<div
|
||||
className="min-w-0 flex-1 max-w-[calc(100%-1.75rem)] text-[11px] leading-5 truncate"
|
||||
title={`${displayHost.label} · ${(displayHost.username || "root")}@${displayHost.hostname}:${displayHost.port || 22}`}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{displayHost.label}
|
||||
</span>
|
||||
<span className="mx-1 text-muted-foreground">·</span>
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{(displayHost.username || "root")}@{displayHost.hostname}:{displayHost.port || 22}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* File browser pane - render only the active pane */}
|
||||
<div className="relative flex-1 min-h-0">
|
||||
{leftPanes.map((pane, idx) => {
|
||||
// Manage visibility locally instead of via activeTabStore
|
||||
const isActive = activeLeftPaneId
|
||||
? pane.id === activeLeftPaneId
|
||||
: idx === 0;
|
||||
if (!isActive) return null;
|
||||
|
||||
return (
|
||||
<div key={pane.id} className="absolute inset-0 z-10">
|
||||
<SftpPaneView
|
||||
side="left"
|
||||
pane={pane}
|
||||
showHeader
|
||||
showEmptyHeader
|
||||
onToggleShowHiddenFiles={() => handleToggleHiddenFiles(pane.id)}
|
||||
onGoToTerminalCwd={onGetTerminalCwd ? handleGoToTerminalCwd : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<SftpTransferQueue
|
||||
sftp={sftp}
|
||||
visibleTransfers={visibleTransfers}
|
||||
canRevealTransferTarget={canRevealTransferTarget}
|
||||
onRevealTransferTarget={handleRevealTransferTarget}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{renderOverlays && (
|
||||
<SftpOverlays
|
||||
hosts={hosts}
|
||||
sftp={sftp}
|
||||
visibleTransfers={visibleTransfers}
|
||||
showTransferQueue={false}
|
||||
showHostPickerLeft={showHostPickerLeft}
|
||||
showHostPickerRight={showHostPickerRight}
|
||||
hostSearchLeft={hostSearchLeft}
|
||||
hostSearchRight={hostSearchRight}
|
||||
setShowHostPickerLeft={setShowHostPickerLeft}
|
||||
setShowHostPickerRight={setShowHostPickerRight}
|
||||
setHostSearchLeft={setHostSearchLeft}
|
||||
setHostSearchRight={setHostSearchRight}
|
||||
handleHostSelectLeft={handleHostSelectLeft}
|
||||
handleHostSelectRight={handleHostSelectRight}
|
||||
permissionsState={permissionsState}
|
||||
setPermissionsState={setPermissionsState}
|
||||
showTextEditor={showTextEditor}
|
||||
setShowTextEditor={setShowTextEditor}
|
||||
textEditorTarget={textEditorTarget}
|
||||
setTextEditorTarget={setTextEditorTarget}
|
||||
textEditorContent={textEditorContent}
|
||||
setTextEditorContent={setTextEditorContent}
|
||||
handleSaveTextFile={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
showFileOpenerDialog={showFileOpenerDialog}
|
||||
setShowFileOpenerDialog={setShowFileOpenerDialog}
|
||||
fileOpenerTarget={fileOpenerTarget}
|
||||
setFileOpenerTarget={setFileOpenerTarget}
|
||||
handleFileOpenerSelect={handleFileOpenerSelect}
|
||||
handleSelectSystemApp={handleSelectSystemApp}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</SftpContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps): boolean =>
|
||||
prev.hosts === next.hosts &&
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.updateHosts === next.updateHosts &&
|
||||
prev.activeHost === next.activeHost &&
|
||||
prev.showWorkspaceHostHeader === next.showWorkspaceHostHeader &&
|
||||
prev.isVisible === next.isVisible &&
|
||||
prev.renderOverlays === next.renderOverlays &&
|
||||
prev.pendingUpload?.requestId === next.pendingUpload?.requestId &&
|
||||
prev.onPendingUploadHandled === next.onPendingUploadHandled &&
|
||||
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
|
||||
prev.sftpAutoSync === next.sftpAutoSync &&
|
||||
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
|
||||
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
|
||||
prev.editorWordWrap === next.editorWordWrap &&
|
||||
prev.setEditorWordWrap === next.setEditorWordWrap &&
|
||||
prev.onGetTerminalCwd === next.onGetTerminalCwd &&
|
||||
prev.initialLocation?.hostId === next.initialLocation?.hostId &&
|
||||
prev.initialLocation?.path === next.initialLocation?.path;
|
||||
|
||||
export const SftpSidePanel = memo(SftpSidePanelInner, sidePanelAreEqual);
|
||||
SftpSidePanel.displayName = "SftpSidePanel";
|
||||
@@ -14,7 +14,7 @@
|
||||
* - components/sftp/SftpHostPicker.tsx - Host selection dialog
|
||||
*/
|
||||
|
||||
import React, { memo, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import React, { memo, useCallback, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useIsSftpActive } from "../application/state/activeTabStore";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
@@ -37,6 +37,8 @@ import { Loader2 } from "lucide-react";
|
||||
import { SftpContextProvider, activeTabStore } from "./sftp";
|
||||
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
|
||||
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
|
||||
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
|
||||
import { sftpFocusStore, SftpFocusedSide, useSftpFocusedSide } from "./sftp/hooks/useSftpFocusedPane";
|
||||
|
||||
// Wrapper component that subscribes to activeTabId for CSS visibility
|
||||
// This isolates the activeTabId subscription - only this component re-renders on tab switch
|
||||
@@ -46,12 +48,22 @@ interface SftpViewProps {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
}
|
||||
|
||||
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) => {
|
||||
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updateHosts }) => {
|
||||
const { t } = useI18n();
|
||||
const isActive = useIsSftpActive();
|
||||
const { sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles } = useSettingsState();
|
||||
const {
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
} = useSettingsState();
|
||||
|
||||
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
|
||||
const fileWatchHandlers = useMemo(() => ({
|
||||
@@ -66,7 +78,13 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
},
|
||||
}), [t]);
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, fileWatchHandlers);
|
||||
const sftpOptions = useMemo(() => ({
|
||||
...fileWatchHandlers,
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
defaultShowHiddenFiles: sftpShowHiddenFiles,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
|
||||
|
||||
// Get stream transfer functions for optimized downloads
|
||||
const { showSaveDialog, startStreamTransfer } = useSftpBackend();
|
||||
@@ -84,6 +102,30 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
const autoSyncRef = useRef(sftpAutoSync);
|
||||
autoSyncRef.current = sftpAutoSync;
|
||||
|
||||
// SFTP keyboard shortcuts handler
|
||||
useSftpKeyboardShortcuts({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
sftpRef,
|
||||
isActive,
|
||||
});
|
||||
|
||||
// Subscribe to focused side for visual indicator
|
||||
const focusedSide = useSftpFocusedSide();
|
||||
|
||||
// Handle pane focus when clicking on a pane container
|
||||
const handlePaneFocus = useCallback((side: SftpFocusedSide) => {
|
||||
sftpFocusStore.setFocusedSide(side);
|
||||
}, []);
|
||||
|
||||
const handleToggleHiddenFiles = useCallback((side: "left" | "right", paneId: string) => {
|
||||
const sideTabs = side === "left" ? sftpRef.current.leftTabs : sftpRef.current.rightTabs;
|
||||
const pane = sideTabs.tabs.find((tab) => tab.id === paneId);
|
||||
if (!pane) return;
|
||||
|
||||
sftpRef.current.setShowHiddenFiles(side, paneId, !pane.showHiddenFiles);
|
||||
}, []);
|
||||
|
||||
// Sync activeTabId to external store (allows child components to subscribe without parent re-render)
|
||||
// Using useLayoutEffect to sync before paint
|
||||
useLayoutEffect(() => {
|
||||
@@ -140,7 +182,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
});
|
||||
|
||||
const visibleTransfers = useMemo(
|
||||
() => sftp.transfers.slice(-5),
|
||||
() => [...sftp.transfers].reverse().slice(0, 5),
|
||||
[sftp.transfers],
|
||||
);
|
||||
|
||||
@@ -186,11 +228,11 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
return (
|
||||
<SftpContextProvider
|
||||
hosts={hosts}
|
||||
updateHosts={updateHosts}
|
||||
draggedFiles={draggedFiles}
|
||||
dragCallbacks={dragCallbacks}
|
||||
leftCallbacks={leftCallbacks}
|
||||
rightCallbacks={rightCallbacks}
|
||||
showHiddenFiles={sftpShowHiddenFiles}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -200,7 +242,23 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
style={containerStyle}
|
||||
>
|
||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 min-h-0 border-t border-border/70">
|
||||
<div className="relative border-r border-border/70 flex flex-col">
|
||||
<div
|
||||
className="relative border-r border-border/70 flex flex-col"
|
||||
onClick={() => handlePaneFocus("left")}
|
||||
>
|
||||
{/* Focus indicator triangle */}
|
||||
{focusedSide === "left" && (
|
||||
<div
|
||||
className="absolute top-0 left-0 z-50 pointer-events-none"
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderStyle: 'solid',
|
||||
borderWidth: '12px 12px 0 0',
|
||||
borderColor: 'hsl(var(--primary)) transparent transparent transparent',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Left side tab bar - only show when there are tabs */}
|
||||
{leftTabsInfo.length > 0 && (
|
||||
<SftpTabBar
|
||||
@@ -226,6 +284,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
pane={pane}
|
||||
showHeader
|
||||
showEmptyHeader={false}
|
||||
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("left", pane.id)}
|
||||
/>
|
||||
</SftpPaneWrapper>
|
||||
))}
|
||||
@@ -240,7 +299,23 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex flex-col">
|
||||
<div
|
||||
className="relative flex flex-col"
|
||||
onClick={() => handlePaneFocus("right")}
|
||||
>
|
||||
{/* Focus indicator triangle */}
|
||||
{focusedSide === "right" && (
|
||||
<div
|
||||
className="absolute top-0 left-0 z-50 pointer-events-none"
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderStyle: 'solid',
|
||||
borderWidth: '12px 12px 0 0',
|
||||
borderColor: 'hsl(var(--primary)) transparent transparent transparent',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Right side tab bar - only show when there are tabs */}
|
||||
{rightTabsInfo.length > 0 && (
|
||||
<SftpTabBar
|
||||
@@ -266,6 +341,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
pane={pane}
|
||||
showHeader
|
||||
showEmptyHeader={false}
|
||||
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("right", pane.id)}
|
||||
/>
|
||||
</SftpPaneWrapper>
|
||||
))}
|
||||
@@ -305,6 +381,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
textEditorContent={textEditorContent}
|
||||
setTextEditorContent={setTextEditorContent}
|
||||
handleSaveTextFile={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
showFileOpenerDialog={showFileOpenerDialog}
|
||||
setShowFileOpenerDialog={setShowFileOpenerDialog}
|
||||
fileOpenerTarget={fileOpenerTarget}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Check, ChevronDown, Clock, Copy, Edit2, FileCode, FolderPlus, LayoutGrid, List as ListIcon, Loader2, Package, Play, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Check, ChevronDown, Clock, Copy, Edit2, FileCode, FolderPlus, Keyboard, LayoutGrid, List as ListIcon, Loader2, Package, Play, Plus, RotateCcw, Search, Trash2 } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useStoredViewMode } from '../application/state/useStoredViewMode';
|
||||
import { STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE } from '../infrastructure/config/storageKeys';
|
||||
import { cn } from '../lib/utils';
|
||||
import { cn, isMacPlatform } from '../lib/utils';
|
||||
import { Host, ShellHistoryEntry, Snippet, SSHKey } from '../types';
|
||||
import { ManagedSource } from '../domain/models';
|
||||
import { HotkeyScheme, KeyBinding, keyEventToString, ManagedSource, matchesKeyBinding, parseKeyCombo } from '../domain/models';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import SelectHostPanel from './SelectHostPanel';
|
||||
import { AsidePanel, AsidePanelContent } from './ui/aside-panel';
|
||||
@@ -25,6 +25,8 @@ interface SnippetsManagerProps {
|
||||
hosts: Host[];
|
||||
customGroups?: string[];
|
||||
shellHistory: ShellHistoryEntry[];
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
onSave: (snippet: Snippet) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onPackagesChange: (packages: string[]) => void;
|
||||
@@ -46,6 +48,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
hosts,
|
||||
customGroups = [],
|
||||
shellHistory,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
onSave,
|
||||
onDelete,
|
||||
onPackagesChange,
|
||||
@@ -89,6 +93,187 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
const historyScrollRef = useRef<HTMLDivElement>(null);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
|
||||
// Shortkey recording state
|
||||
const [isRecordingShortkey, setIsRecordingShortkey] = useState(false);
|
||||
const [shortkeyError, setShortkeyError] = useState<string | null>(null);
|
||||
|
||||
const existingShortkeys = useMemo(() => (
|
||||
snippets.filter(s => Boolean(s.shortkey) && s.id !== editingSnippet.id)
|
||||
), [snippets, editingSnippet.id]);
|
||||
|
||||
const isMac = useMemo(() => (
|
||||
hotkeyScheme === 'mac' || (hotkeyScheme === 'disabled' && isMacPlatform())
|
||||
), [hotkeyScheme]);
|
||||
|
||||
const activeSystemBindings = useMemo(() => {
|
||||
return keyBindings.flatMap((binding) => {
|
||||
const entries: { binding: string; isMac: boolean }[] = [];
|
||||
const macBinding = binding.mac;
|
||||
const pcBinding = binding.pc;
|
||||
|
||||
if (hotkeyScheme === 'mac') {
|
||||
if (macBinding && macBinding !== 'Disabled') {
|
||||
entries.push({ binding: macBinding, isMac: true });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (hotkeyScheme === 'pc') {
|
||||
if (pcBinding && pcBinding !== 'Disabled') {
|
||||
entries.push({ binding: pcBinding, isMac: false });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (macBinding && macBinding !== 'Disabled') {
|
||||
entries.push({ binding: macBinding, isMac: true });
|
||||
}
|
||||
if (pcBinding && pcBinding !== 'Disabled') {
|
||||
entries.push({ binding: pcBinding, isMac: false });
|
||||
}
|
||||
return entries;
|
||||
});
|
||||
}, [hotkeyScheme, keyBindings]);
|
||||
|
||||
const buildKeyEventFromString = useCallback((keyString: string) => {
|
||||
const parsed = parseKeyCombo(keyString);
|
||||
if (!parsed) return null;
|
||||
|
||||
const modifiers = new Set(parsed.modifiers);
|
||||
const key = parsed.key;
|
||||
const normalizedKey = (() => {
|
||||
switch (key) {
|
||||
case 'Space':
|
||||
return ' ';
|
||||
case '↑':
|
||||
return 'ArrowUp';
|
||||
case '↓':
|
||||
return 'ArrowDown';
|
||||
case '←':
|
||||
return 'ArrowLeft';
|
||||
case '→':
|
||||
return 'ArrowRight';
|
||||
case 'Esc':
|
||||
return 'Escape';
|
||||
case '⌫':
|
||||
return 'Backspace';
|
||||
case 'Del':
|
||||
return 'Delete';
|
||||
case '↵':
|
||||
return 'Enter';
|
||||
case '⇥':
|
||||
return 'Tab';
|
||||
default:
|
||||
return key.length === 1 ? key.toLowerCase() : key;
|
||||
}
|
||||
})();
|
||||
|
||||
return new KeyboardEvent('keydown', {
|
||||
key: normalizedKey,
|
||||
metaKey: modifiers.has('⌘') || modifiers.has('Win'),
|
||||
ctrlKey: modifiers.has('⌃') || modifiers.has('Ctrl'),
|
||||
altKey: modifiers.has('⌥') || modifiers.has('Alt'),
|
||||
shiftKey: modifiers.has('Shift'),
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Validate shortkey for conflicts (case-insensitive comparison)
|
||||
const normalizeKeyString = useCallback((value: string) => (
|
||||
value.toLowerCase().replace(/\s+/g, '')
|
||||
), []);
|
||||
|
||||
const validateShortkey = useCallback((key: string): string | null => {
|
||||
if (!key) return null;
|
||||
|
||||
const syntheticEvent = buildKeyEventFromString(key);
|
||||
if (syntheticEvent) {
|
||||
const conflictsSystem = activeSystemBindings.some(({ binding, isMac: bindingIsMac }) => (
|
||||
matchesKeyBinding(syntheticEvent, binding, bindingIsMac)
|
||||
));
|
||||
if (conflictsSystem) {
|
||||
return t('snippets.shortkey.error.systemConflict');
|
||||
}
|
||||
}
|
||||
|
||||
// Check other snippet shortcuts
|
||||
if (syntheticEvent) {
|
||||
for (const snippet of existingShortkeys) {
|
||||
if (snippet.shortkey && matchesKeyBinding(syntheticEvent, snippet.shortkey, isMac)) {
|
||||
return t('snippets.shortkey.error.snippetConflict', { name: snippet.label });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const normalizedKey = normalizeKeyString(key);
|
||||
const conflictingSnippet = existingShortkeys.find(snippet => (
|
||||
snippet.shortkey && normalizeKeyString(snippet.shortkey) === normalizedKey
|
||||
));
|
||||
if (conflictingSnippet) {
|
||||
return t('snippets.shortkey.error.snippetConflict', { name: conflictingSnippet.label });
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [
|
||||
activeSystemBindings,
|
||||
buildKeyEventFromString,
|
||||
existingShortkeys,
|
||||
isMac,
|
||||
normalizeKeyString,
|
||||
t,
|
||||
]);
|
||||
|
||||
// Handle shortkey recording
|
||||
useEffect(() => {
|
||||
if (!isRecordingShortkey) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Escape cancels recording
|
||||
if (e.key === 'Escape') {
|
||||
setIsRecordingShortkey(false);
|
||||
setShortkeyError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip pure modifier keys
|
||||
if (['Meta', 'Control', 'Alt', 'Shift'].includes(e.key)) return;
|
||||
|
||||
const keyString = keyEventToString(e, isMac);
|
||||
|
||||
// Validate the new shortkey
|
||||
const error = validateShortkey(keyString);
|
||||
if (error) {
|
||||
setShortkeyError(error);
|
||||
// Don't stop recording, let user try again
|
||||
return;
|
||||
}
|
||||
|
||||
setShortkeyError(null);
|
||||
setEditingSnippet(prev => ({ ...prev, shortkey: keyString }));
|
||||
setIsRecordingShortkey(false);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
setIsRecordingShortkey(false);
|
||||
setShortkeyError(null);
|
||||
};
|
||||
|
||||
// Delay adding click handler by 100ms to prevent the button click that
|
||||
// initiated recording from immediately triggering the click handler
|
||||
const timer = setTimeout(() => {
|
||||
window.addEventListener('click', handleClick, true);
|
||||
}, 100);
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener('keydown', handleKeyDown, true);
|
||||
window.removeEventListener('click', handleClick, true);
|
||||
};
|
||||
}, [isRecordingShortkey, isMac, validateShortkey]);
|
||||
|
||||
const handleEdit = (snippet?: Snippet) => {
|
||||
if (snippet) {
|
||||
setEditingSnippet(snippet);
|
||||
@@ -114,6 +299,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
tags: editingSnippet.tags || [],
|
||||
package: editingSnippet.package || '',
|
||||
targets: targetSelection,
|
||||
shortkey: editingSnippet.shortkey,
|
||||
});
|
||||
setRightPanelMode('none');
|
||||
}
|
||||
@@ -606,6 +792,50 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Shortkey */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold text-muted-foreground">{t('snippets.field.shortkey')}</p>
|
||||
{editingSnippet.shortkey && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => {
|
||||
setEditingSnippet(prev => ({ ...prev, shortkey: undefined }));
|
||||
setShortkeyError(null);
|
||||
}}
|
||||
title={t('snippets.shortkey.clear')}
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRecordingShortkey(true);
|
||||
setShortkeyError(null);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full h-10 px-3 text-sm font-mono rounded-lg border transition-colors flex items-center justify-center gap-2",
|
||||
isRecordingShortkey
|
||||
? "border-primary bg-primary/10 animate-pulse"
|
||||
: "border-border hover:border-primary/50 bg-background"
|
||||
)}
|
||||
>
|
||||
<Keyboard size={14} className="text-muted-foreground" />
|
||||
{isRecordingShortkey
|
||||
? t('snippets.shortkey.recording')
|
||||
: editingSnippet.shortkey || t('snippets.shortkey.placeholder')}
|
||||
</button>
|
||||
{shortkeyError && (
|
||||
<p className="text-xs text-destructive">{shortkeyError}</p>
|
||||
)}
|
||||
<p className="text-[11px] text-muted-foreground">{t('snippets.shortkey.hint')}</p>
|
||||
</Card>
|
||||
|
||||
{/* Targets */}
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -895,6 +1125,11 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
{snippet.command.replace(/\s+/g, ' ') || t('snippets.commandFallback')}
|
||||
</div>
|
||||
</div>
|
||||
{snippet.shortkey && (
|
||||
<div className="shrink-0 px-2 py-1 text-[10px] font-mono rounded border border-border bg-muted/50 text-muted-foreground">
|
||||
{snippet.shortkey}
|
||||
</div>
|
||||
)}
|
||||
{viewMode === 'list' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
Server,
|
||||
} from 'lucide-react';
|
||||
import { useCloudSync } from '../application/state/useCloudSync';
|
||||
import type { CloudProvider } from '../domain/sync';
|
||||
import { isProviderReadyForSync, type CloudProvider } from '../domain/sync';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
@@ -122,12 +122,11 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
|
||||
// Get connected provider (include syncing status as it's still connected)
|
||||
const getConnectedProvider = (): CloudProvider | null => {
|
||||
const isProviderActive = (status: string) => status === 'connected' || status === 'syncing';
|
||||
if (isProviderActive(sync.providers.github.status)) return 'github';
|
||||
if (isProviderActive(sync.providers.google.status)) return 'google';
|
||||
if (isProviderActive(sync.providers.onedrive.status)) return 'onedrive';
|
||||
if (isProviderActive(sync.providers.webdav.status)) return 'webdav';
|
||||
if (isProviderActive(sync.providers.s3.status)) return 's3';
|
||||
if (isProviderReadyForSync(sync.providers.github)) return 'github';
|
||||
if (isProviderReadyForSync(sync.providers.google)) return 'google';
|
||||
if (isProviderReadyForSync(sync.providers.onedrive)) return 'onedrive';
|
||||
if (isProviderReadyForSync(sync.providers.webdav)) return 'webdav';
|
||||
if (isProviderReadyForSync(sync.providers.s3)) return 's3';
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -136,9 +135,9 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
|
||||
// Determine overall status for the button indicator
|
||||
const getOverallStatus = (): StatusIndicatorProps['status'] => {
|
||||
if (sync.isSyncing) return 'syncing';
|
||||
if (sync.lastError) return 'error';
|
||||
if (sync.hasAnyConnectedProvider) return 'synced';
|
||||
if (sync.overallSyncStatus === 'syncing') return 'syncing';
|
||||
if (sync.overallSyncStatus === 'error' || sync.overallSyncStatus === 'conflict') return 'error';
|
||||
if (sync.overallSyncStatus === 'synced') return 'synced';
|
||||
return 'none';
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SearchAddon } from "@xterm/addon-search";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
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";
|
||||
// flushSync removed - no longer needed
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -21,18 +21,24 @@ import {
|
||||
TerminalSettings,
|
||||
KeyBinding,
|
||||
} from "../types";
|
||||
import {
|
||||
shouldEnableNativeUserInputAutoScroll,
|
||||
shouldScrollOnTerminalInput,
|
||||
} from "../domain/terminalScroll";
|
||||
import { resolveHostAuth } from "../domain/sshAuth";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
|
||||
import SFTPModal from "./SFTPModal";
|
||||
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
|
||||
import { Button } from "./ui/button";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
|
||||
import { toast } from "./ui/toast";
|
||||
import { useAvailableFonts } from "../application/state/fontStore";
|
||||
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
|
||||
import { useCustomThemes } from "../application/state/customThemeStore";
|
||||
|
||||
import { TerminalConnectionDialog } from "./terminal/TerminalConnectionDialog";
|
||||
import { TerminalToolbar } from "./terminal/TerminalToolbar";
|
||||
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
|
||||
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
|
||||
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
|
||||
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
@@ -113,9 +119,6 @@ interface TerminalProps {
|
||||
sessionId: string;
|
||||
startupCommand?: string;
|
||||
serialConfig?: SerialConfig;
|
||||
onUpdateTerminalThemeId?: (themeId: string) => void;
|
||||
onUpdateTerminalFontFamilyId?: (fontFamilyId: string) => void;
|
||||
onUpdateTerminalFontSize?: (fontSize: number) => void;
|
||||
hotkeyScheme?: "disabled" | "mac" | "pc";
|
||||
keyBindings?: KeyBinding[];
|
||||
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
|
||||
@@ -135,8 +138,18 @@ interface TerminalProps {
|
||||
) => void;
|
||||
onSplitHorizontal?: () => void;
|
||||
onSplitVertical?: () => void;
|
||||
onOpenSftp?: (
|
||||
host: Host,
|
||||
initialPath?: string,
|
||||
pendingUploadEntries?: DropEntry[],
|
||||
sourceSessionId?: string,
|
||||
) => void;
|
||||
onOpenScripts?: () => void;
|
||||
onOpenTheme?: () => void;
|
||||
isBroadcastEnabled?: boolean;
|
||||
onToggleBroadcast?: () => void;
|
||||
onToggleComposeBar?: () => void;
|
||||
isWorkspaceComposeBarOpen?: boolean;
|
||||
onBroadcastInput?: (data: string, sourceSessionId: string) => void;
|
||||
}
|
||||
|
||||
@@ -172,9 +185,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
sessionId,
|
||||
startupCommand,
|
||||
serialConfig,
|
||||
onUpdateTerminalThemeId,
|
||||
onUpdateTerminalFontFamilyId,
|
||||
onUpdateTerminalFontSize,
|
||||
hotkeyScheme = "disabled",
|
||||
keyBindings = [],
|
||||
onHotkeyAction,
|
||||
@@ -189,8 +199,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onCommandExecuted,
|
||||
onSplitHorizontal,
|
||||
onSplitVertical,
|
||||
onOpenSftp,
|
||||
onOpenScripts,
|
||||
onOpenTheme,
|
||||
isBroadcastEnabled,
|
||||
onToggleBroadcast,
|
||||
onToggleComposeBar,
|
||||
isWorkspaceComposeBarOpen,
|
||||
onBroadcastInput,
|
||||
}) => {
|
||||
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
|
||||
@@ -209,19 +224,45 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const hasConnectedRef = useRef(false);
|
||||
const hasRunStartupCommandRef = useRef(false);
|
||||
const commandBufferRef = useRef<string>("");
|
||||
const [hasMouseTracking, setHasMouseTracking] = useState(false);
|
||||
const mouseTrackingRef = useRef(false);
|
||||
const serialLineBufferRef = useRef<string>("");
|
||||
|
||||
const terminalSettingsRef = useRef(terminalSettings);
|
||||
terminalSettingsRef.current = terminalSettings;
|
||||
const isVisibleRef = useRef(isVisible);
|
||||
isVisibleRef.current = isVisible;
|
||||
const pendingOutputScrollRef = useRef(false);
|
||||
const lastFittedSizeRef = useRef<{ width: number; height: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (xtermRuntimeRef.current) {
|
||||
xtermRuntimeRef.current.keywordHighlighter.setRules(
|
||||
terminalSettings?.keywordHighlightRules ?? [],
|
||||
terminalSettings?.keywordHighlightEnabled ?? false
|
||||
);
|
||||
// Merge global rules with host-level rules
|
||||
// Host-level rules are appended to global rules, allowing hosts to add custom highlighting
|
||||
const globalRules = terminalSettings?.keywordHighlightRules ?? [];
|
||||
const hostRules = host?.keywordHighlightRules ?? [];
|
||||
|
||||
// Check if highlighting is enabled at either global or host level
|
||||
const globalEnabled = terminalSettings?.keywordHighlightEnabled ?? false;
|
||||
const hostEnabled = host?.keywordHighlightEnabled ?? false;
|
||||
|
||||
// Merge rules: include only rules from enabled sources
|
||||
const mergedRules = [
|
||||
...(globalEnabled ? globalRules : []),
|
||||
...(hostEnabled ? hostRules : [])
|
||||
];
|
||||
|
||||
// Enable highlighting if either global or host-level is enabled
|
||||
const isEnabled = globalEnabled || hostEnabled;
|
||||
|
||||
xtermRuntimeRef.current.keywordHighlighter.setRules(mergedRules, isEnabled);
|
||||
}
|
||||
}, [terminalSettings?.keywordHighlightEnabled, terminalSettings?.keywordHighlightRules]);
|
||||
}, [
|
||||
terminalSettings?.keywordHighlightEnabled,
|
||||
terminalSettings?.keywordHighlightRules,
|
||||
host?.keywordHighlightEnabled,
|
||||
host?.keywordHighlightRules
|
||||
]);
|
||||
|
||||
const hotkeySchemeRef = useRef(hotkeyScheme);
|
||||
const keyBindingsRef = useRef(keyBindings);
|
||||
@@ -235,12 +276,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isBroadcastEnabledRef.current = isBroadcastEnabled;
|
||||
onBroadcastInputRef.current = onBroadcastInput;
|
||||
|
||||
// Snippets ref for shortkey support in terminal
|
||||
const snippetsRef = useRef(snippets);
|
||||
snippetsRef.current = snippets;
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const { resizeSession } = terminalBackend;
|
||||
const { resizeSession, setSessionEncoding } = terminalBackend;
|
||||
|
||||
|
||||
|
||||
const [isScriptsOpen, setIsScriptsOpen] = useState(false);
|
||||
// isScriptsOpen state removed - scripts now handled by side panel
|
||||
const [status, setStatus] = useState<TerminalSession["status"]>("connecting");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const lastToastedErrorRef = useRef<string | null>(null);
|
||||
@@ -249,7 +294,6 @@ 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);
|
||||
|
||||
@@ -265,7 +309,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Drag and drop state
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const [pendingUploadEntries, setPendingUploadEntries] = useState<DropEntry[]>([]);
|
||||
// pendingUploadEntries removed - drag-drop uploads now handled by SftpSidePanel
|
||||
const [isComposeBarOpen, setIsComposeBarOpen] = useState(false);
|
||||
const [terminalEncoding, setTerminalEncoding] = useState<'utf-8' | 'gb18030'>(() => {
|
||||
if (host?.charset && /^gb/i.test(String(host.charset).trim())) return 'gb18030';
|
||||
return 'utf-8';
|
||||
});
|
||||
const terminalEncodingRef = useRef(terminalEncoding);
|
||||
terminalEncodingRef.current = terminalEncoding;
|
||||
|
||||
const terminalSearch = useTerminalSearch({ searchAddonRef, termRef });
|
||||
const {
|
||||
@@ -320,13 +371,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [pendingHostKeyInfo, setPendingHostKeyInfo] = useState<HostKeyInfo | null>(null);
|
||||
const pendingConnectionRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
const effectiveTheme = useMemo(() => {
|
||||
if (host.theme) {
|
||||
const hostTheme = TERMINAL_THEMES.find((t) => t.id === host.theme);
|
||||
const hostTheme = TERMINAL_THEMES.find((t) => t.id === host.theme)
|
||||
|| customThemes.find((t) => t.id === host.theme);
|
||||
if (hostTheme) return hostTheme;
|
||||
}
|
||||
return terminalTheme;
|
||||
}, [host.theme, terminalTheme]);
|
||||
}, [host.theme, terminalTheme, customThemes]);
|
||||
|
||||
const resolvedChainHosts =
|
||||
(host.hostChain?.hostIds
|
||||
@@ -373,8 +428,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
sessionId,
|
||||
startupCommand,
|
||||
terminalSettings,
|
||||
terminalSettingsRef,
|
||||
terminalBackend,
|
||||
serialConfig,
|
||||
isVisibleRef,
|
||||
pendingOutputScrollRef,
|
||||
sessionRef,
|
||||
hasConnectedRef,
|
||||
hasRunStartupCommandRef,
|
||||
@@ -392,6 +450,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setProgressLogs,
|
||||
setProgressValue,
|
||||
setChainProgress,
|
||||
t,
|
||||
onSessionAttached: (id: string) => {
|
||||
// Sync terminal encoding to SSH backend before first data arrives
|
||||
const isSSH = host.protocol !== 'local' && host.protocol !== 'serial' && host.protocol !== 'telnet' && host.protocol !== 'mosh' && !host.moshEnabled && !host.id?.startsWith('local-') && !host.id?.startsWith('serial-') && host.hostname !== 'localhost';
|
||||
if (isSSH) {
|
||||
setSessionEncoding(id, terminalEncodingRef.current);
|
||||
}
|
||||
},
|
||||
onSessionExit,
|
||||
onTerminalDataCapture,
|
||||
onOsDetected,
|
||||
@@ -403,6 +469,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
let disposed = false;
|
||||
setError(null);
|
||||
hasConnectedRef.current = false;
|
||||
pendingOutputScrollRef.current = false;
|
||||
setProgressLogs([]);
|
||||
setShowLogs(false);
|
||||
setIsCancelling(false);
|
||||
@@ -425,6 +492,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onHotkeyActionRef,
|
||||
isBroadcastEnabledRef,
|
||||
onBroadcastInputRef,
|
||||
snippetsRef,
|
||||
sessionId,
|
||||
statusRef,
|
||||
onCommandExecuted,
|
||||
@@ -442,6 +510,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
serializeAddonRef.current = runtime.serializeAddon;
|
||||
searchAddonRef.current = runtime.searchAddon;
|
||||
|
||||
// Apply merged keyword highlight rules immediately after runtime creation
|
||||
// This fixes a timing issue where the useEffect for keyword highlighting
|
||||
// runs before the runtime is created, causing host-level rules to be missed
|
||||
const globalRules = terminalSettingsRef.current?.keywordHighlightRules ?? [];
|
||||
const hostRules = host?.keywordHighlightRules ?? [];
|
||||
const globalEnabled = terminalSettingsRef.current?.keywordHighlightEnabled ?? false;
|
||||
const hostEnabled = host?.keywordHighlightEnabled ?? false;
|
||||
const mergedRules = [
|
||||
...(globalEnabled ? globalRules : []),
|
||||
...(hostEnabled ? hostRules : [])
|
||||
];
|
||||
const isEnabled = globalEnabled || hostEnabled;
|
||||
runtime.keywordHighlighter.setRules(mergedRules, isEnabled);
|
||||
|
||||
const term = runtime.term;
|
||||
|
||||
if (host.protocol === "serial") {
|
||||
@@ -565,12 +647,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- updateStatus is a stable internal helper
|
||||
}, [status, auth.needsAuth, host.protocol, host.hostname]);
|
||||
|
||||
const safeFit = () => {
|
||||
const safeFit = (options?: { force?: boolean; requireVisible?: boolean }) => {
|
||||
const fitAddon = fitAddonRef.current;
|
||||
if (!fitAddon) return;
|
||||
if (options?.requireVisible && !isVisibleRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
if (width <= 0 || height <= 0) {
|
||||
// Terminal is hidden — invalidate the cached size so that when it
|
||||
// becomes visible again, a non-forced fit won't be suppressed by a
|
||||
// stale size match (e.g. after font metrics changed while hidden).
|
||||
lastFittedSizeRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options?.force) {
|
||||
const lastSize = lastFittedSizeRef.current;
|
||||
if (lastSize && lastSize.width === width && lastSize.height === height) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const runFit = () => {
|
||||
try {
|
||||
lastFittedSizeRef.current = { width, height };
|
||||
fitAddon.fit();
|
||||
} catch (err) {
|
||||
logger.warn("Fit failed", err);
|
||||
@@ -637,12 +741,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalSettings.drawBoldInBrightColors;
|
||||
termRef.current.options.minimumContrastRatio =
|
||||
terminalSettings.minimumContrastRatio;
|
||||
termRef.current.options.scrollOnUserInput = terminalSettings.scrollOnInput;
|
||||
termRef.current.options.scrollOnUserInput =
|
||||
shouldEnableNativeUserInputAutoScroll(terminalSettings);
|
||||
termRef.current.options.altClickMovesCursor = !terminalSettings.altAsMeta;
|
||||
termRef.current.options.wordSeparator = terminalSettings.wordSeparators;
|
||||
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
|
||||
}
|
||||
|
||||
setTimeout(() => safeFit(), 50);
|
||||
setTimeout(() => safeFit({ force: true }), 50);
|
||||
}
|
||||
}, [fontSize, effectiveTheme, terminalSettings, host.fontSize]);
|
||||
|
||||
@@ -660,15 +766,25 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
selectionBackground: effectiveTheme.colors.selection,
|
||||
};
|
||||
|
||||
setTimeout(() => safeFit(), 50);
|
||||
setTimeout(() => safeFit({ force: true }), 50);
|
||||
}
|
||||
}, [host.fontSize, host.fontFamily, host.theme, fontFamilyId, fontSize, effectiveTheme, availableFonts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && fitAddonRef.current) {
|
||||
const timer = setTimeout(() => safeFit(), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
if (!isVisible) return;
|
||||
const timer = setTimeout(() => {
|
||||
safeFit({ requireVisible: true });
|
||||
if (pendingOutputScrollRef.current) {
|
||||
termRef.current?.scrollToBottom();
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => {
|
||||
termRef.current?.scrollToBottom();
|
||||
});
|
||||
}
|
||||
pendingOutputScrollRef.current = false;
|
||||
}
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -739,17 +855,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, [host.id, host.fontFamily, host.fontSize, fontFamilyId, fontSize, resizeSession, sessionId, terminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !fitAddonRef.current) return;
|
||||
if (!isVisible || !containerRef.current || !fitAddonRef.current) return;
|
||||
|
||||
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (isResizing) return;
|
||||
if (isResizing || !isVisibleRef.current) return;
|
||||
if (resizeTimeout) {
|
||||
clearTimeout(resizeTimeout);
|
||||
}
|
||||
resizeTimeout = setTimeout(() => {
|
||||
safeFit();
|
||||
safeFit({ requireVisible: true });
|
||||
}, 250);
|
||||
});
|
||||
|
||||
@@ -764,7 +880,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
useEffect(() => {
|
||||
if (prevIsResizingRef.current && !isResizing && isVisible) {
|
||||
const timer = setTimeout(() => {
|
||||
safeFit();
|
||||
safeFit({ force: true, requireVisible: true });
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
@@ -774,7 +890,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
useEffect(() => {
|
||||
if (!isVisible || !fitAddonRef.current) return;
|
||||
const timer = setTimeout(() => {
|
||||
safeFit();
|
||||
safeFit({ requireVisible: true });
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [inWorkspace, isVisible]);
|
||||
@@ -814,18 +930,75 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
term.onSelectionChange(onSelectionChange);
|
||||
const disposable = term.onSelectionChange(onSelectionChange);
|
||||
return () => disposable.dispose();
|
||||
}, [terminalSettings?.copyOnSelect]);
|
||||
|
||||
// Track whether the terminal application has enabled mouse tracking
|
||||
// (e.g. tmux with `set -g mouse on`, vim with `set mouse=a`).
|
||||
// When mouse tracking is active, disable Netcatty's context menu to avoid
|
||||
// conflicting with the application's own mouse handling.
|
||||
useEffect(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
|
||||
const disposable = term.onWriteParsed(() => {
|
||||
const tracking = term.modes.mouseTrackingMode !== 'none';
|
||||
if (tracking !== mouseTrackingRef.current) {
|
||||
mouseTrackingRef.current = tracking;
|
||||
setHasMouseTracking(tracking);
|
||||
}
|
||||
});
|
||||
|
||||
// Set initial state
|
||||
const initial = term.modes.mouseTrackingMode !== 'none';
|
||||
mouseTrackingRef.current = initial;
|
||||
setHasMouseTracking(initial);
|
||||
|
||||
return () => disposable.dispose();
|
||||
}, [sessionId]);
|
||||
|
||||
// Prevent xterm.js's built-in rightClickHandler and right-button mouseup
|
||||
// from interfering with tmux/vim popup menus when mouse tracking is active.
|
||||
// - contextmenu: xterm.js calls textarea.select() which steals focus
|
||||
// - mouseup (button 2): tmux interprets the right-button release as a
|
||||
// dismiss action, closing the popup menu immediately after it appears
|
||||
// Both are intercepted at the capture phase before xterm.js's own listeners.
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const handleContextMenuCapture = (e: MouseEvent) => {
|
||||
if (mouseTrackingRef.current) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUpCapture = (e: MouseEvent) => {
|
||||
if (e.button === 2 && mouseTrackingRef.current) {
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
|
||||
el.addEventListener('contextmenu', handleContextMenuCapture, true);
|
||||
el.addEventListener('mouseup', handleMouseUpCapture, true);
|
||||
return () => {
|
||||
el.removeEventListener('contextmenu', handleContextMenuCapture, true);
|
||||
el.removeEventListener('mouseup', handleMouseUpCapture, true);
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const handler = () => {
|
||||
if (!isVisibleRef.current) return;
|
||||
if (resizeTimeout) {
|
||||
clearTimeout(resizeTimeout);
|
||||
}
|
||||
resizeTimeout = setTimeout(() => {
|
||||
safeFit();
|
||||
safeFit({ requireVisible: true });
|
||||
}, 250);
|
||||
};
|
||||
|
||||
@@ -836,48 +1009,57 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const disableBracketedPasteRef = useRef(terminalSettings?.disableBracketedPaste ?? false);
|
||||
disableBracketedPasteRef.current = terminalSettings?.disableBracketedPaste ?? false;
|
||||
|
||||
const scrollOnPasteRef = useRef(terminalSettings?.scrollOnPaste ?? true);
|
||||
scrollOnPasteRef.current = terminalSettings?.scrollOnPaste ?? true;
|
||||
|
||||
const scrollToBottomAfterProgrammaticInput = (data: string) => {
|
||||
if (termRef.current && shouldScrollOnTerminalInput(terminalSettingsRef.current, data)) {
|
||||
termRef.current.scrollToBottom();
|
||||
}
|
||||
};
|
||||
|
||||
const terminalContextActions = useTerminalContextActions({
|
||||
termRef,
|
||||
sessionRef,
|
||||
terminalBackend,
|
||||
onHasSelectionChange: setHasSelection,
|
||||
disableBracketedPasteRef,
|
||||
scrollOnPasteRef,
|
||||
});
|
||||
|
||||
const handleSnippetClick = (cmd: string) => {
|
||||
const handleSetTerminalEncoding = (encoding: 'utf-8' | 'gb18030') => {
|
||||
setTerminalEncoding(encoding);
|
||||
if (sessionRef.current) {
|
||||
terminalBackend.writeToSession(sessionRef.current, `${cmd}\r`);
|
||||
setIsScriptsOpen(false);
|
||||
termRef.current?.focus();
|
||||
return;
|
||||
setSessionEncoding(sessionRef.current, encoding);
|
||||
}
|
||||
termRef.current?.writeln("\r\n[No active SSH session]");
|
||||
};
|
||||
|
||||
const handleOpenSFTP = async () => {
|
||||
// If SFTP is already open, toggle it off
|
||||
if (onOpenSftp) {
|
||||
// Delegate to parent (TerminalLayer) for shared SFTP side panel
|
||||
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
|
||||
}
|
||||
}
|
||||
onOpenSftp(host, initialPath, undefined, sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: toggle internal SFTP state (shouldn't happen with new architecture)
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -935,6 +1117,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (!termRef.current) return;
|
||||
cleanupSession();
|
||||
auth.resetForRetry();
|
||||
hasRunStartupCommandRef.current = false;
|
||||
setStatus("connecting");
|
||||
setError(null);
|
||||
setProgressLogs(["Retrying secure channel..."]);
|
||||
@@ -991,7 +1174,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
try {
|
||||
const dropEntries = await extractDropEntries(e.dataTransfer);
|
||||
|
||||
|
||||
if (dropEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -1004,30 +1187,25 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const pathsText = paths.join(' ');
|
||||
// Write the paths to the terminal
|
||||
terminalBackend.writeToSession(sessionRef.current, pathsText);
|
||||
scrollToBottomAfterProgrammaticInput(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;
|
||||
// Remote terminal: Trigger SFTP upload via parent
|
||||
if (onOpenSftp) {
|
||||
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
|
||||
}
|
||||
} catch {
|
||||
// Silently fail and open SFTP without initial path
|
||||
}
|
||||
onOpenSftp(host, initialPath, dropEntries, sessionId);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -1038,23 +1216,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const renderControls = (opts?: { showClose?: boolean }) => (
|
||||
<TerminalToolbar
|
||||
status={status}
|
||||
snippets={snippets}
|
||||
host={host}
|
||||
defaultThemeId={terminalTheme.id}
|
||||
defaultFontFamilyId={fontFamilyId}
|
||||
defaultFontSize={fontSize}
|
||||
onUpdateTerminalThemeId={onUpdateTerminalThemeId}
|
||||
onUpdateTerminalFontFamilyId={onUpdateTerminalFontFamilyId}
|
||||
onUpdateTerminalFontSize={onUpdateTerminalFontSize}
|
||||
isScriptsOpen={isScriptsOpen}
|
||||
setIsScriptsOpen={setIsScriptsOpen}
|
||||
onOpenSFTP={handleOpenSFTP}
|
||||
onSnippetClick={handleSnippetClick}
|
||||
onOpenScripts={onOpenScripts ?? (() => {})}
|
||||
onOpenTheme={onOpenTheme ?? (() => {})}
|
||||
onUpdateHost={onUpdateHost}
|
||||
showClose={opts?.showClose}
|
||||
onClose={() => onCloseSession?.(sessionId)}
|
||||
isSearchOpen={isSearchOpen}
|
||||
onToggleSearch={handleToggleSearch}
|
||||
isComposeBarOpen={inWorkspace ? isWorkspaceComposeBarOpen : isComposeBarOpen}
|
||||
onToggleComposeBar={inWorkspace ? onToggleComposeBar : () => setIsComposeBarOpen(prev => !prev)}
|
||||
terminalEncoding={terminalEncoding}
|
||||
onSetTerminalEncoding={handleSetTerminalEncoding}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1064,8 +1238,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
: status === "connecting"
|
||||
? "bg-amber-400"
|
||||
: "bg-rose-500";
|
||||
const _isConnecting = status === "connecting";
|
||||
const _hasError = Boolean(error);
|
||||
|
||||
return (
|
||||
<TerminalContextMenu
|
||||
@@ -1073,6 +1245,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
rightClickBehavior={terminalSettings?.rightClickBehavior}
|
||||
isAlternateScreen={hasMouseTracking}
|
||||
onCopy={terminalContextActions.onCopy}
|
||||
onPaste={terminalContextActions.onPaste}
|
||||
onSelectAll={terminalContextActions.onSelectAll}
|
||||
@@ -1082,8 +1255,11 @@ 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={cn(
|
||||
"relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]",
|
||||
isComposeBarOpen && !inWorkspace && "flex-col"
|
||||
)}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -1095,13 +1271,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<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
|
||||
{isLocalConnection
|
||||
? t("terminal.dragDrop.localTitle")
|
||||
: t("terminal.dragDrop.remoteTitle")
|
||||
}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isLocalConnection
|
||||
{isLocalConnection
|
||||
? t("terminal.dragDrop.localMessage")
|
||||
: t("terminal.dragDrop.remoteMessage")
|
||||
}
|
||||
@@ -1260,6 +1436,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Swap bar */}
|
||||
{serverStats.swapTotal !== null && serverStats.swapTotal > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="font-medium text-[11px] text-muted-foreground">{t("terminal.serverStats.swap")}</div>
|
||||
<div className="w-full h-3 bg-muted rounded overflow-hidden flex">
|
||||
{serverStats.swapUsed !== null && serverStats.swapUsed > 0 && (
|
||||
<div
|
||||
className="h-full bg-rose-500"
|
||||
style={{ width: `${(serverStats.swapUsed / serverStats.swapTotal) * 100}%` }}
|
||||
title={`${t("terminal.serverStats.swapUsed")}: ${(serverStats.swapUsed / 1024).toFixed(1)}G`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<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-rose-500" />
|
||||
<span>{t("terminal.serverStats.swapUsed")}: {serverStats.swapUsed !== null ? `${(serverStats.swapUsed / 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.swapFree")}: {serverStats.swapTotal !== null && serverStats.swapUsed !== null ? `${((serverStats.swapTotal - serverStats.swapUsed) / 1024).toFixed(1)}G` : '--'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">{t("terminal.serverStats.swapTotal")}: {`${(serverStats.swapTotal / 1024).toFixed(1)}G`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Top 10 processes */}
|
||||
{serverStats.topProcesses.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
@@ -1449,7 +1653,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-full flex-1 min-w-0 transition-all duration-300 relative overflow-hidden pt-8"
|
||||
className="h-full flex-1 min-w-0 relative overflow-hidden pt-8"
|
||||
style={{ backgroundColor: effectiveTheme.colors.background }}
|
||||
>
|
||||
<div
|
||||
@@ -1478,117 +1682,67 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
{status !== "connected" && !needsHostKeyVerification && !(
|
||||
(isLocalConnection || isSerialConnection) && status === "connecting"
|
||||
) && (
|
||||
<TerminalConnectionDialog
|
||||
host={host}
|
||||
status={status}
|
||||
error={error}
|
||||
progressValue={progressValue}
|
||||
chainProgress={chainProgress}
|
||||
needsAuth={auth.needsAuth}
|
||||
showLogs={showLogs}
|
||||
_setShowLogs={setShowLogs}
|
||||
keys={keys}
|
||||
authProps={{
|
||||
authMethod: auth.authMethod,
|
||||
setAuthMethod: auth.setAuthMethod,
|
||||
authUsername: auth.authUsername,
|
||||
setAuthUsername: auth.setAuthUsername,
|
||||
authPassword: auth.authPassword,
|
||||
setAuthPassword: auth.setAuthPassword,
|
||||
authKeyId: auth.authKeyId,
|
||||
setAuthKeyId: auth.setAuthKeyId,
|
||||
authPassphrase: auth.authPassphrase,
|
||||
setAuthPassphrase: auth.setAuthPassphrase,
|
||||
showAuthPassphrase: auth.showAuthPassphrase,
|
||||
setShowAuthPassphrase: auth.setShowAuthPassphrase,
|
||||
showAuthPassword: auth.showAuthPassword,
|
||||
setShowAuthPassword: auth.setShowAuthPassword,
|
||||
authRetryMessage: auth.authRetryMessage,
|
||||
onSubmit: () => auth.submit(),
|
||||
onSubmitWithoutSave: () => auth.submit({ saveToHost: false }),
|
||||
onCancel: handleCancelConnect,
|
||||
isValid: auth.isValid,
|
||||
}}
|
||||
progressProps={{
|
||||
timeLeft,
|
||||
isCancelling,
|
||||
progressLogs,
|
||||
onCancel: handleCancelConnect,
|
||||
onRetry: handleRetry,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<TerminalConnectionDialog
|
||||
host={host}
|
||||
status={status}
|
||||
error={error}
|
||||
progressValue={progressValue}
|
||||
chainProgress={chainProgress}
|
||||
needsAuth={auth.needsAuth}
|
||||
showLogs={showLogs}
|
||||
_setShowLogs={setShowLogs}
|
||||
keys={keys}
|
||||
authProps={{
|
||||
authMethod: auth.authMethod,
|
||||
setAuthMethod: auth.setAuthMethod,
|
||||
authUsername: auth.authUsername,
|
||||
setAuthUsername: auth.setAuthUsername,
|
||||
authPassword: auth.authPassword,
|
||||
setAuthPassword: auth.setAuthPassword,
|
||||
authKeyId: auth.authKeyId,
|
||||
setAuthKeyId: auth.setAuthKeyId,
|
||||
authPassphrase: auth.authPassphrase,
|
||||
setAuthPassphrase: auth.setAuthPassphrase,
|
||||
showAuthPassphrase: auth.showAuthPassphrase,
|
||||
setShowAuthPassphrase: auth.setShowAuthPassphrase,
|
||||
showAuthPassword: auth.showAuthPassword,
|
||||
setShowAuthPassword: auth.setShowAuthPassword,
|
||||
authRetryMessage: auth.authRetryMessage,
|
||||
onSubmit: () => auth.submit(),
|
||||
onSubmitWithoutSave: () => auth.submit({ saveToHost: false }),
|
||||
onCancel: handleCancelConnect,
|
||||
isValid: auth.isValid,
|
||||
}}
|
||||
progressProps={{
|
||||
timeLeft,
|
||||
isCancelling,
|
||||
progressLogs,
|
||||
onCancel: handleCancelConnect,
|
||||
onRetry: handleRetry,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SFTPModal
|
||||
host={host}
|
||||
credentials={(() => {
|
||||
const resolvedAuth = resolveHostAuth({ host, keys, identities });
|
||||
|
||||
// Build proxy config if present
|
||||
const proxyConfig = host.proxyConfig
|
||||
? {
|
||||
type: host.proxyConfig.type,
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
username: host.proxyConfig.username,
|
||||
password: host.proxyConfig.password,
|
||||
{/* Compose Bar (solo sessions only; workspace uses TerminalLayer's global bar) */}
|
||||
{isComposeBarOpen && !inWorkspace && (
|
||||
<TerminalComposeBar
|
||||
onSend={(text) => {
|
||||
if (sessionRef.current) {
|
||||
const payload = text + '\r';
|
||||
terminalBackend.writeToSession(sessionRef.current, payload);
|
||||
scrollToBottomAfterProgrammaticInput(payload);
|
||||
onBroadcastInput?.(payload, sessionRef.current);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Build jump hosts array if host chain is configured
|
||||
let jumpHosts: NetcattyJumpHost[] | undefined;
|
||||
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
|
||||
jumpHosts = host.hostChain.hostIds
|
||||
.map((hostId) => allHosts.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 {
|
||||
username: resolvedAuth.username,
|
||||
hostname: host.hostname,
|
||||
port: host.port,
|
||||
password: resolvedAuth.password,
|
||||
privateKey: resolvedAuth.key?.privateKey,
|
||||
certificate: resolvedAuth.key?.certificate,
|
||||
passphrase: resolvedAuth.passphrase,
|
||||
publicKey: resolvedAuth.key?.publicKey,
|
||||
keyId: resolvedAuth.keyId,
|
||||
keySource: resolvedAuth.key?.source,
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
sftpSudo: host.sftpSudo,
|
||||
};
|
||||
})()}
|
||||
open={showSFTP && status === "connected"}
|
||||
onClose={() => {
|
||||
setShowSFTP(false);
|
||||
setPendingUploadEntries([]);
|
||||
}}
|
||||
initialPath={sftpInitialPath}
|
||||
initialEntriesToUpload={pendingUploadEntries}
|
||||
/>
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsComposeBarOpen(false);
|
||||
termRef.current?.focus();
|
||||
}}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
themeColors={effectiveTheme.colors}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TerminalContextMenu>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CloudUpload,
|
||||
Loader2,
|
||||
Search,
|
||||
WrapText,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import Editor, { type OnMount, loader, useMonaco } from '@monaco-editor/react';
|
||||
@@ -18,6 +19,7 @@ const monacoBasePath = import.meta.env.DEV
|
||||
loader.config({ paths: { vs: monacoBasePath } });
|
||||
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useClipboardBackend } from '../application/state/useClipboardBackend';
|
||||
import { getLanguageId, getLanguageName, getSupportedLanguages } from '../lib/sftpFileUtils';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
@@ -30,6 +32,8 @@ interface TextEditorModalProps {
|
||||
fileName: string;
|
||||
initialContent: string;
|
||||
onSave: (content: string) => Promise<void>;
|
||||
editorWordWrap: boolean;
|
||||
onToggleWordWrap: () => void;
|
||||
}
|
||||
|
||||
// Map our language IDs to Monaco language IDs
|
||||
@@ -132,8 +136,11 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
fileName,
|
||||
initialContent,
|
||||
onSave,
|
||||
editorWordWrap,
|
||||
onToggleWordWrap,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { readClipboardText: readClipboardTextFromBridge } = useClipboardBackend();
|
||||
const monaco = useMonaco();
|
||||
const [content, setContent] = useState(initialContent);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -143,6 +150,7 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
|
||||
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
|
||||
const handleSaveRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
const handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
|
||||
// Track theme from document.documentElement class (syncs with app theme)
|
||||
const [isDarkTheme, setIsDarkTheme] = useState(() =>
|
||||
@@ -229,6 +237,58 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
handleSaveRef.current = handleSave;
|
||||
}, [handleSave]);
|
||||
|
||||
const readClipboardText = useCallback(async (): Promise<string | null> => {
|
||||
try {
|
||||
if (navigator.clipboard?.readText) {
|
||||
return await navigator.clipboard.readText();
|
||||
}
|
||||
} catch {
|
||||
// Fall through to Electron bridge
|
||||
}
|
||||
|
||||
try {
|
||||
return await readClipboardTextFromBridge();
|
||||
} catch {
|
||||
// Both clipboard APIs unavailable; signal failure so caller can fall back.
|
||||
return null;
|
||||
}
|
||||
}, [readClipboardTextFromBridge]);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const text = await readClipboardText();
|
||||
if (text === null) {
|
||||
// Clipboard read unavailable; fall back to Monaco's native paste.
|
||||
editor.trigger('keyboard', 'editor.action.clipboardPasteAction', null);
|
||||
return;
|
||||
}
|
||||
if (!text) return;
|
||||
|
||||
const selections = editor.getSelections();
|
||||
if (!selections || selections.length === 0) return;
|
||||
|
||||
// Match Monaco's default multicursorPaste:'spread' behavior:
|
||||
// distribute one line per cursor when line count equals cursor count.
|
||||
const lines = text.split(/\r\n|\n/);
|
||||
const distribute = selections.length > 1 && lines.length === selections.length;
|
||||
|
||||
editor.executeEdits(
|
||||
'netcatty-paste',
|
||||
selections.map((selection, i) => ({
|
||||
range: selection,
|
||||
text: distribute ? lines[i] : text,
|
||||
forceMoveMarkers: true,
|
||||
})),
|
||||
);
|
||||
editor.focus();
|
||||
}, [readClipboardText]);
|
||||
|
||||
useEffect(() => {
|
||||
handlePasteRef.current = handlePaste;
|
||||
}, [handlePaste]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (hasChanges) {
|
||||
const confirmed = confirm(t('sftp.editor.unsavedChanges'));
|
||||
@@ -254,6 +314,11 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
// Trigger Monaco's built-in find widget
|
||||
editor.trigger('keyboard', 'actions.find', null);
|
||||
});
|
||||
|
||||
// Fallback paste path for Electron environments where Monaco paste can fail.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
|
||||
void handlePasteRef.current();
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Trigger search dialog
|
||||
@@ -299,6 +364,17 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
<Search size={14} />
|
||||
</Button>
|
||||
|
||||
{/* Word wrap toggle */}
|
||||
<Button
|
||||
variant={editorWordWrap ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onToggleWordWrap}
|
||||
title={t('sftp.editor.wordWrap')}
|
||||
>
|
||||
<WrapText size={14} />
|
||||
</Button>
|
||||
|
||||
{/* Language selector */}
|
||||
<Combobox
|
||||
options={languageOptions}
|
||||
@@ -352,6 +428,8 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
</div>
|
||||
}
|
||||
options={{
|
||||
// Prefer native context menu in Electron so right-click Paste uses OS clipboard path.
|
||||
contextmenu: false,
|
||||
minimap: { enabled: true },
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
@@ -360,7 +438,7 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
wordWrap: 'off',
|
||||
wordWrap: editorWordWrap ? 'on' : 'off',
|
||||
folding: true,
|
||||
renderWhitespace: 'selection',
|
||||
bracketPairColorization: { enabled: true },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Users } from 'lucide-react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../application/state/customThemeStore';
|
||||
import { cn } from '../lib/utils';
|
||||
import { TerminalTheme } from '../types';
|
||||
import {
|
||||
@@ -63,17 +63,12 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
|
||||
// Reserved for future hover preview feature
|
||||
const [_hoveredThemeId, setHoveredThemeId] = useState<string | null>(null);
|
||||
|
||||
// Group themes by type - reserved for future sectioned view
|
||||
const _groupedThemes = useMemo(() => {
|
||||
const dark = TERMINAL_THEMES.filter(t => t.type === 'dark');
|
||||
const light = TERMINAL_THEMES.filter(t => t.type === 'light');
|
||||
return { dark, light };
|
||||
}, []);
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
// Find selected theme info - reserved for displaying selection details
|
||||
const _selectedTheme = useMemo(() => {
|
||||
return TERMINAL_THEMES.find(t => t.id === selectedThemeId);
|
||||
}, [selectedThemeId]);
|
||||
// All themes combined
|
||||
const allThemes = useMemo(() => {
|
||||
return [...TERMINAL_THEMES, ...customThemes];
|
||||
}, [customThemes]);
|
||||
|
||||
const renderThemeItem = (theme: TerminalTheme) => {
|
||||
const isSelected = theme.id === selectedThemeId;
|
||||
@@ -99,36 +94,12 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
|
||||
)}>
|
||||
{theme.name}
|
||||
</div>
|
||||
{/* Show usage stats or badge */}
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{theme.id === 'netcatty-dark' && (
|
||||
<span className="text-muted-foreground">Default</span>
|
||||
)}
|
||||
{theme.id === 'netcatty-light' && (
|
||||
<>
|
||||
<Users size={10} />
|
||||
<span>Light mode</span>
|
||||
</>
|
||||
)}
|
||||
{theme.id === 'flexoki-dark' && (
|
||||
<span className="text-xs">new</span>
|
||||
)}
|
||||
{theme.id === 'flexoki-light' && (
|
||||
<span className="text-xs">new</span>
|
||||
)}
|
||||
{theme.id.startsWith('kanagawa') && (
|
||||
<>
|
||||
<Users size={10} />
|
||||
<span>{Math.floor(Math.random() * 20000)}</span>
|
||||
</>
|
||||
)}
|
||||
{theme.id.startsWith('hacker') && (
|
||||
<>
|
||||
<Users size={10} />
|
||||
<span>{Math.floor(Math.random() * 15000)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{theme.id === 'netcatty-dark' && (
|
||||
<div className="text-xs text-muted-foreground">Default</div>
|
||||
)}
|
||||
{theme.id === 'netcatty-light' && (
|
||||
<div className="text-xs text-muted-foreground">Light mode</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -146,7 +117,7 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
|
||||
<ScrollArea className="h-full">
|
||||
<div className="py-2">
|
||||
{/* All themes in a single list */}
|
||||
{TERMINAL_THEMES.map(renderThemeItem)}
|
||||
{allThemes.map(renderThemeItem)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</AsidePanelContent>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Bell, Copy, FileText, Folder, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Shield, Square, Sun, TerminalSquare, X } from 'lucide-react';
|
||||
import { Bell, Copy, FileText, Folder, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Shield, Sparkles, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore, useActiveTabId } from '../application/state/activeTabStore';
|
||||
import { LogView } from '../application/state/useSessionState';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { normalizeDistroId } from '../domain/host';
|
||||
import { cn } from '../lib/utils';
|
||||
import { TerminalSession, Workspace } from '../types';
|
||||
import { Host, TerminalSession, Workspace } from '../types';
|
||||
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
|
||||
import { Button } from './ui/button';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { SyncStatusButton } from './SyncStatusButton';
|
||||
@@ -16,6 +18,7 @@ const dragRegionNoSelect = { WebkitAppRegion: 'drag', userSelect: 'none' } as Re
|
||||
|
||||
interface TopTabsProps {
|
||||
theme: 'dark' | 'light';
|
||||
hosts: Host[];
|
||||
sessions: TerminalSession[];
|
||||
orphanSessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
@@ -38,6 +41,82 @@ interface TopTabsProps {
|
||||
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
|
||||
}
|
||||
|
||||
// Detect local OS for local terminal tab icons
|
||||
const localOsId = (() => {
|
||||
if (typeof navigator === 'undefined') return 'linux';
|
||||
const ua = navigator.userAgent;
|
||||
if (/Mac/i.test(ua)) return 'macos';
|
||||
if (/Win/i.test(ua)) return 'windows';
|
||||
return 'linux';
|
||||
})();
|
||||
|
||||
// Lightweight OS/distro icon for session tabs — matches DistroAvatar "sm" style
|
||||
const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; protocol?: string }> = memo(({ host, isActive, protocol }) => {
|
||||
const boxBase = "shrink-0 h-4 w-4 rounded flex items-center justify-center";
|
||||
const iconSize = "h-2.5 w-2.5";
|
||||
const fallbackIcon = cn(iconSize, isActive ? "text-accent" : "text-muted-foreground");
|
||||
|
||||
// Serial protocol → USB icon
|
||||
if (protocol === 'serial' || host?.protocol === 'serial') {
|
||||
return (
|
||||
<div className={cn(boxBase, "bg-amber-500/15 text-amber-500")}>
|
||||
<Usb className={iconSize} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Local protocol → OS-specific icon (protocol may be undefined for local sessions)
|
||||
if (protocol === 'local' || host?.protocol === 'local' || (!protocol && !host)) {
|
||||
const logo = DISTRO_LOGOS[localOsId];
|
||||
const bg = DISTRO_COLORS[localOsId] || DISTRO_COLORS.default;
|
||||
if (logo) {
|
||||
return (
|
||||
<div className={cn(boxBase, bg)}>
|
||||
<img
|
||||
src={logo}
|
||||
alt={localOsId}
|
||||
className={cn(iconSize, "object-contain invert brightness-0")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={cn(boxBase, "bg-primary/15 text-primary")}>
|
||||
<TerminalSquare className={iconSize} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Try distro logo with brand background color
|
||||
if (host) {
|
||||
const distro = normalizeDistroId(host.distro) || (host.distro || '').toLowerCase();
|
||||
const logo = DISTRO_LOGOS[distro];
|
||||
if (logo) {
|
||||
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
|
||||
return (
|
||||
<div className={cn(boxBase, bg)}>
|
||||
<img
|
||||
src={logo}
|
||||
alt={host.distro || host.os}
|
||||
className={cn(iconSize, "object-contain invert brightness-0")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: generic server icon for remote, terminal for unknown
|
||||
if (host && host.protocol !== 'local') {
|
||||
return (
|
||||
<div className={cn(boxBase, "bg-primary/15 text-primary")}>
|
||||
<Server className={iconSize} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <TerminalSquare className={fallbackIcon} />;
|
||||
});
|
||||
SessionTabIcon.displayName = 'SessionTabIcon';
|
||||
|
||||
const sessionStatusDot = (status: TerminalSession['status']) => {
|
||||
const tone = status === 'connected'
|
||||
? "bg-emerald-400"
|
||||
@@ -56,12 +135,19 @@ const WindowControls: React.FC = memo(() => {
|
||||
// Check initial maximized state
|
||||
fetchIsMaximized().then(v => setIsMaximized(!!v));
|
||||
|
||||
// Listen for window resize to update maximized state
|
||||
// Listen for window resize to update maximized state (debounced to avoid IPC storm)
|
||||
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const handleResize = () => {
|
||||
fetchIsMaximized().then(v => setIsMaximized(!!v));
|
||||
if (resizeTimer) clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
fetchIsMaximized().then(v => setIsMaximized(!!v));
|
||||
}, 200);
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (resizeTimer) clearTimeout(resizeTimer);
|
||||
};
|
||||
}, [fetchIsMaximized]);
|
||||
|
||||
const handleMinimize = () => {
|
||||
@@ -78,17 +164,17 @@ const WindowControls: React.FC = memo(() => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center app-drag">
|
||||
<div className="flex items-center app-drag h-full">
|
||||
<button
|
||||
onClick={handleMinimize}
|
||||
className="h-8 w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
|
||||
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMaximize}
|
||||
className="h-8 w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
|
||||
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
|
||||
title={isMaximized ? "Restore" : "Maximize"}
|
||||
>
|
||||
{isMaximized ? (
|
||||
@@ -101,7 +187,7 @@ const WindowControls: React.FC = memo(() => {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="h-8 w-10 flex items-center justify-center text-muted-foreground hover:bg-red-500 hover:text-white transition-all duration-150 app-no-drag"
|
||||
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-red-500 hover:text-white transition-all duration-150 app-no-drag"
|
||||
title="Close"
|
||||
>
|
||||
<X size={16} />
|
||||
@@ -113,6 +199,7 @@ WindowControls.displayName = 'WindowControls';
|
||||
|
||||
const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
theme,
|
||||
hosts,
|
||||
sessions,
|
||||
orphanSessions,
|
||||
workspaces,
|
||||
@@ -235,6 +322,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
return map;
|
||||
}, [logViews]);
|
||||
|
||||
const hostMap = useMemo(() => {
|
||||
const map = new Map<string, Host>();
|
||||
for (const h of hosts) map.set(h.id, h);
|
||||
return map;
|
||||
}, [hosts]);
|
||||
|
||||
// Pre-compute session counts per workspace for O(1) access
|
||||
const workspacePaneCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
@@ -376,26 +469,29 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onDragLeave={handleTabDragLeave}
|
||||
onDrop={(e) => handleTabDrop(e, session.id)}
|
||||
className={cn(
|
||||
"relative h-6 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-md border text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-all duration-200 ease-out",
|
||||
activeTabId === session.id ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground",
|
||||
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-all duration-150",
|
||||
activeTabId === session.id
|
||||
? "bg-background text-foreground"
|
||||
: "text-muted-foreground hover:bg-background/40 hover:text-foreground",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
|
||||
)}
|
||||
style={{
|
||||
...shiftStyle,
|
||||
...(activeTabId === session.id ? { borderColor: 'hsl(var(--accent))' } : {})
|
||||
}}
|
||||
style={shiftStyle}
|
||||
>
|
||||
{/* Active tab top accent line */}
|
||||
{activeTabId === session.id && (
|
||||
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
|
||||
)}
|
||||
{/* Drop indicator line - before */}
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
<div className="absolute -left-1.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
<div className="absolute -left-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
)}
|
||||
{/* Drop indicator line - after */}
|
||||
{showDropIndicatorAfter && isDraggingForReorder && (
|
||||
<div className="absolute -right-1.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
<div className="absolute -right-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
)}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<TerminalSquare size={14} className={cn("shrink-0", activeTabId === session.id ? "text-accent" : "text-muted-foreground")} />
|
||||
<SessionTabIcon host={hostMap.get(session.hostId)} isActive={activeTabId === session.id} protocol={session.protocol} />
|
||||
<span className="truncate">{session.hostLabel}</span>
|
||||
<div className="flex-shrink-0">{sessionStatusDot(session.status)}</div>
|
||||
</div>
|
||||
@@ -445,23 +541,26 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onDragLeave={handleTabDragLeave}
|
||||
onDrop={(e) => handleTabDrop(e, workspace.id)}
|
||||
className={cn(
|
||||
"relative h-6 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-md border text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-all duration-200 ease-out",
|
||||
isActive ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground",
|
||||
"relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-all duration-150",
|
||||
isActive
|
||||
? "bg-background text-foreground"
|
||||
: "text-muted-foreground hover:bg-background/40 hover:text-foreground",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
|
||||
)}
|
||||
style={{
|
||||
...shiftStyle,
|
||||
...(isActive ? { borderColor: 'hsl(var(--accent))' } : {})
|
||||
}}
|
||||
style={shiftStyle}
|
||||
>
|
||||
{/* Active tab top accent line */}
|
||||
{isActive && (
|
||||
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
|
||||
)}
|
||||
{/* Drop indicator line - before */}
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
<div className="absolute -left-1.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
<div className="absolute -left-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
)}
|
||||
{/* Drop indicator line - after */}
|
||||
{showDropIndicatorAfter && isDraggingForReorder && (
|
||||
<div className="absolute -right-1.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
<div className="absolute -right-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
)}
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<LayoutGrid size={14} className={cn("shrink-0", isActive ? "text-primary" : "text-muted-foreground")} />
|
||||
@@ -495,12 +594,17 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
data-tab-id={logView.id}
|
||||
onClick={() => onSelectTab(logView.id)}
|
||||
className={cn(
|
||||
"relative h-6 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-md border text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-all duration-200 ease-out",
|
||||
isActive ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground"
|
||||
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-colors duration-150",
|
||||
isActive
|
||||
? "bg-background text-foreground"
|
||||
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
|
||||
)}
|
||||
style={isActive ? { borderColor: 'hsl(var(--accent))' } : {}}
|
||||
>
|
||||
{/* Active tab top accent line */}
|
||||
{isActive && (
|
||||
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
|
||||
)}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<FileText size={14} className={cn("shrink-0", isActive ? "text-accent" : "text-muted-foreground")} />
|
||||
<span className="truncate">
|
||||
@@ -536,36 +640,41 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full bg-secondary border-b border-border/60 app-drag"
|
||||
className="relative w-full bg-secondary app-drag"
|
||||
style={dragRegionNoSelect}
|
||||
onDoubleClick={handleTitleBarDoubleClick}
|
||||
>
|
||||
{/* Always-on drag stripe so the window can be moved even when tabs fill the bar */}
|
||||
<div className="absolute inset-x-0 top-0 h-1 app-drag pointer-events-auto z-10" style={dragRegionStyle} aria-hidden />
|
||||
<div
|
||||
className="h-8 px-3 flex items-center gap-2 app-drag"
|
||||
style={{ ...dragRegionStyle, paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12 }}
|
||||
className="h-9 flex items-end gap-0 app-drag"
|
||||
style={{ ...dragRegionStyle, paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12, paddingRight: isMacClient ? 12 : 0 }}
|
||||
>
|
||||
{/* Fixed left tabs: Vaults and SFTP */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0 app-drag">
|
||||
<div className="flex items-end gap-0 flex-shrink-0 app-drag">
|
||||
<div
|
||||
onClick={() => onSelectTab('vault')}
|
||||
className={cn(
|
||||
"h-6 px-3 rounded-md border text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
isVaultActive ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground"
|
||||
"relative h-7 px-3 rounded text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
"transition-colors duration-150",
|
||||
isVaultActive
|
||||
? "bg-foreground/10 text-foreground"
|
||||
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
|
||||
)}
|
||||
style={isVaultActive ? { borderColor: 'hsl(var(--accent))' } : undefined}
|
||||
>
|
||||
<Shield size={14} /> Vaults
|
||||
</div>
|
||||
<div
|
||||
onClick={() => onSelectTab('sftp')}
|
||||
className={cn(
|
||||
"h-6 px-3 rounded-md border text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
isSftpActive ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground"
|
||||
"relative h-7 px-3 rounded-none text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
"transition-colors duration-150",
|
||||
isSftpActive
|
||||
? "bg-background text-foreground"
|
||||
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
|
||||
)}
|
||||
style={isSftpActive ? { borderColor: 'hsl(var(--accent))' } : undefined}
|
||||
>
|
||||
{isSftpActive && <div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />}
|
||||
<Folder size={14} /> SFTP
|
||||
</div>
|
||||
</div>
|
||||
@@ -594,7 +703,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{/* Scrollable container */}
|
||||
<div
|
||||
ref={tabsContainerRef}
|
||||
className="flex items-center gap-2 overflow-x-auto scrollbar-none app-drag max-w-full"
|
||||
className="flex items-end gap-0 overflow-x-auto scrollbar-none app-drag max-w-full"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{renderOrderedTabs()}
|
||||
@@ -603,7 +712,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 flex-shrink-0 app-no-drag"
|
||||
className="h-7 w-7 flex-shrink-0 app-no-drag mb-0 rounded-none"
|
||||
onClick={onOpenQuickSwitcher}
|
||||
title="Open quick switcher"
|
||||
>
|
||||
@@ -611,7 +720,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
</Button>
|
||||
)}
|
||||
{/* Draggable spacer - fixed width handle at the end */}
|
||||
<div className="min-w-[20px] h-6 app-drag flex-shrink-0" style={dragRegionStyle} />
|
||||
<div className="min-w-[20px] h-7 app-drag flex-shrink-0" style={dragRegionStyle} />
|
||||
</div>
|
||||
|
||||
{/* Right fade mask */}
|
||||
@@ -628,7 +737,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 flex-shrink-0 app-no-drag"
|
||||
className="h-7 w-7 flex-shrink-0 app-no-drag self-end rounded-none"
|
||||
onClick={onOpenQuickSwitcher}
|
||||
title="More tabs"
|
||||
>
|
||||
@@ -637,7 +746,16 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
)}
|
||||
|
||||
{/* Fixed right controls */}
|
||||
<div className="flex-shrink-0 flex items-center gap-2 app-drag" style={dragRegionStyle}>
|
||||
<div className="flex-shrink-0 flex items-center gap-2 app-drag self-center" style={dragRegionStyle}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag"
|
||||
title="AI Assistant"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('netcatty:toggle-ai-panel'))}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag">
|
||||
<Bell size={16} />
|
||||
</Button>
|
||||
@@ -653,9 +771,9 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
{/* Custom window controls for Windows/Linux */}
|
||||
{!isMacClient && <WindowControls />}
|
||||
{/* Small drag shim to the right edge */}
|
||||
<div className="w-2 h-8 app-drag flex-shrink-0" />
|
||||
{!isMacClient && <div className="self-stretch flex items-stretch"><WindowControls /></div>}
|
||||
{/* Small drag shim to the right edge (macOS only – on Windows the close button should touch the edge) */}
|
||||
{isMacClient && <div className="w-2 h-9 app-drag flex-shrink-0" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -665,6 +783,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
|
||||
return (
|
||||
prev.theme === next.theme &&
|
||||
prev.hosts === next.hosts &&
|
||||
prev.sessions === next.sessions &&
|
||||
prev.orphanSessions === next.orphanSessions &&
|
||||
prev.workspaces === next.workspaces &&
|
||||
|
||||
410
components/TrayPanel.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { useSessionState } from "../application/state/useSessionState";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import { useVaultState } from "../application/state/useVaultState";
|
||||
import { toast } from "./ui/toast";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
|
||||
import { useActiveTabId } from "../application/state/activeTabStore";
|
||||
import { X, Maximize2, ChevronRight, ChevronDown, Power } from "lucide-react";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
|
||||
const StatusDot: React.FC<{ status: "success" | "warning" | "error" | "neutral"; spinning?: boolean }> = ({
|
||||
status,
|
||||
spinning,
|
||||
}) => {
|
||||
const color =
|
||||
status === "success"
|
||||
? "bg-emerald-500"
|
||||
: status === "warning"
|
||||
? "bg-amber-500"
|
||||
: status === "error"
|
||||
? "bg-rose-500"
|
||||
: "bg-zinc-500";
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-2 w-2 rounded-full",
|
||||
color,
|
||||
spinning ? "animate-spin" : "",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Session type for workspace grouping
|
||||
type TraySession = {
|
||||
id: string;
|
||||
label: string;
|
||||
hostLabel: string;
|
||||
status: "connecting" | "connected" | "disconnected";
|
||||
workspaceId?: string;
|
||||
workspaceTitle?: string;
|
||||
};
|
||||
|
||||
// Collapsible workspace group component
|
||||
const WorkspaceGroup: React.FC<{
|
||||
workspaceId: string;
|
||||
title: string;
|
||||
sessions: TraySession[];
|
||||
activeTabId: string | null;
|
||||
jumpToSession: (sessionId: string) => Promise<void>;
|
||||
t: (key: string) => string;
|
||||
}> = ({ workspaceId, title, sessions, activeTabId, jumpToSession, t }) => {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const isAnyActive = sessions.some((s) => s.id === activeTabId) || activeTabId === workspaceId;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center gap-1",
|
||||
isAnyActive ? "bg-muted" : "",
|
||||
)}
|
||||
>
|
||||
{expanded ? <ChevronDown size={14} className="text-muted-foreground" /> : <ChevronRight size={14} className="text-muted-foreground" />}
|
||||
<span className="font-medium truncate">{title}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{sessions.length}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="ml-4 mt-0.5 space-y-0.5">
|
||||
{sessions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
title={s.hostLabel || s.label}
|
||||
onClick={() => {
|
||||
// Jump to session (using session id)
|
||||
void jumpToSession(s.id);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1 rounded hover:bg-muted flex items-center justify-between text-sm",
|
||||
s.status === "connected" ? "" : "text-muted-foreground",
|
||||
activeTabId === s.id ? "bg-muted/60" : "",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
|
||||
spinning={s.status === "connecting"}
|
||||
/>
|
||||
<span className="truncate">{s.hostLabel || s.label}</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TrayPanelContent: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
hideTrayPanel,
|
||||
openMainWindow,
|
||||
quitApp,
|
||||
jumpToSession,
|
||||
onTrayPanelCloseRequest,
|
||||
onTrayPanelRefresh,
|
||||
onTrayPanelMenuData,
|
||||
} = useTrayPanelBackend();
|
||||
|
||||
const { hosts, keys } = useVaultState();
|
||||
useSessionState();
|
||||
const { rules: portForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
|
||||
const activeTabId = useActiveTabId();
|
||||
|
||||
const [traySessions, setTraySessions] = useState<TraySession[]>([]);
|
||||
|
||||
const jumpableSessions = useMemo(
|
||||
() => traySessions.filter((s) => s.status === "connected" || s.status === "connecting"),
|
||||
[traySessions],
|
||||
);
|
||||
|
||||
const activeSession = useMemo(() => {
|
||||
if (!activeTabId) return null;
|
||||
return traySessions.find((s) => s.id === activeTabId) || null;
|
||||
}, [activeTabId, traySessions]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onTrayPanelMenuData?.((data) => {
|
||||
setTraySessions(data.sessions || []);
|
||||
});
|
||||
return () => unsubscribe?.();
|
||||
}, [onTrayPanelMenuData]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onTrayPanelRefresh?.(() => {
|
||||
try {
|
||||
window.dispatchEvent(new Event("storage"));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
return () => unsubscribe?.();
|
||||
}, [onTrayPanelRefresh]);
|
||||
|
||||
const keysForPf = useMemo(
|
||||
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase })),
|
||||
[keys],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
void hideTrayPanel();
|
||||
}, [hideTrayPanel]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [handleClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
const target = e.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (document.body && !document.body.contains(target)) return;
|
||||
// Ignore clicks on interactive elements inside the panel.
|
||||
if (target instanceof HTMLElement && target.closest("button,a,input,select,textarea,[role='button']")) {
|
||||
return;
|
||||
}
|
||||
// Clicking on background should close panel
|
||||
const root = document.getElementById("tray-panel-root");
|
||||
if (root && !root.contains(target)) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("pointerdown", onPointerDown, true);
|
||||
return () => window.removeEventListener("pointerdown", onPointerDown, true);
|
||||
}, [handleClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onTrayPanelCloseRequest(() => {
|
||||
handleClose();
|
||||
});
|
||||
return () => unsubscribe?.();
|
||||
}, [handleClose, onTrayPanelCloseRequest]);
|
||||
|
||||
const handleOpenMain = useCallback(() => {
|
||||
void openMainWindow();
|
||||
}, [openMainWindow]);
|
||||
|
||||
const handleQuit = useCallback(() => {
|
||||
void quitApp();
|
||||
}, [quitApp]);
|
||||
|
||||
return (
|
||||
<div id="tray-panel-root" className="w-full h-full bg-background/95 backdrop-blur border border-border/60 rounded-lg shadow-lg overflow-hidden flex flex-col">
|
||||
<div className="px-3 py-2 border-b border-border/60 flex items-center justify-between app-no-drag">
|
||||
<div className="flex items-center gap-2">
|
||||
<AppLogo className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">Netcatty</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
onClick={handleOpenMain}
|
||||
title={t("tray.openMainWindow")}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
onClick={handleClose}
|
||||
title="Close"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-2 space-y-3 text-sm flex-1 overflow-y-auto min-h-0">
|
||||
|
||||
{jumpableSessions.length > 0 && (() => {
|
||||
// Group sessions by workspace
|
||||
const workspaceGroups = new Map<string, { title: string; sessions: typeof jumpableSessions }>();
|
||||
const soloSessions: typeof jumpableSessions = [];
|
||||
|
||||
jumpableSessions.forEach((s) => {
|
||||
if (s.workspaceId) {
|
||||
const existing = workspaceGroups.get(s.workspaceId);
|
||||
if (existing) {
|
||||
existing.sessions.push(s);
|
||||
} else {
|
||||
workspaceGroups.set(s.workspaceId, {
|
||||
title: s.workspaceTitle || "Workspace",
|
||||
sessions: [s],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
soloSessions.push(s);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground">{t("tray.sessions")}</div>
|
||||
<div className="space-y-1">
|
||||
{/* Workspace groups */}
|
||||
{Array.from(workspaceGroups.entries()).map(([wsId, group]) => (
|
||||
<WorkspaceGroup
|
||||
key={wsId}
|
||||
workspaceId={wsId}
|
||||
title={group.title}
|
||||
sessions={group.sessions}
|
||||
activeTabId={activeTabId}
|
||||
jumpToSession={jumpToSession}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
{/* Solo sessions */}
|
||||
{soloSessions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
title={s.hostLabel || s.label}
|
||||
onClick={() => {
|
||||
void jumpToSession(s.id);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
|
||||
s.status === "connected" ? "" : "text-muted-foreground",
|
||||
activeTabId === s.id ? "bg-muted" : "",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
|
||||
spinning={s.status === "connecting"}
|
||||
/>
|
||||
<span className="truncate">{s.hostLabel || s.label}</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{activeSession && (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground">Current</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start px-2 h-8"
|
||||
title={activeSession.hostLabel || activeSession.label}
|
||||
onClick={() => {
|
||||
void jumpToSession(activeSession.id);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{activeSession.hostLabel || activeSession.label}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{portForwardingRules.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground">{t("tray.portForwarding")}</div>
|
||||
<div className="space-y-1">
|
||||
{portForwardingRules.map((rule) => {
|
||||
const isConnecting = rule.status === "connecting";
|
||||
const isActive = rule.status === "active";
|
||||
const label = rule.label || (rule.type === "dynamic"
|
||||
? `SOCKS:${rule.localPort}`
|
||||
: `${rule.localPort} → ${rule.remoteHost}:${rule.remotePort}`);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={rule.id}
|
||||
disabled={isConnecting}
|
||||
title={label}
|
||||
onClick={() => {
|
||||
const host = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
|
||||
if (!host) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
if (isActive) {
|
||||
void stopTunnel(rule.id);
|
||||
} else {
|
||||
void startTunnel(rule, host, keysForPf, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
|
||||
isConnecting ? "opacity-60" : "",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
status={
|
||||
rule.status === "active"
|
||||
? "success"
|
||||
: rule.status === "connecting"
|
||||
? "warning"
|
||||
: rule.status === "error"
|
||||
? "error"
|
||||
: "neutral"
|
||||
}
|
||||
spinning={rule.status === "connecting"}
|
||||
/>
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{t(`tray.status.${rule.status}`)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state - show when nothing is active */}
|
||||
{jumpableSessions.length === 0 && portForwardingRules.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<span className="text-2xl mb-2">😴</span>
|
||||
<span className="text-sm text-muted-foreground">{t("tray.empty.title")}</span>
|
||||
<span className="text-xs text-muted-foreground/60 mt-1">{t("tray.empty.subtitle")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quit button at the bottom */}
|
||||
<div className="px-3 py-2 border-t border-border/60">
|
||||
<button
|
||||
className="w-full text-left px-2 py-1.5 rounded hover:bg-destructive/10 flex items-center gap-2 text-sm text-muted-foreground hover:text-destructive transition-colors"
|
||||
onClick={handleQuit}
|
||||
>
|
||||
<Power size={14} />
|
||||
<span>{t("tray.quit")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TrayPanel: React.FC = () => {
|
||||
const settings = useSettingsState();
|
||||
return (
|
||||
<I18nProvider locale={settings.uiLanguage}>
|
||||
<TrayPanelContent />
|
||||
</I18nProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrayPanel;
|
||||
@@ -30,11 +30,12 @@ import {
|
||||
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
|
||||
import { sanitizeHost } from "../domain/host";
|
||||
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
|
||||
import type { VaultImportFormat } from "../domain/vaultImport";
|
||||
import { STORAGE_KEY_VAULT_HOSTS_VIEW_MODE, STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from "../infrastructure/config/storageKeys";
|
||||
import { STORAGE_KEY_VAULT_HOSTS_VIEW_MODE, STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED, STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED } from "../infrastructure/config/storageKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
ConnectionLog,
|
||||
@@ -84,7 +85,9 @@ import { Label } from "./ui/label";
|
||||
import { SortDropdown, SortMode } from "./ui/sort-dropdown";
|
||||
import { TagFilterDropdown } from "./ui/tag-filter-dropdown";
|
||||
import { toast } from "./ui/toast";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "./ui/tooltip";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { HotkeyScheme, KeyBinding } from "../domain/models";
|
||||
|
||||
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
|
||||
const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
|
||||
@@ -104,6 +107,8 @@ interface VaultViewProps {
|
||||
connectionLogs: ConnectionLog[];
|
||||
managedSources: ManagedSource[];
|
||||
sessions: TerminalSession[];
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
onOpenSettings: () => void;
|
||||
onOpenQuickSwitcher: () => void;
|
||||
onCreateLocalTerminal: () => void;
|
||||
@@ -144,6 +149,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
connectionLogs,
|
||||
managedSources,
|
||||
sessions,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
onOpenSettings,
|
||||
onOpenQuickSwitcher,
|
||||
onCreateLocalTerminal,
|
||||
@@ -189,6 +196,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const [deleteTargetPath, setDeleteTargetPath] = useState<string | null>(null);
|
||||
const [deleteGroupWithHosts, setDeleteGroupWithHosts] = useState(false);
|
||||
|
||||
// Sidebar collapsed state with localStorage persistence
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useStoredBoolean(
|
||||
STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED,
|
||||
false,
|
||||
);
|
||||
|
||||
// Handle external navigation requests
|
||||
useEffect(() => {
|
||||
if (navigateToSection) {
|
||||
@@ -702,6 +715,26 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
return root;
|
||||
}, [hosts, customGroups]);
|
||||
|
||||
// Generate all possible group paths from the tree (including all intermediate nodes)
|
||||
const allGroupPaths = useMemo(() => {
|
||||
const paths = new Set<string>();
|
||||
|
||||
const traverse = (nodes: Record<string, GroupNode>) => {
|
||||
Object.values(nodes).forEach((node) => {
|
||||
if (node.path) {
|
||||
paths.add(node.path);
|
||||
}
|
||||
if (node.children) {
|
||||
traverse(node.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Traverse the tree
|
||||
traverse(buildGroupTree);
|
||||
|
||||
return Array.from(paths).sort();
|
||||
}, [buildGroupTree]);
|
||||
|
||||
const findGroupNode = (path: string | null): GroupNode | null => {
|
||||
if (!path)
|
||||
@@ -819,7 +852,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
if (sortMode !== "group") return null;
|
||||
const groups: { name: string; hosts: Host[] }[] = [];
|
||||
const groupMap = new Map<string, Host[]>();
|
||||
|
||||
|
||||
for (const host of displayedHosts) {
|
||||
const groupName = host.group || "";
|
||||
if (!groupMap.has(groupName)) {
|
||||
@@ -827,7 +860,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}
|
||||
groupMap.get(groupName)!.push(host);
|
||||
}
|
||||
|
||||
|
||||
const sortedKeys = [...groupMap.keys()].sort((a, b) => a.localeCompare(b));
|
||||
for (const key of sortedKeys) {
|
||||
groups.push({ name: key, hosts: groupMap.get(key)! });
|
||||
@@ -866,6 +899,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
return root;
|
||||
}, [treeViewHosts, customGroups]);
|
||||
|
||||
// Helper function to recursively count all hosts in a node and its children
|
||||
const countAllHostsInNode = (node: GroupNode): number => {
|
||||
let count = node.hosts.length;
|
||||
if (node.children) {
|
||||
Object.values(node.children).forEach((child) => {
|
||||
count += countAllHostsInNode(child);
|
||||
});
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
// Create tree view specific group tree that excludes ungrouped hosts
|
||||
const treeViewGroupTree = useMemo<GroupNode[]>(() => {
|
||||
return (Object.values(buildTreeViewGroupTree) as GroupNode[]).sort((a, b) => a.name.localeCompare(b.name));
|
||||
@@ -1225,100 +1269,171 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
return (
|
||||
<div className="absolute inset-0 min-h-0 flex">
|
||||
{/* Sidebar */}
|
||||
<div className="w-52 bg-secondary/80 border-r border-border/60 flex flex-col">
|
||||
<div className="px-4 py-4 flex items-center gap-3">
|
||||
<AppLogo className="h-10 w-10 rounded-xl" />
|
||||
<div>
|
||||
<p className="text-sm font-bold text-foreground">Netcatty</p>
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<div className={cn(
|
||||
"bg-secondary/80 border-r border-border/60 flex flex-col transition-all duration-200",
|
||||
sidebarCollapsed ? "w-14" : "w-52"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"py-4 flex items-center",
|
||||
sidebarCollapsed ? "px-2 justify-center" : "px-4"
|
||||
)}>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<AppLogo className="h-10 w-10 rounded-xl flex-shrink-0" />
|
||||
{!sidebarCollapsed && (
|
||||
<p className="text-sm font-bold text-foreground">Netcatty</p>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{sidebarCollapsed ? t("vault.sidebar.expand") : t("vault.sidebar.collapse")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className={cn("space-y-1", sidebarCollapsed ? "px-1.5" : "px-3")}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentSection === "hosts" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
|
||||
currentSection === "hosts" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentSection("hosts");
|
||||
setSelectedGroupPath(null);
|
||||
}}
|
||||
>
|
||||
<LayoutGrid size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.hosts")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.hosts")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentSection === "keys" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
|
||||
currentSection === "keys" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentSection("keys");
|
||||
}}
|
||||
>
|
||||
<Key size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.keychain")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.keychain")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentSection === "port" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
|
||||
currentSection === "port" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => setCurrentSection("port")}
|
||||
>
|
||||
<Plug size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.portForwarding")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.portForwarding")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentSection === "snippets" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
|
||||
currentSection === "snippets" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentSection("snippets");
|
||||
}}
|
||||
>
|
||||
<FileCode size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.snippets")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.snippets")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentSection === "knownhosts" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
|
||||
currentSection === "knownhosts" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => setCurrentSection("knownhosts")}
|
||||
>
|
||||
<BookMarked size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.knownHosts")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.knownHosts")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentSection === "logs" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
|
||||
currentSection === "logs" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => setCurrentSection("logs")}
|
||||
>
|
||||
<Activity size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.logs")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.logs")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className={cn("mt-auto pb-4 space-y-2", sidebarCollapsed ? "px-1.5" : "px-3")}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full",
|
||||
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3"
|
||||
)}
|
||||
onClick={onOpenSettings}
|
||||
>
|
||||
<Settings size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("common.settings")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("common.settings")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 space-y-1">
|
||||
<Button
|
||||
variant={currentSection === "hosts" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 h-10",
|
||||
currentSection === "hosts" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentSection("hosts");
|
||||
setSelectedGroupPath(null);
|
||||
}}
|
||||
>
|
||||
<LayoutGrid size={16} /> {t("vault.nav.hosts")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentSection === "keys" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 h-10",
|
||||
currentSection === "keys" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentSection("keys");
|
||||
}}
|
||||
>
|
||||
<Key size={16} /> {t("vault.nav.keychain")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentSection === "port" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 h-10",
|
||||
currentSection === "port" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => setCurrentSection("port")}
|
||||
>
|
||||
<Plug size={16} /> {t("vault.nav.portForwarding")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentSection === "snippets" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 h-10",
|
||||
currentSection === "snippets" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentSection("snippets");
|
||||
}}
|
||||
>
|
||||
<FileCode size={16} /> {t("vault.nav.snippets")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentSection === "knownhosts" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 h-10",
|
||||
currentSection === "knownhosts" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => setCurrentSection("knownhosts")}
|
||||
>
|
||||
<BookMarked size={16} /> {t("vault.nav.knownHosts")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentSection === "logs" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 h-10",
|
||||
currentSection === "logs" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => setCurrentSection("logs")}
|
||||
>
|
||||
<Activity size={16} /> {t("vault.nav.logs")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto px-3 pb-4 space-y-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-3"
|
||||
onClick={onOpenSettings}
|
||||
>
|
||||
<Settings size={16} /> {t("common.settings")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Main Area */}
|
||||
<div className="flex-1 flex flex-col min-h-0 relative">
|
||||
@@ -1586,93 +1701,93 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
moveGroup(groupPath, selectedGroupPath);
|
||||
}}
|
||||
>
|
||||
{displayedGroups.map((node) => (
|
||||
<ContextMenu key={node.path}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer",
|
||||
viewMode === "grid"
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
||||
)}
|
||||
draggable
|
||||
onDragStart={(e) =>
|
||||
e.dataTransfer.setData("group-path", node.path)
|
||||
}
|
||||
onDoubleClick={() =>
|
||||
setSelectedGroupPath(node.path)
|
||||
}
|
||||
onClick={() => setSelectedGroupPath(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="flex items-center gap-3 h-full">
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center">
|
||||
<FolderTree size={20} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold truncate flex items-center gap-2">
|
||||
{node.name}
|
||||
{managedGroupPaths.has(node.path) && (
|
||||
<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">
|
||||
<FileSymlink size={10} />
|
||||
Managed
|
||||
</span>
|
||||
)}
|
||||
{displayedGroups.map((node) => (
|
||||
<ContextMenu key={node.path}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer",
|
||||
viewMode === "grid"
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
||||
)}
|
||||
draggable
|
||||
onDragStart={(e) =>
|
||||
e.dataTransfer.setData("group-path", node.path)
|
||||
}
|
||||
onDoubleClick={() =>
|
||||
setSelectedGroupPath(node.path)
|
||||
}
|
||||
onClick={() => setSelectedGroupPath(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="flex items-center gap-3 h-full">
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center">
|
||||
<FolderTree size={20} />
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t("vault.groups.hostsCount", { count: node.hosts.length })}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold truncate flex items-center gap-2">
|
||||
{node.name}
|
||||
{managedGroupPaths.has(node.path) && (
|
||||
<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">
|
||||
<FileSymlink size={10} />
|
||||
Managed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t("vault.groups.hostsCount", { count: countAllHostsInNode(node) })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
setTargetParentPath(node.path);
|
||||
setNewFolderName("");
|
||||
setIsNewFolderOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderPlus className="mr-2 h-4 w-4" /> {t("vault.groups.newSubgroup")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
setRenameTargetPath(node.path);
|
||||
setRenameGroupName(node.name);
|
||||
setRenameGroupError(null);
|
||||
setIsRenameGroupOpen(true);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t("vault.groups.rename")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
setDeleteTargetPath(node.path);
|
||||
setIsDeleteGroupOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t("vault.groups.delete")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
setTargetParentPath(node.path);
|
||||
setNewFolderName("");
|
||||
setIsNewFolderOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderPlus className="mr-2 h-4 w-4" /> {t("vault.groups.newSubgroup")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
setRenameTargetPath(node.path);
|
||||
setRenameGroupName(node.name);
|
||||
setRenameGroupError(null);
|
||||
setIsRenameGroupOpen(true);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t("vault.groups.rename")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
setDeleteTargetPath(node.path);
|
||||
setIsDeleteGroupOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t("vault.groups.delete")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1690,7 +1805,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{isMultiSelectMode && (
|
||||
<div className="flex items-center gap-2 p-2 bg-secondary/60 rounded-lg border border-border/40">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
@@ -1733,7 +1848,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{viewMode === "tree" ? (
|
||||
<HostTreeView
|
||||
groupTree={treeViewGroupTree}
|
||||
@@ -1773,6 +1888,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={handleUnmanageGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
/>
|
||||
) : sortMode === "group" && groupedDisplayHosts ? (
|
||||
<div className="space-y-6">
|
||||
@@ -1825,7 +1943,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
{isMultiSelectMode && (
|
||||
<div
|
||||
<div
|
||||
className="shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -1916,11 +2034,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<LayoutGrid size={32} className="opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
Set up your hosts
|
||||
{t('vault.hosts.empty.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm">
|
||||
Save hosts to quickly connect to your servers, VMs,
|
||||
and containers.
|
||||
{t('vault.hosts.empty.desc')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1964,7 +2081,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
{isMultiSelectMode && (
|
||||
<div
|
||||
<div
|
||||
className="shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -2052,11 +2169,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<LayoutGrid size={32} className="opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
Set up your hosts
|
||||
{t('vault.hosts.empty.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm">
|
||||
Save hosts to quickly connect to your servers, VMs,
|
||||
and containers.
|
||||
{t('vault.hosts.empty.desc')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -2075,6 +2191,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
shellHistory={shellHistory}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
onPackagesChange={onUpdateSnippetPackages}
|
||||
onSave={(s) =>
|
||||
onUpdateSnippets(
|
||||
@@ -2183,12 +2301,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
initialData={editingHost}
|
||||
availableKeys={keys}
|
||||
identities={identities}
|
||||
groups={Array.from(
|
||||
new Set([
|
||||
...customGroups,
|
||||
...hosts.map((h) => h.group || "General"),
|
||||
]),
|
||||
)}
|
||||
groups={allGroupPaths}
|
||||
managedSources={managedSources}
|
||||
allTags={allTags}
|
||||
allHosts={hosts}
|
||||
@@ -2223,12 +2336,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<SerialHostDetailsPanel
|
||||
initialData={editingHost}
|
||||
allTags={allTags}
|
||||
groups={Array.from(
|
||||
new Set([
|
||||
...customGroups,
|
||||
...hosts.map((h) => h.group || "General"),
|
||||
]),
|
||||
)}
|
||||
groups={allGroupPaths}
|
||||
onSave={(host) => {
|
||||
onUpdateHosts(
|
||||
hosts.map((h) => (h.id === host.id ? host : h)),
|
||||
@@ -2379,8 +2487,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<Button variant="ghost" onClick={() => setIsDeleteGroupOpen(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (deleteTargetPath) {
|
||||
const isManaged = managedGroupPaths.has(deleteTargetPath);
|
||||
@@ -2408,7 +2516,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
open={isQuickConnectOpen}
|
||||
target={quickConnectTarget}
|
||||
keys={keys}
|
||||
knownHosts={knownHosts}
|
||||
onConnect={handleQuickConnect}
|
||||
onSaveHost={handleQuickConnectSaveHost}
|
||||
onClose={() => {
|
||||
@@ -2456,6 +2563,7 @@ const vaultViewAreEqual = (
|
||||
const isEqual =
|
||||
prev.hosts === next.hosts &&
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.snippets === next.snippets &&
|
||||
prev.snippetPackages === next.snippetPackages &&
|
||||
prev.customGroups === next.customGroups &&
|
||||
|
||||
89
components/ai-elements/conversation.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
|
||||
import { ArrowDown } from 'lucide-react';
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn('relative flex-1 overflow-y-hidden', className)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationContentProps = ComponentProps<typeof StickToBottom.Content>;
|
||||
|
||||
export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
|
||||
<StickToBottom.Content
|
||||
className={cn('flex flex-col gap-4 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export interface ConversationEmptyStateProps extends HTMLAttributes<HTMLDivElement> {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
className,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-full flex-col items-center justify-center gap-3 p-8 text-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ConversationScrollButton = ({ className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
scrollToBottom();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
if (isAtBottom) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'absolute bottom-3 left-1/2 -translate-x-1/2 z-10',
|
||||
'h-7 w-7 rounded-full border border-border/40 bg-background/90 backdrop-blur-sm',
|
||||
'flex items-center justify-center',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-pointer',
|
||||
'shadow-sm',
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
<ArrowDown size={14} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
87
components/ai-elements/message.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cjk } from '@streamdown/cjk';
|
||||
import { code } from '@streamdown/code';
|
||||
import type { ComponentProps, HTMLAttributes } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { Streamdown } from 'streamdown';
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: 'user' | 'assistant' | 'system' | 'tool';
|
||||
};
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex w-full max-w-[95%] flex-col gap-1.5',
|
||||
from === 'user' ? 'is-user ml-auto' : 'is-assistant',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-fit min-w-0 max-w-full flex-col gap-1.5 overflow-hidden text-[13px] leading-relaxed',
|
||||
'group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-4 group-[.is-user]:py-2.5',
|
||||
'group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground/90',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionsProps = ComponentProps<'div'>;
|
||||
|
||||
export const MessageActions = ({ className, children, ...props }: MessageActionsProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const streamdownPlugins = { cjk, code };
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
// Style the rendered markdown
|
||||
// Code: base styles (code-block overrides are in index.css)
|
||||
'[&_code]:text-[12px] [&_code]:font-mono',
|
||||
'[&_p_code]:px-[0.4em] [&_p_code]:py-[0.15em] [&_p_code]:rounded [&_p_code]:bg-foreground/[0.06] [&_p_code]:text-[85%]',
|
||||
'[&_p]:my-1.5',
|
||||
'[&_ul]:my-1.5 [&_ul]:pl-4 [&_ul]:list-disc',
|
||||
'[&_ol]:my-1.5 [&_ol]:pl-4 [&_ol]:list-decimal',
|
||||
'[&_li]:my-0.5',
|
||||
'[&_h1]:text-base [&_h1]:font-semibold [&_h1]:mt-4 [&_h1]:mb-2',
|
||||
'[&_h2]:text-sm [&_h2]:font-semibold [&_h2]:mt-3 [&_h2]:mb-1.5',
|
||||
'[&_h3]:text-sm [&_h3]:font-medium [&_h3]:mt-2 [&_h3]:mb-1',
|
||||
'[&_blockquote]:border-l-2 [&_blockquote]:border-border/50 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground',
|
||||
'[&_a]:text-primary [&_a]:underline',
|
||||
'[&_hr]:border-border/30 [&_hr]:my-3',
|
||||
'[&_table]:text-[12px] [&_th]:px-2 [&_th]:py-1 [&_th]:border [&_th]:border-border/30 [&_th]:bg-muted/20 [&_td]:px-2 [&_td]:py-1 [&_td]:border [&_td]:border-border/30',
|
||||
className,
|
||||
)}
|
||||
plugins={streamdownPlugins}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.children === nextProps.children &&
|
||||
nextProps.isAnimating === prevProps.isAnimating,
|
||||
);
|
||||
MessageResponse.displayName = 'MessageResponse';
|
||||
283
components/ai-elements/prompt-input.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* PromptInput - Adapted from Vercel AI Elements prompt-input for netcatty.
|
||||
*
|
||||
* Simplified: no file attachments, screenshots, drag-drop, command palette,
|
||||
* hover cards, referenced sources, or tabs. Core input + footer + submit.
|
||||
*/
|
||||
|
||||
import { ArrowUp, Square, X } from 'lucide-react';
|
||||
import type {
|
||||
ComponentProps,
|
||||
ComponentPropsWithoutRef,
|
||||
ElementRef,
|
||||
FormEvent,
|
||||
HTMLAttributes,
|
||||
KeyboardEvent,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { forwardRef, useCallback, useRef } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../ui/select';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupTextarea,
|
||||
} from '../ui/input-group';
|
||||
import { Spinner } from '../ui/spinner';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInput (form wrapper)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PromptInputProps extends HTMLAttributes<HTMLFormElement> {
|
||||
onSubmit: (text: string, event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export const PromptInput = forwardRef<HTMLFormElement, PromptInputProps>(
|
||||
({ className, onSubmit, children, ...props }, ref) => {
|
||||
const handleSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
const textarea = form.querySelector('textarea');
|
||||
const text = textarea?.value?.trim() ?? '';
|
||||
if (!text) return;
|
||||
onSubmit(text, e);
|
||||
},
|
||||
[onSubmit],
|
||||
);
|
||||
|
||||
return (
|
||||
<form ref={ref} onSubmit={handleSubmit} className={className} {...props}>
|
||||
<InputGroup>{children}</InputGroup>
|
||||
</form>
|
||||
);
|
||||
},
|
||||
);
|
||||
PromptInput.displayName = 'PromptInput';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputTextarea
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PromptInputTextareaProps extends ComponentProps<'textarea'> {
|
||||
/** Called when Enter is pressed (without Shift) to trigger form submit */
|
||||
onSubmitRequest?: () => void;
|
||||
}
|
||||
|
||||
export const PromptInputTextarea = forwardRef<HTMLTextAreaElement, PromptInputTextareaProps>(
|
||||
({ className, onSubmitRequest, onKeyDown, ...props }, ref) => {
|
||||
const internalRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const setRef = useCallback(
|
||||
(node: HTMLTextAreaElement | null) => {
|
||||
internalRef.current = node;
|
||||
if (typeof ref === 'function') ref(node);
|
||||
else if (ref) ref.current = node;
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
onKeyDown?.(e);
|
||||
if (e.defaultPrevented) return;
|
||||
|
||||
// CJK composition guard
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSubmitRequest?.();
|
||||
// Trigger form submit
|
||||
const form = internalRef.current?.closest('form');
|
||||
if (form) {
|
||||
form.requestSubmit();
|
||||
}
|
||||
}
|
||||
},
|
||||
[onKeyDown, onSubmitRequest],
|
||||
);
|
||||
|
||||
return (
|
||||
<InputGroupTextarea
|
||||
ref={setRef}
|
||||
className={className}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
PromptInputTextarea.displayName = 'PromptInputTextarea';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputFooter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PromptInputFooterProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PromptInputFooter = forwardRef<HTMLDivElement, PromptInputFooterProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<InputGroupAddon
|
||||
ref={ref}
|
||||
align="block-end"
|
||||
className={cn('gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
PromptInputFooter.displayName = 'PromptInputFooter';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputTools (left side of footer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PromptInputTools = forwardRef<HTMLDivElement, PromptInputToolsProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center gap-0.5', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
PromptInputTools.displayName = 'PromptInputTools';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputButton (toolbar button with optional tooltip)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PromptInputButtonProps extends ComponentProps<typeof InputGroupButton> {
|
||||
tooltip?: ReactNode;
|
||||
tooltipSide?: 'top' | 'bottom' | 'left' | 'right';
|
||||
}
|
||||
|
||||
export const PromptInputButton = forwardRef<HTMLButtonElement, PromptInputButtonProps>(
|
||||
({ tooltip, tooltipSide = 'top', ...props }, ref) => {
|
||||
const button = <InputGroupButton ref={ref} {...props} />;
|
||||
|
||||
if (!tooltip) return button;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side={tooltipSide}>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
PromptInputButton.displayName = 'PromptInputButton';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputSubmit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PromptInputStatus = 'idle' | 'submitted' | 'streaming' | 'error';
|
||||
|
||||
export interface PromptInputSubmitProps extends ComponentProps<typeof InputGroupButton> {
|
||||
status?: PromptInputStatus;
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
||||
export const PromptInputSubmit = forwardRef<HTMLButtonElement, PromptInputSubmitProps>(
|
||||
({ status = 'idle', onStop, className, disabled, ...props }, ref) => {
|
||||
const isRunning = status === 'submitted' || status === 'streaming';
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (isRunning && onStop) {
|
||||
onStop();
|
||||
}
|
||||
}, [isRunning, onStop]);
|
||||
|
||||
const icon =
|
||||
status === 'submitted' ? (
|
||||
<Spinner size={14} />
|
||||
) : status === 'streaming' ? (
|
||||
<Square size={14} />
|
||||
) : status === 'error' ? (
|
||||
<X size={14} />
|
||||
) : (
|
||||
<ArrowUp size={14} />
|
||||
);
|
||||
|
||||
const tooltipLabel =
|
||||
status === 'submitted'
|
||||
? 'Waiting...'
|
||||
: status === 'streaming'
|
||||
? 'Stop'
|
||||
: status === 'error'
|
||||
? 'Error'
|
||||
: 'Send';
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InputGroupButton
|
||||
ref={ref}
|
||||
type={isRunning ? 'button' : 'submit'}
|
||||
onClick={isRunning ? handleClick : undefined}
|
||||
variant="ghost"
|
||||
disabled={disabled && !isRunning}
|
||||
className={cn(
|
||||
'h-8 w-8 rounded-full border p-0 shadow-sm disabled:opacity-100',
|
||||
isRunning
|
||||
? 'border-destructive/60 bg-destructive/85 text-destructive-foreground hover:bg-destructive'
|
||||
: disabled
|
||||
? 'border-border/80 bg-muted/52 text-foreground/72 hover:bg-muted/52'
|
||||
: 'border-foreground/20 bg-foreground text-background hover:bg-foreground/90',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
</InputGroupButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{tooltipLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
PromptInputSubmit.displayName = 'PromptInputSubmit';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputSelect (thin wrappers around the project's Select component)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PromptInputSelect = Select;
|
||||
|
||||
export const PromptInputSelectTrigger = forwardRef<
|
||||
ElementRef<typeof SelectTrigger>,
|
||||
ComponentPropsWithoutRef<typeof SelectTrigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-7 min-w-0 w-auto gap-1 border-none bg-transparent px-2 text-[11px]',
|
||||
'text-muted-foreground/40 hover:text-muted-foreground/70',
|
||||
'focus:ring-0 focus:ring-offset-0',
|
||||
'[&>svg]:h-3 [&>svg]:w-3 [&>svg]:opacity-40',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
PromptInputSelectTrigger.displayName = 'PromptInputSelectTrigger';
|
||||
|
||||
export const PromptInputSelectContent = SelectContent;
|
||||
export const PromptInputSelectItem = SelectItem;
|
||||
export const PromptInputSelectValue = SelectValue;
|
||||
65
components/ai-elements/tool-call.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ChevronDown, ChevronRight, CheckCircle2, Loader2, XCircle } from 'lucide-react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
|
||||
name: string;
|
||||
args?: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
isError?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const ToolCall = ({ name, args, result, isError, isLoading, className, ...props }: ToolCallProps) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const statusIcon = isLoading ? (
|
||||
<Loader2 size={12} className="animate-spin text-blue-400/70" />
|
||||
) : isError ? (
|
||||
<XCircle size={12} className="text-red-400/70" />
|
||||
) : result !== undefined ? (
|
||||
<CheckCircle2 size={12} className="text-green-400/70" />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-md border border-border/25 bg-muted/10 overflow-hidden text-[12px]', className)} {...props}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-muted/20 transition-colors cursor-pointer"
|
||||
>
|
||||
{expanded
|
||||
? <ChevronDown size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
: <ChevronRight size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
}
|
||||
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
|
||||
<span className="flex-1" />
|
||||
{statusIcon}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="border-t border-border/20">
|
||||
{args && Object.keys(args).length > 0 && (
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Arguments</div>
|
||||
<pre className="text-[11px] font-mono text-muted-foreground/50 whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{result !== undefined && (
|
||||
<div className="px-3 py-2 border-t border-border/20">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Result</div>
|
||||
<pre className={cn(
|
||||
'text-[11px] font-mono whitespace-pre-wrap break-all',
|
||||
isError ? 'text-red-400/60' : 'text-muted-foreground/50',
|
||||
)}>
|
||||
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
220
components/ai/AgentIconBadge.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
type AgentLike = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
type?: 'builtin' | 'external';
|
||||
icon?: string;
|
||||
command?: string;
|
||||
};
|
||||
|
||||
type AgentIconKey =
|
||||
| 'catty'
|
||||
| 'openai'
|
||||
| 'claude'
|
||||
| 'anthropic'
|
||||
| 'gemini'
|
||||
| 'google'
|
||||
| 'ollama'
|
||||
| 'openrouter'
|
||||
| 'zed'
|
||||
| 'atom'
|
||||
| 'terminal'
|
||||
| 'plus';
|
||||
|
||||
type AgentIconVisual = {
|
||||
src: string;
|
||||
badgeClassName: string;
|
||||
imageClassName: string;
|
||||
};
|
||||
|
||||
const AGENT_ICON_VISUALS: Record<AgentIconKey, AgentIconVisual> = {
|
||||
catty: {
|
||||
src: '/ai/agents/catty.svg',
|
||||
badgeClassName: 'border-violet-500/20 bg-violet-500/10',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
|
||||
},
|
||||
openai: {
|
||||
src: '/ai/providers/openai.svg',
|
||||
badgeClassName: 'border-emerald-500/22 bg-emerald-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert',
|
||||
},
|
||||
claude: {
|
||||
src: '/ai/agents/claude.svg',
|
||||
badgeClassName: 'border-orange-500/22 bg-orange-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert',
|
||||
},
|
||||
anthropic: {
|
||||
src: '/ai/providers/anthropic.svg',
|
||||
badgeClassName: 'border-orange-500/22 bg-orange-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert',
|
||||
},
|
||||
gemini: {
|
||||
src: '/ai/agents/gemini.svg',
|
||||
badgeClassName: 'border-sky-500/22 bg-sky-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert',
|
||||
},
|
||||
google: {
|
||||
src: '/ai/providers/google.svg',
|
||||
badgeClassName: 'border-sky-500/22 bg-sky-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert',
|
||||
},
|
||||
ollama: {
|
||||
src: '/ai/providers/ollama.svg',
|
||||
badgeClassName: 'border-violet-500/22 bg-violet-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert',
|
||||
},
|
||||
openrouter: {
|
||||
src: '/ai/providers/openrouter.svg',
|
||||
badgeClassName: 'border-fuchsia-500/22 bg-fuchsia-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert',
|
||||
},
|
||||
zed: {
|
||||
src: '/ai/agents/zed.svg',
|
||||
badgeClassName: 'border-cyan-500/22 bg-cyan-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert',
|
||||
},
|
||||
atom: {
|
||||
src: '/ai/agents/atom.svg',
|
||||
badgeClassName: 'border-amber-500/18 bg-amber-500/10',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
|
||||
},
|
||||
terminal: {
|
||||
src: '/ai/agents/terminal.svg',
|
||||
badgeClassName: 'border-white/8 bg-white/[0.04]',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
|
||||
},
|
||||
plus: {
|
||||
src: '/ai/agents/plus.svg',
|
||||
badgeClassName: 'border-white/8 bg-white/[0.04]',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-85',
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeToken(value?: string): string {
|
||||
return (value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '');
|
||||
}
|
||||
|
||||
function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
|
||||
if (agent === 'add-more') {
|
||||
return 'plus';
|
||||
}
|
||||
|
||||
if (agent.type === 'builtin') {
|
||||
return 'catty';
|
||||
}
|
||||
|
||||
const tokens = [
|
||||
normalizeToken(agent.icon),
|
||||
normalizeToken(agent.command),
|
||||
normalizeToken(agent.name),
|
||||
normalizeToken(agent.id),
|
||||
].filter(Boolean);
|
||||
|
||||
if (tokens.some((token) => token.includes('claude'))) {
|
||||
return 'claude';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('anthropic'))) {
|
||||
return 'anthropic';
|
||||
}
|
||||
if (
|
||||
tokens.some(
|
||||
(token) =>
|
||||
token.includes('codex') ||
|
||||
token.includes('openai') ||
|
||||
token.includes('chatgpt'),
|
||||
)
|
||||
) {
|
||||
return 'openai';
|
||||
}
|
||||
if (
|
||||
tokens.some(
|
||||
(token) =>
|
||||
token.includes('gemini') ||
|
||||
token.includes('google') ||
|
||||
token.includes('googlegemini'),
|
||||
)
|
||||
) {
|
||||
return 'gemini';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('ollama'))) {
|
||||
return 'ollama';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('openrouter'))) {
|
||||
return 'openrouter';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('zed'))) {
|
||||
return 'zed';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('factory'))) {
|
||||
return 'atom';
|
||||
}
|
||||
|
||||
return 'terminal';
|
||||
}
|
||||
|
||||
export function getAgentCommandLabel(agent: AgentLike): string | undefined {
|
||||
if (agent.type === 'builtin') {
|
||||
return 'Built-in terminal assistant';
|
||||
}
|
||||
return agent.command ? `CLI: ${agent.command}` : 'External CLI agent';
|
||||
}
|
||||
|
||||
export const AgentIconBadge: React.FC<{
|
||||
agent: AgentLike | 'add-more';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
variant?: 'plain' | 'badge';
|
||||
className?: string;
|
||||
}> = ({ agent, size = 'md', variant = 'badge', className }) => {
|
||||
const visual = AGENT_ICON_VISUALS[getAgentIconKey(agent)];
|
||||
const badgeSize =
|
||||
size === 'xs'
|
||||
? 'h-4 w-4 rounded-sm'
|
||||
: size === 'sm'
|
||||
? 'h-7 w-7 rounded-lg'
|
||||
: size === 'lg'
|
||||
? 'h-10 w-10 rounded-xl'
|
||||
: 'h-8 w-8 rounded-lg';
|
||||
const imageSize =
|
||||
size === 'xs'
|
||||
? 'h-3.5 w-3.5'
|
||||
: size === 'sm'
|
||||
? 'h-3.5 w-3.5'
|
||||
: size === 'lg'
|
||||
? 'h-5 w-5'
|
||||
: 'h-4 w-4';
|
||||
|
||||
if (variant === 'plain') {
|
||||
return (
|
||||
<img
|
||||
src={visual.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn('shrink-0', imageSize, visual.imageClassName, className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center overflow-hidden border',
|
||||
badgeSize,
|
||||
visual.badgeClassName,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={visual.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn(imageSize, visual.imageClassName)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentIconBadge;
|
||||
280
components/ai/AgentSelector.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* AgentSelector - Dropdown for switching between AI agents
|
||||
*
|
||||
* Dark, grouped agent menu with local SVG branding for built-in,
|
||||
* discovered, and external agents.
|
||||
*/
|
||||
|
||||
import { ChevronDown, RefreshCw, Plus, Settings } from 'lucide-react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { AgentInfo, ExternalAgentConfig, DiscoveredAgent } from '../../infrastructure/ai/types';
|
||||
import AgentIconBadge from './AgentIconBadge';
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownContent,
|
||||
DropdownTrigger,
|
||||
} from '../ui/dropdown';
|
||||
|
||||
interface AgentSelectorProps {
|
||||
currentAgentId: string;
|
||||
externalAgents: ExternalAgentConfig[];
|
||||
discoveredAgents?: DiscoveredAgent[];
|
||||
isDiscovering?: boolean;
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
onEnableDiscoveredAgent?: (agent: DiscoveredAgent) => void;
|
||||
onRediscover?: () => void;
|
||||
onManageAgents?: () => void;
|
||||
}
|
||||
|
||||
const BUILTIN_AGENTS: AgentInfo[] = [
|
||||
{
|
||||
id: 'catty',
|
||||
name: 'Catty Agent',
|
||||
type: 'builtin',
|
||||
description: 'Built-in terminal assistant',
|
||||
available: true,
|
||||
},
|
||||
];
|
||||
|
||||
const SectionLabel: React.FC<{ children: React.ReactNode; action?: React.ReactNode }> = ({ children, action }) => (
|
||||
<div className="px-4 pb-2 pt-2 flex items-center justify-between">
|
||||
<span className="text-[10px] font-medium tracking-wide text-muted-foreground/52">
|
||||
{children}
|
||||
</span>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
|
||||
const AgentMenuRow: React.FC<{
|
||||
agent: AgentInfo;
|
||||
isActive?: boolean;
|
||||
subtitle?: string;
|
||||
onClick: () => void;
|
||||
}> = ({ agent, isActive, subtitle, onClick }) => {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center gap-3 px-4 text-left text-[13px] text-foreground/86 transition-colors cursor-pointer hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/30',
|
||||
isActive && 'bg-muted',
|
||||
)}
|
||||
>
|
||||
<AgentIconBadge agent={agent} size="xs" variant="plain" className="opacity-78" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate">{agent.name}</span>
|
||||
{subtitle && (
|
||||
<span className="block truncate text-[10px] text-muted-foreground/40">{subtitle}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const DiscoveredAgentRow: React.FC<{
|
||||
agent: DiscoveredAgent;
|
||||
onEnable: () => void;
|
||||
}> = ({ agent, onEnable }) => {
|
||||
const agentLike: AgentInfo = {
|
||||
id: `discovered_${agent.command}`,
|
||||
name: agent.name,
|
||||
type: 'external',
|
||||
icon: agent.icon,
|
||||
command: agent.command,
|
||||
available: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-10 w-full items-center gap-3 rounded-md px-4 text-[13px]">
|
||||
<AgentIconBadge agent={agentLike} size="xs" variant="plain" className="opacity-78" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate text-foreground/86">{agent.name}</span>
|
||||
<span className="block truncate text-[10px] text-muted-foreground/40">
|
||||
{agent.version || agent.path}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onEnable}
|
||||
className="shrink-0 rounded-md px-2 py-0.5 text-[11px] font-medium text-primary/80 hover:bg-primary/10 hover:text-primary transition-colors cursor-pointer"
|
||||
title={`Enable ${agent.name}`}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
currentAgentId,
|
||||
externalAgents,
|
||||
discoveredAgents = [],
|
||||
isDiscovering = false,
|
||||
onSelectAgent,
|
||||
onEnableDiscoveredAgent,
|
||||
onRediscover,
|
||||
onManageAgents,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const enabledExternalAgents = useMemo(
|
||||
() =>
|
||||
externalAgents
|
||||
.filter((agent) => agent.enabled)
|
||||
.map(
|
||||
(agent): AgentInfo => ({
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
type: 'external',
|
||||
icon: agent.icon,
|
||||
command: agent.command,
|
||||
args: agent.args,
|
||||
available: true,
|
||||
}),
|
||||
),
|
||||
[externalAgents],
|
||||
);
|
||||
|
||||
// Discovered agents not yet added to external agents
|
||||
const unconfiguredDiscovered = useMemo(
|
||||
() =>
|
||||
discoveredAgents.filter(
|
||||
(da) => !externalAgents.some((ea) => ea.command === da.command || ea.command === da.path),
|
||||
),
|
||||
[discoveredAgents, externalAgents],
|
||||
);
|
||||
|
||||
const allAgents = useMemo(
|
||||
() => [...BUILTIN_AGENTS, ...enabledExternalAgents],
|
||||
[enabledExternalAgents],
|
||||
);
|
||||
|
||||
const currentAgent = useMemo(
|
||||
() => allAgents.find((agent) => agent.id === currentAgentId) ?? BUILTIN_AGENTS[0],
|
||||
[allAgents, currentAgentId],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(agentId: string) => {
|
||||
onSelectAgent(agentId);
|
||||
setOpen(false);
|
||||
},
|
||||
[onSelectAgent],
|
||||
);
|
||||
|
||||
const handleEnableDiscovered = useCallback(
|
||||
(agent: DiscoveredAgent) => {
|
||||
onEnableDiscoveredAgent?.(agent);
|
||||
// After enabling, auto-select it
|
||||
const agentId = `discovered_${agent.command}`;
|
||||
onSelectAgent(agentId);
|
||||
setOpen(false);
|
||||
},
|
||||
[onEnableDiscoveredAgent, onSelectAgent],
|
||||
);
|
||||
|
||||
const handleManageAgents = useCallback(() => {
|
||||
setOpen(false);
|
||||
onManageAgents?.();
|
||||
}, [onManageAgents]);
|
||||
|
||||
return (
|
||||
<Dropdown open={open} onOpenChange={setOpen}>
|
||||
<DropdownTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex h-8 min-w-0 max-w-[170px] items-center gap-2 rounded-md px-2 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/28"
|
||||
>
|
||||
<AgentIconBadge
|
||||
agent={currentAgent}
|
||||
size="xs"
|
||||
variant="plain"
|
||||
className="opacity-78"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground/90">
|
||||
{currentAgent.name}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className={cn(
|
||||
'shrink-0 text-muted-foreground/60 transition-transform',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</DropdownTrigger>
|
||||
|
||||
<DropdownContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="w-[288px] rounded-2xl border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-xl"
|
||||
>
|
||||
{BUILTIN_AGENTS.map((agent) => (
|
||||
<AgentMenuRow
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
isActive={currentAgentId === agent.id}
|
||||
onClick={() => handleSelect(agent.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{enabledExternalAgents.length > 0 && (
|
||||
<>
|
||||
<div className="mx-0 my-1 border-t border-border/50" />
|
||||
<SectionLabel>{t('ai.chat.agents')}</SectionLabel>
|
||||
{enabledExternalAgents.map((agent) => (
|
||||
<AgentMenuRow
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
isActive={currentAgentId === agent.id}
|
||||
subtitle={agent.command}
|
||||
onClick={() => handleSelect(agent.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{unconfiguredDiscovered.length > 0 && (
|
||||
<>
|
||||
<div className="mx-0 my-1 border-t border-border/50" />
|
||||
<SectionLabel
|
||||
action={
|
||||
onRediscover && (
|
||||
<button
|
||||
onClick={onRediscover}
|
||||
disabled={isDiscovering}
|
||||
className="text-[10px] text-muted-foreground/40 hover:text-muted-foreground/70 transition-colors cursor-pointer disabled:opacity-50"
|
||||
title={t('ai.chat.rescan')}
|
||||
>
|
||||
<RefreshCw size={10} className={cn(isDiscovering && 'animate-spin')} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('ai.chat.detectedOnMachine')}
|
||||
</SectionLabel>
|
||||
{unconfiguredDiscovered.map((agent) => (
|
||||
<DiscoveredAgentRow
|
||||
key={agent.command}
|
||||
agent={agent}
|
||||
onEnable={() => handleEnableDiscovered(agent)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mx-0 my-1 border-t border-border/50" />
|
||||
<button
|
||||
onClick={handleManageAgents}
|
||||
className="flex h-10 w-full items-center gap-3 px-4 text-left text-[13px] text-foreground/82 transition-colors cursor-pointer hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/30"
|
||||
>
|
||||
<Settings size={16} className="opacity-72 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{t('ai.agentSettings')}</span>
|
||||
</button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(AgentSelector);
|
||||
559
components/ai/ChatInput.tsx
Normal file
@@ -0,0 +1,559 @@
|
||||
/**
|
||||
* ChatInput - Zed-style bottom input area for the AI chat panel
|
||||
*
|
||||
* Thin wrapper around the AI Elements prompt-input components.
|
||||
* Bordered textarea with monospace placeholder, expand toggle,
|
||||
* and a bottom toolbar with muted controls + subtle send button.
|
||||
*/
|
||||
|
||||
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Plus, ShieldCheck, X, Zap } from 'lucide-react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { FormEvent } from 'react';
|
||||
import type { UploadedImage } from '../../application/state/useImageUpload';
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputFooter,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
PromptInputTools,
|
||||
} from '../ai-elements/prompt-input';
|
||||
import type { PromptInputStatus } from '../ai-elements/prompt-input';
|
||||
import { formatThinkingLabel } from '../../infrastructure/ai/types';
|
||||
import type { AgentModelPreset, AIPermissionMode } from '../../infrastructure/ai/types';
|
||||
|
||||
interface ChatInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSend: () => void;
|
||||
onStop?: () => void;
|
||||
isStreaming?: boolean;
|
||||
disabled?: boolean;
|
||||
providerName?: string;
|
||||
modelName?: string;
|
||||
agentName?: string;
|
||||
placeholder?: string;
|
||||
/** Available model presets for the current agent */
|
||||
modelPresets?: AgentModelPreset[];
|
||||
/** Currently selected model ID */
|
||||
selectedModelId?: string;
|
||||
/** Callback when user selects a model */
|
||||
onModelSelect?: (modelId: string) => void;
|
||||
/** Attached images */
|
||||
images?: UploadedImage[];
|
||||
/** Callback to add images (paste/drop) */
|
||||
onAddImages?: (files: File[]) => void;
|
||||
/** Callback to remove an image */
|
||||
onRemoveImage?: (id: string) => void;
|
||||
/** Available hosts for @ mention */
|
||||
hosts?: Array<{ sessionId: string; hostname: string; label: string; connected: boolean }>;
|
||||
/** Permission mode (only shown for Catty Agent) */
|
||||
permissionMode?: AIPermissionMode;
|
||||
/** Callback when user changes permission mode */
|
||||
onPermissionModeChange?: (mode: AIPermissionMode) => void;
|
||||
}
|
||||
|
||||
const ChatInput: React.FC<ChatInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onSend,
|
||||
onStop,
|
||||
isStreaming = false,
|
||||
disabled = false,
|
||||
providerName,
|
||||
modelName,
|
||||
agentName,
|
||||
placeholder,
|
||||
modelPresets = [],
|
||||
selectedModelId,
|
||||
onModelSelect,
|
||||
images = [],
|
||||
onAddImages,
|
||||
onRemoveImage,
|
||||
hosts = [],
|
||||
permissionMode,
|
||||
onPermissionModeChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// Consolidate menu state into a single discriminated union to prevent multiple menus open simultaneously
|
||||
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'perm' | null;
|
||||
const [activeMenu, setActiveMenu] = useState<ActiveMenu>(null);
|
||||
const [menuPos, setMenuPos] = useState<{ left: number; bottom: number } | null>(null);
|
||||
const [hoveredModelId, setHoveredModelId] = useState<string | null>(null);
|
||||
const [showHostSubmenu, setShowHostSubmenu] = useState(false);
|
||||
|
||||
// Derived booleans for readability
|
||||
const showModelPicker = activeMenu === 'model';
|
||||
const showAttachMenu = activeMenu === 'attach';
|
||||
const showAtMention = activeMenu === 'atMention';
|
||||
const showPermPicker = activeMenu === 'perm';
|
||||
|
||||
const closeAllMenus = useCallback(() => {
|
||||
setActiveMenu(null);
|
||||
setMenuPos(null);
|
||||
setHoveredModelId(null);
|
||||
setShowHostSubmenu(false);
|
||||
}, []);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const modelBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const permBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const attachBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleInputChange = useCallback((newValue: string) => {
|
||||
onChange(newValue);
|
||||
// Detect if user just typed @
|
||||
if (
|
||||
hosts.length > 0 &&
|
||||
newValue.length > value.length &&
|
||||
newValue.endsWith('@')
|
||||
) {
|
||||
// Position the popover near the textarea
|
||||
const el = textareaRef.current;
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
setMenuPos({ left: rect.left + 12, bottom: window.innerHeight - rect.top + 4 });
|
||||
}
|
||||
setActiveMenu('atMention');
|
||||
} else if (showAtMention && !newValue.includes('@')) {
|
||||
setActiveMenu(null);
|
||||
}
|
||||
}, [onChange, value, hosts.length, showAtMention]);
|
||||
|
||||
const handleSelectAtMention = useCallback((host: { label: string; hostname: string }) => {
|
||||
// Replace the trailing @ with @hostname
|
||||
const name = host.label || host.hostname;
|
||||
const lastAt = value.lastIndexOf('@');
|
||||
const newValue = lastAt >= 0
|
||||
? value.slice(0, lastAt) + `@${name} `
|
||||
: value + `@${name} `;
|
||||
onChange(newValue);
|
||||
closeAllMenus();
|
||||
}, [value, onChange, closeAllMenus]);
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||
const files = Array.from(e.clipboardData.items)
|
||||
.filter((item) => item.type.startsWith('image/'))
|
||||
.map((item) => item.getAsFile())
|
||||
.filter(Boolean) as File[];
|
||||
if (files.length > 0) {
|
||||
e.preventDefault();
|
||||
onAddImages?.(files);
|
||||
}
|
||||
}, [onAddImages]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
|
||||
if (files.length > 0) {
|
||||
onAddImages?.(files);
|
||||
}
|
||||
}, [onAddImages]);
|
||||
|
||||
const defaultPlaceholder = agentName
|
||||
? t('ai.chat.placeholder').replace('{agent}', agentName)
|
||||
: t('ai.chat.placeholderDefault');
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(_text: string, _event: FormEvent<HTMLFormElement>) => {
|
||||
onSend();
|
||||
},
|
||||
[onSend],
|
||||
);
|
||||
|
||||
const status: PromptInputStatus = isStreaming ? 'streaming' : 'idle';
|
||||
|
||||
// Permission mode chip removed — agents run in autonomous mode
|
||||
|
||||
// selectedModelId may be "model/thinking" for codex
|
||||
const selectedBaseModelId = selectedModelId?.split('/')[0];
|
||||
const selectedThinking = selectedModelId?.includes('/') ? selectedModelId.split('/')[1] : undefined;
|
||||
const selectedPreset = modelPresets.find(m => m.id === selectedBaseModelId);
|
||||
const modelLabel = selectedPreset
|
||||
? selectedPreset.name + (selectedThinking ? ` / ${formatThinkingLabel(selectedThinking)}` : '')
|
||||
: modelName || providerName || t('ai.chat.noModel');
|
||||
const hasModelPicker = modelPresets.length > 0 && onModelSelect;
|
||||
const chipClassName =
|
||||
'inline-flex h-6 items-center gap-1 rounded-full px-1.5 text-[10.5px] text-foreground/72';
|
||||
const iconButtonClassName =
|
||||
'h-6 w-6 rounded-full bg-transparent text-foreground/62 hover:bg-muted/24 hover:text-foreground';
|
||||
|
||||
return (
|
||||
<div className="shrink-0 px-4 pb-4">
|
||||
<PromptInput onSubmit={handleSubmit}>
|
||||
{/* Image attachment chips */}
|
||||
{images.length > 0 && (
|
||||
<div className="flex gap-1.5 px-3 pt-2 pb-0.5 flex-wrap">
|
||||
{images.map((img) => (
|
||||
<div
|
||||
key={img.id}
|
||||
className="inline-flex items-center gap-1 h-6 pl-1.5 pr-1 rounded-md bg-muted/30 border border-border/30 text-[11px] text-foreground/70 group"
|
||||
>
|
||||
<ImageIcon size={11} className="text-muted-foreground/60 shrink-0" />
|
||||
<span className="truncate max-w-[80px]">{img.filename}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveImage?.(img.id)}
|
||||
className="h-3.5 w-3.5 rounded-sm flex items-center justify-center opacity-50 hover:opacity-100 hover:bg-muted/50 transition-opacity cursor-pointer"
|
||||
>
|
||||
<X size={8} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) {
|
||||
onAddImages?.(Array.from(e.target.files));
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Textarea with expand toggle */}
|
||||
<div className="relative" onPaste={handlePaste} onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
|
||||
<PromptInputTextarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
placeholder={placeholder || defaultPlaceholder}
|
||||
disabled={disabled || isStreaming}
|
||||
className={expanded ? 'max-h-[220px]' : undefined}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="absolute top-3.5 right-3 rounded-md p-1 text-muted-foreground/38 hover:text-muted-foreground/72 hover:bg-muted/25 transition-colors cursor-pointer"
|
||||
title={expanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<Expand size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* @ mention popover */}
|
||||
{showAtMention && hosts.length > 0 && menuPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Mention host"
|
||||
className="fixed z-[1000] min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom }}
|
||||
>
|
||||
<div className="px-3 py-1 text-[10px] text-muted-foreground/40 tracking-wide">{t('ai.chat.menuHosts')}</div>
|
||||
{hosts.map(host => (
|
||||
<button
|
||||
key={host.sessionId}
|
||||
type="button"
|
||||
role="option"
|
||||
onClick={() => handleSelectAtMention(host)}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
|
||||
<span className="text-foreground/85 truncate">{host.label || host.hostname}</span>
|
||||
{host.label && host.hostname !== host.label && (
|
||||
<span className="text-[10px] text-muted-foreground/40">{host.hostname}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{/* Footer toolbar */}
|
||||
<PromptInputFooter className="gap-1.5 border-t-0 bg-transparent px-3 pb-2 pt-0">
|
||||
<PromptInputTools className="gap-1 flex-wrap">
|
||||
<button
|
||||
ref={attachBtnRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!showAttachMenu) {
|
||||
const rect = attachBtnRef.current?.getBoundingClientRect();
|
||||
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
|
||||
setActiveMenu('attach');
|
||||
} else {
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className={iconButtonClassName}
|
||||
title="Attach"
|
||||
aria-label="Attach file"
|
||||
aria-expanded={showAttachMenu}
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
{showAttachMenu && menuPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="menu"
|
||||
className="fixed z-[1000] min-w-[170px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom }}
|
||||
>
|
||||
<div className="px-3 py-1 text-[10px] text-muted-foreground/40 tracking-wide">{t('ai.chat.menuContext')}</div>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => { fileInputRef.current?.setAttribute('accept', '*/*'); fileInputRef.current?.click(); closeAllMenus(); }}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<FileText size={13} className="text-muted-foreground/60" />
|
||||
<span className="text-foreground/85">{t('ai.chat.menuFiles')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => { fileInputRef.current?.setAttribute('accept', 'image/*'); fileInputRef.current?.click(); closeAllMenus(); }}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<ImageIcon size={13} className="text-muted-foreground/60" />
|
||||
<span className="text-foreground/85">{t('ai.chat.menuImage')}</span>
|
||||
</button>
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setShowHostSubmenu(true)}
|
||||
onMouseLeave={() => setShowHostSubmenu(false)}
|
||||
onFocus={() => setShowHostSubmenu(true)}
|
||||
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setShowHostSubmenu(false); }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label="Mention host"
|
||||
aria-expanded={showHostSubmenu && hosts.length > 0}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<AtSign size={13} className="text-muted-foreground/60" />
|
||||
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionHost')}</span>
|
||||
{hosts.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
|
||||
</button>
|
||||
{showHostSubmenu && hosts.length > 0 && (
|
||||
<div role="menu" className="absolute left-full top-0 ml-1 min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1 z-[1001]">
|
||||
{hosts.map(host => (
|
||||
<button
|
||||
key={host.sessionId}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const mention = `@${host.label || host.hostname} `;
|
||||
onChange(value + mention);
|
||||
closeAllMenus();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
|
||||
<span className="text-foreground/85 truncate">{host.label || host.hostname}</span>
|
||||
{host.label && host.hostname !== host.label && (
|
||||
<span className="text-[10px] text-muted-foreground/40">{host.hostname}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
<button
|
||||
ref={modelBtnRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!hasModelPicker) return;
|
||||
if (!showModelPicker) {
|
||||
const rect = modelBtnRef.current?.getBoundingClientRect();
|
||||
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
|
||||
setActiveMenu('model');
|
||||
} else {
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className={`${chipClassName} ${hasModelPicker ? 'cursor-pointer hover:bg-muted/24 transition-colors' : ''}`}
|
||||
aria-label="Select model"
|
||||
aria-expanded={showModelPicker}
|
||||
>
|
||||
<Cpu size={11} className="text-muted-foreground/64" />
|
||||
<span className="truncate max-w-[82px]">{modelLabel}</span>
|
||||
{hasModelPicker && <ChevronDown size={9} className="text-muted-foreground/50" />}
|
||||
</button>
|
||||
{showModelPicker && hasModelPicker && menuPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Select model"
|
||||
className="fixed z-[1000] min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom }}
|
||||
onMouseLeave={() => setHoveredModelId(null)}
|
||||
>
|
||||
{modelPresets.map(preset => {
|
||||
const isSelected = preset.id === selectedBaseModelId;
|
||||
const hasThinking = preset.thinkingLevels && preset.thinkingLevels.length > 0;
|
||||
return (
|
||||
<div
|
||||
key={preset.id}
|
||||
className="relative"
|
||||
onMouseEnter={() => setHoveredModelId(hasThinking ? preset.id : null)}
|
||||
onFocus={() => { if (hasThinking) setHoveredModelId(preset.id); }}
|
||||
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setHoveredModelId(null); }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => {
|
||||
if (!hasThinking) {
|
||||
onModelSelect?.(preset.id);
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
{isSelected ? <Check size={11} className="text-primary shrink-0" /> : <span className="w-[11px] shrink-0" />}
|
||||
<span className="flex-1 text-foreground/85">{preset.name}</span>
|
||||
{preset.description && <span className="text-[10px] text-muted-foreground/50 mr-1">{preset.description}</span>}
|
||||
{hasThinking && <ChevronRight size={10} className="text-muted-foreground/50" />}
|
||||
</button>
|
||||
{/* Thinking level sub-menu */}
|
||||
{hasThinking && hoveredModelId === preset.id && (
|
||||
<div role="listbox" aria-label="Thinking level" className="absolute left-full top-0 ml-1 min-w-[120px] rounded-lg border border-border/50 bg-popover shadow-lg py-1 z-[1001]">
|
||||
{preset.thinkingLevels!.map(level => {
|
||||
const fullId = `${preset.id}/${level}`;
|
||||
const isLevelSelected = selectedModelId === fullId;
|
||||
return (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isLevelSelected}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
onModelSelect?.(fullId);
|
||||
closeAllMenus();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onModelSelect?.(fullId);
|
||||
closeAllMenus();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
{isLevelSelected ? <Check size={11} className="text-primary shrink-0" /> : <span className="w-[11px] shrink-0" />}
|
||||
<span className="text-foreground/85">{formatThinkingLabel(level)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
{/* Permission mode chip — only for Catty Agent */}
|
||||
{permissionMode && onPermissionModeChange && (
|
||||
<>
|
||||
<button
|
||||
ref={permBtnRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!showPermPicker) {
|
||||
const rect = permBtnRef.current?.getBoundingClientRect();
|
||||
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
|
||||
setActiveMenu('perm');
|
||||
} else {
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className={`${chipClassName} cursor-pointer hover:bg-muted/24 transition-colors`}
|
||||
title={t('ai.safety.permissionMode')}
|
||||
aria-label="Permission mode"
|
||||
aria-expanded={showPermPicker}
|
||||
>
|
||||
{permissionMode === 'observer' && <Eye size={11} className="text-blue-400/70" />}
|
||||
{permissionMode === 'confirm' && <ShieldCheck size={11} className="text-yellow-400/70" />}
|
||||
{permissionMode === 'autonomous' && <Zap size={11} className="text-green-400/70" />}
|
||||
<span className="truncate max-w-[72px]">
|
||||
{permissionMode === 'observer' && t('ai.chat.permObserver')}
|
||||
{permissionMode === 'confirm' && t('ai.chat.permConfirm')}
|
||||
{permissionMode === 'autonomous' && t('ai.chat.permAuto')}
|
||||
</span>
|
||||
<ChevronDown size={9} className="text-muted-foreground/50" />
|
||||
</button>
|
||||
{showPermPicker && menuPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Permission mode"
|
||||
className="fixed z-[1000] min-w-[180px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom }}
|
||||
>
|
||||
{([
|
||||
{ mode: 'autonomous' as const, icon: Zap, color: 'text-green-400/70', label: t('ai.chat.permAuto'), desc: t('ai.chat.permAutoDesc') },
|
||||
{ mode: 'confirm' as const, icon: ShieldCheck, color: 'text-yellow-400/70', label: t('ai.chat.permConfirm'), desc: t('ai.chat.permConfirmDesc') },
|
||||
{ mode: 'observer' as const, icon: Eye, color: 'text-blue-400/70', label: t('ai.chat.permObserver'), desc: t('ai.chat.permObserverDesc') },
|
||||
]).map(({ mode, icon: Icon, color, label, desc }) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={permissionMode === mode}
|
||||
onClick={() => {
|
||||
onPermissionModeChange(mode);
|
||||
closeAllMenus();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
>
|
||||
{permissionMode === mode
|
||||
? <Check size={11} className="text-primary shrink-0" />
|
||||
: <span className="w-[11px] shrink-0" />
|
||||
}
|
||||
<Icon size={12} className={`${color} shrink-0`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-foreground/85">{label}</div>
|
||||
<div className="text-[10px] text-muted-foreground/40 leading-tight">{desc}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PromptInputTools>
|
||||
|
||||
<div className="flex-1 min-w-0" />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<PromptInputSubmit
|
||||
status={status}
|
||||
onStop={onStop}
|
||||
disabled={!value.trim() || disabled}
|
||||
/>
|
||||
</div>
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ChatInput);
|
||||
196
components/ai/ChatMessageList.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* ChatMessageList - Renders the list of chat messages
|
||||
*
|
||||
* Claude-Code-style: user messages in bordered bubbles (right-aligned),
|
||||
* assistant responses as plain text (left-aligned, no border/bg).
|
||||
* No avatars. Thinking blocks are collapsible.
|
||||
*/
|
||||
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { ChatMessage } from '../../infrastructure/ai/types';
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from '../ai-elements/conversation';
|
||||
import { Message, MessageContent, MessageResponse } from '../ai-elements/message';
|
||||
import { ToolCall } from '../ai-elements/tool-call';
|
||||
import { InlineApprovalCard } from './InlineApprovalCard';
|
||||
import ThinkingBlock from './ThinkingBlock';
|
||||
|
||||
interface ChatMessageListProps {
|
||||
messages: ChatMessage[];
|
||||
isStreaming?: boolean;
|
||||
onApprove?: (messageId: string) => void;
|
||||
onReject?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming, onApprove, onReject }) => {
|
||||
const { t } = useI18n();
|
||||
const visibleMessages = messages.filter(m => m.role !== 'system');
|
||||
|
||||
if (visibleMessages.length === 0 && !isStreaming) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center px-6">
|
||||
<p className="text-[13px] text-muted-foreground/40 text-center">
|
||||
{t('ai.chat.emptyHint')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const lastAssistantMessage = visibleMessages.findLast(m => m.role === 'assistant');
|
||||
|
||||
return (
|
||||
<Conversation className="flex-1">
|
||||
<ConversationContent className="gap-1.5 px-4 py-2">
|
||||
{visibleMessages.map((message) => {
|
||||
if (message.role === 'tool') {
|
||||
return (
|
||||
<React.Fragment key={message.id}>
|
||||
{message.toolResults?.map((tr) => (
|
||||
<ToolCall
|
||||
key={tr.toolCallId}
|
||||
name={tr.toolCallId}
|
||||
result={tr.content}
|
||||
isError={tr.isError}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const isUser = message.role === 'user';
|
||||
const isLastAssistant = message === lastAssistantMessage;
|
||||
const isThisStreaming = isStreaming && isLastAssistant;
|
||||
|
||||
return (
|
||||
<Message key={message.id} from={message.role}>
|
||||
<MessageContent>
|
||||
{/* Thinking block */}
|
||||
{!isUser && message.thinking && (
|
||||
<ThinkingBlock
|
||||
content={message.thinking}
|
||||
isStreaming={!!isThisStreaming && !message.content}
|
||||
durationMs={message.thinkingDurationMs}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* User images */}
|
||||
{isUser && message.images && message.images.length > 0 && (
|
||||
<div className="flex gap-1.5 flex-wrap mb-1">
|
||||
{message.images.map((img, i) => (
|
||||
<img
|
||||
key={img.filename ? `${img.filename}-${i}` : `img-${message.id}-${i}`}
|
||||
src={`data:${img.mediaType};base64,${img.base64Data}`}
|
||||
alt={img.filename || 'image'}
|
||||
className="max-h-[120px] max-w-[200px] rounded-md object-contain border border-border/20"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.content && (
|
||||
isUser
|
||||
? <div className="whitespace-pre-wrap break-words text-[13px]">{message.content}</div>
|
||||
: <MessageResponse isAnimating={isThisStreaming}>
|
||||
{message.content}
|
||||
</MessageResponse>
|
||||
)}
|
||||
|
||||
{/* Tool calls */}
|
||||
{message.toolCalls?.map((tc) => (
|
||||
<ToolCall
|
||||
key={tc.id}
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isLoading={isThisStreaming && message.executionStatus === 'running'}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Inline approval card */}
|
||||
{message.pendingApproval && (
|
||||
<InlineApprovalCard
|
||||
toolName={message.pendingApproval.toolName}
|
||||
toolArgs={message.pendingApproval.toolArgs}
|
||||
status={message.pendingApproval.status}
|
||||
onApprove={() => onApprove?.(message.id)}
|
||||
onReject={() => onReject?.(message.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Status text with shimmer */}
|
||||
{message.statusText && (
|
||||
<div className="py-1">
|
||||
<span className="thinking-shimmer text-xs">{message.statusText}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error info */}
|
||||
{message.errorInfo && (
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-destructive/10 border border-destructive/20 text-sm">
|
||||
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-destructive font-medium">{message.errorInfo.message}</p>
|
||||
{message.errorInfo.retryable && (
|
||||
<p className="text-muted-foreground text-xs mt-1">{t('ai.chat.retryHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Streaming indicator — only when no content and no thinking yet */}
|
||||
{isStreaming && !lastAssistantMessage?.content && !lastAssistantMessage?.thinking && (
|
||||
<div className="flex items-center gap-1 py-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/30 animate-bounce [animation-delay:0ms]" />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/30 animate-bounce [animation-delay:150ms]" />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/30 animate-bounce [animation-delay:300ms]" />
|
||||
</div>
|
||||
)}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
);
|
||||
};
|
||||
|
||||
function areMessagesEqual(prev: ChatMessageListProps, next: ChatMessageListProps): boolean {
|
||||
if (prev.isStreaming !== next.isStreaming) return false;
|
||||
if (prev.onApprove !== next.onApprove) return false;
|
||||
if (prev.onReject !== next.onReject) return false;
|
||||
if (prev.messages.length !== next.messages.length) return false;
|
||||
if (prev.messages === next.messages) return true;
|
||||
|
||||
// Shallow-compare each message by reference
|
||||
for (let i = 0; i < prev.messages.length; i++) {
|
||||
if (prev.messages[i] !== next.messages[i]) {
|
||||
// For the last message during streaming, compare by content to avoid
|
||||
// re-renders when only the array reference changed but content is the same
|
||||
const p = prev.messages[i];
|
||||
const n = next.messages[i];
|
||||
if (
|
||||
p.id !== n.id ||
|
||||
p.content !== n.content ||
|
||||
p.thinking !== n.thinking ||
|
||||
p.role !== n.role ||
|
||||
p.statusText !== n.statusText ||
|
||||
p.executionStatus !== n.executionStatus ||
|
||||
p.pendingApproval !== n.pendingApproval ||
|
||||
p.errorInfo !== n.errorInfo ||
|
||||
p.toolCalls !== n.toolCalls ||
|
||||
p.toolResults !== n.toolResults
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export default React.memo(ChatMessageList, areMessagesEqual);
|
||||
82
components/ai/ConversationExport.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* ConversationExport - Dropdown button for exporting chat sessions
|
||||
*
|
||||
* Small download icon button with a dropdown offering Markdown, JSON,
|
||||
* and Plain Text export formats.
|
||||
*/
|
||||
|
||||
import { Download, FileJson, FileText, FileType } from 'lucide-react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownContent,
|
||||
DropdownTrigger,
|
||||
} from '../ui/dropdown';
|
||||
|
||||
interface ConversationExportProps {
|
||||
session: AISession | null;
|
||||
onExport: (format: 'md' | 'json' | 'txt') => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const EXPORT_OPTIONS = [
|
||||
{ format: 'md' as const, labelKey: 'ai.chat.exportMarkdown' as const, icon: FileText },
|
||||
{ format: 'json' as const, labelKey: 'ai.chat.exportJSON' as const, icon: FileJson },
|
||||
{ format: 'txt' as const, labelKey: 'ai.chat.exportPlainText' as const, icon: FileType },
|
||||
];
|
||||
|
||||
const ConversationExport: React.FC<ConversationExportProps> = ({
|
||||
session,
|
||||
onExport,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const handleExport = useCallback(
|
||||
(format: 'md' | 'json' | 'txt') => {
|
||||
onExport(format);
|
||||
},
|
||||
[onExport],
|
||||
);
|
||||
|
||||
const hasMessages = session && session.messages.length > 0;
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={className ?? 'h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground'}
|
||||
disabled={!hasMessages}
|
||||
title={t('ai.chat.exportConversation')}
|
||||
>
|
||||
<Download size={14} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
className="w-40 rounded-xl border border-border/45 bg-[#111111]/98 p-1.5 text-foreground shadow-[0_20px_48px_rgba(0,0,0,0.48)] supports-[backdrop-filter]:bg-[#111111]/92 supports-[backdrop-filter]:backdrop-blur-xl"
|
||||
>
|
||||
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground/48">
|
||||
{t('ai.chat.exportAs')}
|
||||
</div>
|
||||
{EXPORT_OPTIONS.map(({ format, labelKey, icon: Icon }) => (
|
||||
<button
|
||||
key={format}
|
||||
onClick={() => handleExport(format)}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 text-[13px] rounded-lg transition-colors cursor-pointer hover:bg-white/[0.04]"
|
||||
>
|
||||
<Icon size={13} className="shrink-0 text-muted-foreground/70" />
|
||||
<span>{t(labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ConversationExport);
|
||||
169
components/ai/ExecutionPlan.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* ExecutionPlan - Renders a multi-step execution plan for AI agent tasks.
|
||||
*
|
||||
* Shows a numbered list of steps with status indicators, host badges,
|
||||
* optional command previews, and action buttons.
|
||||
*/
|
||||
|
||||
import {
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Loader2,
|
||||
SkipForward,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Types
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface ExecutionPlanStep {
|
||||
description: string;
|
||||
host?: string;
|
||||
command?: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
}
|
||||
|
||||
interface ExecutionPlanProps {
|
||||
steps: ExecutionPlanStep[];
|
||||
onApprove: () => void;
|
||||
onModify: () => void;
|
||||
onReject: () => void;
|
||||
isExecuting: boolean;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Status icon mapping
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function StepStatusIcon({
|
||||
status,
|
||||
}: {
|
||||
status: ExecutionPlanStep['status'];
|
||||
}) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Circle size={16} className="text-muted-foreground" />;
|
||||
case 'running':
|
||||
return (
|
||||
<Loader2 size={16} className="text-blue-500 animate-spin" />
|
||||
);
|
||||
case 'completed':
|
||||
return <CheckCircle2 size={16} className="text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircle size={16} className="text-destructive" />;
|
||||
case 'skipped':
|
||||
return (
|
||||
<SkipForward size={16} className="text-muted-foreground/60" />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const ExecutionPlan: React.FC<ExecutionPlanProps> = ({
|
||||
steps,
|
||||
onApprove,
|
||||
onModify,
|
||||
onReject,
|
||||
isExecuting,
|
||||
}) => {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-muted/30 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 border-b border-border/60 bg-muted/50">
|
||||
<span className="text-sm font-medium">
|
||||
Execution Plan ({steps.length} step{steps.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Steps list */}
|
||||
<div className="divide-y divide-border/30">
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'flex items-start gap-3 px-3 py-2.5 transition-colors',
|
||||
step.status === 'running' && 'bg-blue-500/5',
|
||||
step.status === 'completed' && 'bg-green-500/5',
|
||||
step.status === 'failed' && 'bg-destructive/5',
|
||||
step.status === 'skipped' && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
{/* Step number + status icon */}
|
||||
<div className="flex items-center gap-2 shrink-0 pt-0.5">
|
||||
<span className="text-xs text-muted-foreground font-mono w-4 text-right">
|
||||
{index + 1}
|
||||
</span>
|
||||
<StepStatusIcon status={step.status} />
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm',
|
||||
step.status === 'skipped' && 'line-through',
|
||||
)}
|
||||
>
|
||||
{step.description}
|
||||
</span>
|
||||
{step.host && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{step.host}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{step.command && (
|
||||
<code className="block text-xs font-mono bg-muted/80 px-2 py-1 rounded text-muted-foreground truncate">
|
||||
{step.command}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="px-3 py-2.5 border-t border-border/60 flex items-center justify-end gap-2">
|
||||
{isExecuting ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onReject}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={onReject}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onModify}>
|
||||
Modify Plan
|
||||
</Button>
|
||||
<Button size="sm" onClick={onApprove}>
|
||||
Approve
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ExecutionPlan.displayName = 'ExecutionPlan';
|
||||
|
||||
export default ExecutionPlan;
|
||||
export { ExecutionPlan };
|
||||
export type { ExecutionPlanProps, ExecutionPlanStep };
|
||||
193
components/ai/InlineApprovalCard.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* InlineApprovalCard - Inline tool approval card rendered within chat messages.
|
||||
*
|
||||
* Replaces the modal PermissionDialog. Shows tool name, arguments, and
|
||||
* approve/reject buttons. Keyboard shortcuts: Enter to approve, Escape to reject.
|
||||
*/
|
||||
|
||||
import { Check, ShieldAlert, X } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
interface InlineApprovalCardProps {
|
||||
toolName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
status: 'pending' | 'approved' | 'denied';
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
}
|
||||
|
||||
const InlineApprovalCard: React.FC<InlineApprovalCardProps> = ({
|
||||
toolName,
|
||||
toolArgs,
|
||||
status,
|
||||
onApprove,
|
||||
onReject,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const approveBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const isPending = status === 'pending';
|
||||
const [responded, setResponded] = useState(false);
|
||||
|
||||
// Use refs to always access the latest callbacks without re-registering the listener
|
||||
const onApproveRef = useRef(onApprove);
|
||||
const onRejectRef = useRef(onReject);
|
||||
onApproveRef.current = onApprove;
|
||||
onRejectRef.current = onReject;
|
||||
|
||||
const isDisabled = !isPending || responded;
|
||||
|
||||
const handleApprove = useCallback(() => {
|
||||
if (isDisabled) return;
|
||||
setResponded(true);
|
||||
onApproveRef.current();
|
||||
}, [isDisabled]);
|
||||
|
||||
const handleReject = useCallback(() => {
|
||||
if (isDisabled) return;
|
||||
setResponded(true);
|
||||
onRejectRef.current();
|
||||
}, [isDisabled]);
|
||||
|
||||
// Keyboard shortcuts: handled via local onKeyDown on the focusable card element
|
||||
// to avoid conflicts when multiple InlineApprovalCard instances exist simultaneously.
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (isDisabled) return;
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleApprove();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleReject();
|
||||
}
|
||||
}, [isDisabled, handleApprove, handleReject]);
|
||||
|
||||
// Auto-focus approve button and auto-scroll into view when mounted as pending
|
||||
useEffect(() => {
|
||||
if (isPending && cardRef.current) {
|
||||
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
approveBtnRef.current?.focus();
|
||||
}
|
||||
}, [isPending]);
|
||||
|
||||
let formattedArgs: string;
|
||||
try {
|
||||
formattedArgs = JSON.stringify(toolArgs, null, 2);
|
||||
} catch {
|
||||
formattedArgs = String(toolArgs);
|
||||
}
|
||||
|
||||
// Extract target session info if present
|
||||
const sessionId = toolArgs?.sessionId as string | undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
tabIndex={0}
|
||||
role="alertdialog"
|
||||
aria-label="Tool execution approval required"
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`rounded-md border overflow-hidden text-[12px] mt-1.5 outline-none ${
|
||||
isPending
|
||||
? 'border-yellow-500/30 bg-yellow-500/[0.04]'
|
||||
: status === 'approved'
|
||||
? 'border-green-500/20 bg-green-500/[0.03]'
|
||||
: 'border-red-500/20 bg-red-500/[0.03]'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5">
|
||||
<ShieldAlert
|
||||
size={13}
|
||||
className={
|
||||
isPending
|
||||
? 'text-yellow-500/70 shrink-0'
|
||||
: status === 'approved'
|
||||
? 'text-green-400/70 shrink-0'
|
||||
: 'text-red-400/70 shrink-0'
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px] font-medium text-foreground/70">
|
||||
{t('ai.chat.toolApprovalTitle')}
|
||||
</span>
|
||||
{!isPending && (
|
||||
<Badge
|
||||
className={`ml-auto text-[10px] px-1.5 py-0 ${
|
||||
status === 'approved'
|
||||
? 'bg-green-600/20 text-green-400 border-green-600/30'
|
||||
: 'bg-red-600/20 text-red-400 border-red-600/30'
|
||||
}`}
|
||||
>
|
||||
{status === 'approved' ? t('ai.chat.toolApproved') : t('ai.chat.toolDenied')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tool info */}
|
||||
<div className="px-3 pb-2 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-muted-foreground/40 uppercase tracking-wider">{t('ai.chat.toolLabel')}</span>
|
||||
<code className="text-[11px] font-mono text-muted-foreground/70 bg-muted/30 px-1.5 py-0.5 rounded">
|
||||
{toolName}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{sessionId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-muted-foreground/40 uppercase tracking-wider">{t('ai.chat.targetLabel')}</span>
|
||||
<code className="text-[11px] font-mono text-muted-foreground/50 bg-muted/30 px-1.5 py-0.5 rounded">
|
||||
{sessionId}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Arguments */}
|
||||
<div className="rounded border border-border/20 bg-muted/10 p-2 max-h-32 overflow-auto">
|
||||
<pre className="text-[11px] font-mono whitespace-pre-wrap break-all text-muted-foreground/50">
|
||||
{formattedArgs}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Actions or hint */}
|
||||
{isPending && (
|
||||
<div className="flex items-center justify-between pt-0.5">
|
||||
<span className="text-[10px] text-muted-foreground/30">
|
||||
{t('ai.chat.toolApprovalHint')}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={responded}
|
||||
className={`h-6 px-2 text-[11px] border-red-500/20 text-red-400/80 hover:bg-red-500/10 hover:text-red-400 ${responded ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onClick={handleReject}
|
||||
>
|
||||
<X size={11} className="mr-0.5" />
|
||||
{t('ai.chat.reject')}
|
||||
</Button>
|
||||
<Button
|
||||
ref={approveBtnRef}
|
||||
size="sm"
|
||||
disabled={responded}
|
||||
className={`h-6 px-2.5 text-[11px] bg-green-600/80 hover:bg-green-600 text-white ${responded ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onClick={handleApprove}
|
||||
>
|
||||
<Check size={11} className="mr-0.5" />
|
||||
{t('ai.chat.approve')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
InlineApprovalCard.displayName = 'InlineApprovalCard';
|
||||
|
||||
export default InlineApprovalCard;
|
||||
export { InlineApprovalCard };
|
||||
export type { InlineApprovalCardProps };
|
||||
200
components/ai/PermissionDialog.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* PermissionDialog - Modal for AI agent tool call permission requests.
|
||||
*
|
||||
* Shown when the agent needs user approval to execute a tool call.
|
||||
* Displays tool name, arguments, recommendation, and approve/reject actions.
|
||||
*/
|
||||
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Types
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface PermissionDialogProps {
|
||||
open: boolean;
|
||||
toolCall: { name: string; arguments: Record<string, unknown> } | null;
|
||||
recommendation: 'allow' | 'confirm' | 'deny';
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const PermissionDialog: React.FC<PermissionDialogProps> = ({
|
||||
open,
|
||||
toolCall,
|
||||
recommendation,
|
||||
onApprove,
|
||||
onReject,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isDenied = recommendation === 'deny';
|
||||
|
||||
// Keyboard shortcuts: Enter to approve, Escape to reject
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !isDenied) {
|
||||
e.preventDefault();
|
||||
onApprove();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onReject();
|
||||
}
|
||||
},
|
||||
[isDenied, onApprove, onReject],
|
||||
);
|
||||
|
||||
// Format arguments as readable code block content
|
||||
let formattedArgs = '';
|
||||
if (toolCall) {
|
||||
try {
|
||||
formattedArgs = JSON.stringify(toolCall.arguments, null, 2);
|
||||
} catch {
|
||||
formattedArgs = String(toolCall.arguments);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract host/session info from arguments if present
|
||||
const sessionId =
|
||||
toolCall?.arguments?.sessionId as string | undefined;
|
||||
const sessionIds =
|
||||
toolCall?.arguments?.sessionIds as string[] | undefined;
|
||||
|
||||
const recommendationBadge = () => {
|
||||
switch (recommendation) {
|
||||
case 'allow':
|
||||
return (
|
||||
<Badge className="bg-green-600/20 text-green-400 border-green-600/30">
|
||||
{t('ai.chat.recommendAllow')}
|
||||
</Badge>
|
||||
);
|
||||
case 'confirm':
|
||||
return (
|
||||
<Badge className="bg-yellow-600/20 text-yellow-400 border-yellow-600/30">
|
||||
{t('ai.chat.recommendConfirm')}
|
||||
</Badge>
|
||||
);
|
||||
case 'deny':
|
||||
return <Badge variant="destructive">{t('ai.chat.recommendDeny')}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onDismiss()}>
|
||||
<DialogContent hideCloseButton onKeyDown={handleKeyDown}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ShieldAlert
|
||||
size={20}
|
||||
className={cn(
|
||||
isDenied ? 'text-destructive' : 'text-yellow-500',
|
||||
)}
|
||||
/>
|
||||
{t('ai.chat.permissionRequired')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('ai.chat.permissionDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{toolCall && (
|
||||
<div className="space-y-3">
|
||||
{/* Tool name and recommendation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{t('ai.chat.toolLabel')}:</span>
|
||||
<code className="text-sm font-mono bg-muted px-1.5 py-0.5 rounded">
|
||||
{toolCall.name}
|
||||
</code>
|
||||
</div>
|
||||
{recommendationBadge()}
|
||||
</div>
|
||||
|
||||
{/* Target session(s) */}
|
||||
{(sessionId || sessionIds) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{t('ai.chat.targetLabel')}:</span>
|
||||
{sessionId && (
|
||||
<code className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">
|
||||
{sessionId}
|
||||
</code>
|
||||
)}
|
||||
{sessionIds && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{sessionIds.map((id) => (
|
||||
<code
|
||||
key={id}
|
||||
className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded"
|
||||
>
|
||||
{id}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Arguments code block */}
|
||||
<div className="rounded-md border border-border bg-muted/50 p-3 max-h-48 overflow-auto">
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-foreground">
|
||||
{formattedArgs}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Deny warning */}
|
||||
{isDenied && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3">
|
||||
<p className="text-sm text-destructive">
|
||||
{t('ai.chat.commandBlocked')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{isDenied ? (
|
||||
<Button variant="destructive" onClick={onReject} className="w-full">
|
||||
{t('ai.chat.reject')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onReject}
|
||||
className="border-destructive/30 text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{t('ai.chat.reject')}
|
||||
</Button>
|
||||
<Button onClick={onApprove}>{t('ai.chat.approve')}</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
PermissionDialog.displayName = 'PermissionDialog';
|
||||
|
||||
export default PermissionDialog;
|
||||
export { PermissionDialog };
|
||||
export type { PermissionDialogProps };
|
||||
138
components/ai/ThinkingBlock.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* ThinkingBlock - Collapsible thinking/reasoning display
|
||||
*
|
||||
* - While streaming: expanded, "Thinking" label with shimmer + elapsed time
|
||||
* - When done: auto-collapses to "Thought for Xs", click to expand
|
||||
* - Content area has max-height with scroll and top gradient fade
|
||||
*/
|
||||
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface ThinkingBlockProps {
|
||||
content: string;
|
||||
isStreaming: boolean;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remaining = seconds % 60;
|
||||
return `${minutes}m ${remaining}s`;
|
||||
}
|
||||
|
||||
const ThinkingBlock: React.FC<ThinkingBlockProps> = ({
|
||||
content,
|
||||
isStreaming,
|
||||
durationMs,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isExpanded, setIsExpanded] = useState(isStreaming);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const wasStreamingRef = useRef(false);
|
||||
const startRef = useRef(Date.now());
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-collapse when streaming ends
|
||||
useEffect(() => {
|
||||
if (wasStreamingRef.current && !isStreaming) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
wasStreamingRef.current = isStreaming;
|
||||
}, [isStreaming]);
|
||||
|
||||
// Expand when streaming starts
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
setIsExpanded(true);
|
||||
startRef.current = Date.now();
|
||||
}
|
||||
}, [isStreaming]);
|
||||
|
||||
// Elapsed time ticker
|
||||
useEffect(() => {
|
||||
if (!isStreaming) return;
|
||||
const timer = setInterval(() => {
|
||||
setElapsed(Date.now() - startRef.current);
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [isStreaming]);
|
||||
|
||||
// Auto-scroll to bottom while streaming
|
||||
useEffect(() => {
|
||||
if (isStreaming && isExpanded && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [content, isStreaming, isExpanded]);
|
||||
|
||||
const toggle = useCallback(() => setIsExpanded(e => !e), []);
|
||||
|
||||
const displayDuration = durationMs || elapsed;
|
||||
const preview = content.length > 60 ? content.slice(0, 60) + '…' : content;
|
||||
|
||||
return (
|
||||
<div className="mb-0.5">
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls="thinking-block-content"
|
||||
className="group flex items-center gap-1.5 py-0.5 px-1 cursor-pointer text-left w-full rounded hover:bg-white/[0.03] transition-colors"
|
||||
>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={cn(
|
||||
'shrink-0 text-muted-foreground/50 transition-transform duration-200',
|
||||
isExpanded && 'rotate-90',
|
||||
!isExpanded && 'opacity-50',
|
||||
)}
|
||||
/>
|
||||
<span className="text-[12px] font-medium text-muted-foreground/70 whitespace-nowrap shrink-0">
|
||||
{isStreaming ? (
|
||||
<span className="thinking-shimmer">{t('ai.chat.thinking')}</span>
|
||||
) : (
|
||||
displayDuration > 0
|
||||
? t('ai.chat.thoughtFor', { duration: formatDuration(displayDuration) })
|
||||
: t('ai.chat.thought')
|
||||
)}
|
||||
</span>
|
||||
{isStreaming && elapsed > 0 && (
|
||||
<span className="text-[11px] text-muted-foreground/40 tabular-nums shrink-0">
|
||||
{formatDuration(elapsed)}
|
||||
</span>
|
||||
)}
|
||||
{!isExpanded && !isStreaming && preview && (
|
||||
<span className="text-[11px] text-muted-foreground/40 truncate min-w-0">
|
||||
{preview}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && content && (
|
||||
<div id="thinking-block-content" className="relative">
|
||||
{/* Top gradient fade */}
|
||||
{isStreaming && (
|
||||
<div className="absolute inset-x-0 top-0 h-4 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
'px-5 text-[12px] text-muted-foreground/60 leading-relaxed whitespace-pre-wrap break-words',
|
||||
isStreaming && 'overflow-y-auto scrollbar-hide max-h-36',
|
||||
!isStreaming && 'max-h-36 overflow-y-auto scrollbar-hide',
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ThinkingBlock);
|
||||
732
components/ai/hooks/useAIChatStreaming.ts
Normal file
@@ -0,0 +1,732 @@
|
||||
/**
|
||||
* useAIChatStreaming — Encapsulates all streaming logic for the AI chat panel.
|
||||
*
|
||||
* Handles:
|
||||
* - Catty agent streaming via Vercel AI SDK `streamText`
|
||||
* - External agent streaming (ACP and raw process)
|
||||
* - Text-delta batching via requestAnimationFrame
|
||||
* - Abort controller management
|
||||
* - Stream state tracking (per-session)
|
||||
* - Error reporting
|
||||
*/
|
||||
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { streamText, stepCountIs, type ModelMessage } from 'ai';
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
AISession,
|
||||
ChatMessage,
|
||||
ExternalAgentConfig,
|
||||
ProviderConfig,
|
||||
} from '../../../infrastructure/ai/types';
|
||||
import { buildSystemPrompt } from '../../../infrastructure/ai/cattyAgent/systemPrompt';
|
||||
import { createModelFromConfig } from '../../../infrastructure/ai/sdk/providers';
|
||||
import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
|
||||
import type { NetcattyBridge } from '../../../infrastructure/ai/cattyAgent/executor';
|
||||
import { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter';
|
||||
import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter';
|
||||
import { classifyError, sanitizeErrorMessage } from '../../../infrastructure/ai/errorClassifier';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Stream chunk type interfaces (Issue #13: replace unsafe casts)
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/** Shape of a text/text-delta chunk from the Vercel AI SDK fullStream. */
|
||||
interface TextDeltaChunk {
|
||||
type: 'text' | 'text-delta';
|
||||
text?: string;
|
||||
textDelta?: string;
|
||||
}
|
||||
|
||||
/** Shape of a reasoning chunk from the Vercel AI SDK fullStream. */
|
||||
interface ReasoningChunk {
|
||||
type: 'reasoning' | 'reasoning-start' | 'reasoning-delta';
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/** Shape of a tool-call chunk from the Vercel AI SDK fullStream. */
|
||||
interface ToolCallChunk {
|
||||
type: 'tool-call';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
input?: unknown;
|
||||
args?: unknown;
|
||||
}
|
||||
|
||||
/** Shape of a tool-result chunk from the Vercel AI SDK fullStream. */
|
||||
interface ToolResultChunk {
|
||||
type: 'tool-result';
|
||||
toolCallId: string;
|
||||
output?: unknown;
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
/** Shape of a tool-approval-request chunk from the Vercel AI SDK fullStream. */
|
||||
interface ToolApprovalRequestChunk {
|
||||
type: 'tool-approval-request';
|
||||
approvalId: string;
|
||||
toolCall: {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args?: Record<string, unknown>;
|
||||
input?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/** Shape of an error chunk from the Vercel AI SDK fullStream. */
|
||||
interface ErrorChunk {
|
||||
type: 'error';
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
/** Union of all stream chunk shapes we handle. */
|
||||
type StreamChunk =
|
||||
| TextDeltaChunk
|
||||
| ReasoningChunk
|
||||
| ToolCallChunk
|
||||
| ToolResultChunk
|
||||
| ToolApprovalRequestChunk
|
||||
| ErrorChunk
|
||||
| { type: 'reasoning-end' | 'text-start' | 'text-end' | 'start' | 'finish' | 'start-step' | 'finish-step' };
|
||||
|
||||
/** Shape of the netcatty bridge exposed on `window` (panel-specific subset). */
|
||||
export interface PanelBridge extends NetcattyBridge {
|
||||
credentialsDecrypt?: (value: string) => Promise<string>;
|
||||
aiSyncProviders?: (providers: Array<{ id: string; providerId: string; apiKey?: string; baseURL?: string; enabled: boolean }>) => Promise<{ ok: boolean }>;
|
||||
aiMcpUpdateSessions?: (sessions: TerminalSessionInfo[], chatSessionId?: string) => Promise<unknown>;
|
||||
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
[key: string]: ((...args: unknown[]) => unknown) | undefined;
|
||||
}
|
||||
|
||||
/** Terminal session info used throughout the streaming hooks. */
|
||||
export interface TerminalSessionInfo {
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
hostname: string;
|
||||
label: string;
|
||||
os?: string;
|
||||
username?: string;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
/** Typed accessor for the netcatty bridge on the window object. */
|
||||
export function getNetcattyBridge(): PanelBridge | undefined {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (window as any).netcatty as PanelBridge | undefined;
|
||||
}
|
||||
|
||||
/** Approval info returned by processCattyStream when a tool-approval-request is received. */
|
||||
export interface ApprovalInfo {
|
||||
approvalId: string;
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Pending approval context stored between approval request and user response. */
|
||||
export interface PendingApprovalContext {
|
||||
sessionId: string;
|
||||
scopeKey: string;
|
||||
sdkMessages: Array<ModelMessage>;
|
||||
approvalInfo: ApprovalInfo;
|
||||
model: ReturnType<typeof createModelFromConfig>;
|
||||
systemPrompt: string;
|
||||
tools: ReturnType<typeof createCattyTools>;
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook parameters
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export interface UseAIChatStreamingParams {
|
||||
maxIterations: number;
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateLastMessage: (sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
||||
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook return type
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export interface UseAIChatStreamingReturn {
|
||||
/** Set of session IDs currently streaming. */
|
||||
streamingSessionIds: Set<string>;
|
||||
/** Set or unset streaming state for a session. */
|
||||
setStreamingForScope: (key: string, val: boolean) => void;
|
||||
/** Ref to per-session abort controllers. */
|
||||
abortControllersRef: React.MutableRefObject<Map<string, AbortController>>;
|
||||
/** Process a Catty agent stream, returning approval info if one is requested. */
|
||||
processCattyStream: (
|
||||
streamSessionId: string,
|
||||
model: ReturnType<typeof createModelFromConfig>,
|
||||
systemPrompt: string,
|
||||
tools: ReturnType<typeof createCattyTools>,
|
||||
sdkMessages: Array<ModelMessage>,
|
||||
signal: AbortSignal,
|
||||
currentAssistantMsgId: string,
|
||||
) => Promise<ApprovalInfo | null>;
|
||||
/** Send a message to the Catty agent (built-in). */
|
||||
sendToCattyAgent: (
|
||||
sessionId: string,
|
||||
sendScopeKey: string,
|
||||
trimmed: string,
|
||||
abortController: AbortController,
|
||||
currentSession: AISession | undefined,
|
||||
assistantMsgId: string,
|
||||
context: SendToCattyContext,
|
||||
) => Promise<void>;
|
||||
/** Send a message to an external agent (ACP or raw process). */
|
||||
sendToExternalAgent: (
|
||||
sessionId: string,
|
||||
trimmed: string,
|
||||
agentConfig: ExternalAgentConfig,
|
||||
abortController: AbortController,
|
||||
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string }>,
|
||||
context: SendToExternalContext,
|
||||
) => Promise<void>;
|
||||
/** Report a streaming error to the chat. */
|
||||
reportStreamError: (sessionId: string, abortSignal: AbortSignal, err: unknown) => void;
|
||||
}
|
||||
|
||||
/** Context values needed by sendToCattyAgent that change frequently (avoids stale closures). */
|
||||
export interface SendToCattyContext {
|
||||
activeProvider: ProviderConfig | undefined;
|
||||
activeModelId: string;
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeTargetId?: string;
|
||||
scopeLabel?: string;
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
commandBlocklist?: string[];
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
setPendingApproval: (ctx: PendingApprovalContext | null) => void;
|
||||
autoTitleSession: (sessionId: string, text: string) => void;
|
||||
}
|
||||
|
||||
/** Context values needed by sendToExternalAgent that change frequently. */
|
||||
export interface SendToExternalContext {
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
providers: ProviderConfig[];
|
||||
selectedAgentModel?: string;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook implementation
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export function useAIChatStreaming({
|
||||
maxIterations,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
}: UseAIChatStreamingParams): UseAIChatStreamingReturn {
|
||||
// Per-session streaming state (keyed by sessionId)
|
||||
const [streamingSessionIds, setStreamingSessions] = useState<Set<string>>(new Set());
|
||||
const setStreamingForScope = useCallback((key: string, val: boolean) => {
|
||||
setStreamingSessions(prev => {
|
||||
const next = new Set(prev);
|
||||
if (val) next.add(key); else next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Per-scope abort controllers
|
||||
const abortControllersRef = useRef<Map<string, AbortController>>(new Map());
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// reportStreamError
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const reportStreamError = useCallback((
|
||||
sessionId: string,
|
||||
abortSignal: AbortSignal,
|
||||
err: unknown,
|
||||
) => {
|
||||
if (abortSignal.aborted) return;
|
||||
const errorStr = err instanceof Error ? err.message : String(err);
|
||||
// Log the full unsanitized error for debugging
|
||||
console.error('[AIChatSidePanel] Stream error (full):', errorStr);
|
||||
const errorInfo = classifyError(errorStr);
|
||||
// Sanitize the displayed message to avoid leaking paths, keys, or other sensitive info
|
||||
errorInfo.message = sanitizeErrorMessage(errorInfo.message);
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
|
||||
}));
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
errorInfo,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}, [updateLastMessage, addMessageToSession]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// processCattyStream
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const processCattyStream = useCallback(async (
|
||||
streamSessionId: string,
|
||||
model: ReturnType<typeof createModelFromConfig>,
|
||||
systemPrompt: string,
|
||||
tools: ReturnType<typeof createCattyTools>,
|
||||
sdkMessages: Array<ModelMessage>,
|
||||
signal: AbortSignal,
|
||||
currentAssistantMsgId: string,
|
||||
): Promise<ApprovalInfo | null> => {
|
||||
const result = streamText({
|
||||
model,
|
||||
messages: sdkMessages,
|
||||
system: systemPrompt,
|
||||
tools,
|
||||
stopWhen: stepCountIs(maxIterations),
|
||||
abortSignal: signal,
|
||||
});
|
||||
|
||||
// Track the current assistant message ID so updates target the correct message
|
||||
let activeMsgId = currentAssistantMsgId;
|
||||
let lastAddedRole: 'assistant' | 'tool' = 'assistant';
|
||||
const reader = result.fullStream.getReader();
|
||||
let pendingApprovalInfo: ApprovalInfo | null = null;
|
||||
|
||||
// -- Text-delta batching: accumulate deltas and flush periodically --
|
||||
let pendingText = '';
|
||||
let rafId: number | null = null;
|
||||
|
||||
const flushText = () => {
|
||||
if (pendingText) {
|
||||
const text = pendingText;
|
||||
pendingText = '';
|
||||
if (lastAddedRole === 'tool') {
|
||||
const newId = generateId();
|
||||
addMessageToSession(streamSessionId, {
|
||||
id: newId,
|
||||
role: 'assistant',
|
||||
content: text,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
activeMsgId = newId;
|
||||
lastAddedRole = 'assistant';
|
||||
} else {
|
||||
updateMessageById(streamSessionId, activeMsgId, msg => ({
|
||||
...msg,
|
||||
content: msg.content + text,
|
||||
}));
|
||||
}
|
||||
}
|
||||
rafId = null;
|
||||
};
|
||||
|
||||
const cancelPendingFlush = () => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
// Use the StreamChunk union for type narrowing instead of unsafe casts
|
||||
const chunk = value as StreamChunk;
|
||||
switch (chunk.type) {
|
||||
case 'text':
|
||||
case 'text-delta': {
|
||||
const typedChunk = chunk as TextDeltaChunk;
|
||||
const text = typedChunk.text ?? typedChunk.textDelta;
|
||||
if (text) {
|
||||
pendingText += text;
|
||||
if (rafId === null) {
|
||||
rafId = requestAnimationFrame(flushText);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'reasoning':
|
||||
case 'reasoning-start':
|
||||
case 'reasoning-delta': {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const typedChunk = chunk as ReasoningChunk;
|
||||
const rText = typedChunk.text;
|
||||
if (rText) {
|
||||
if (lastAddedRole === 'tool') {
|
||||
const newId = generateId();
|
||||
addMessageToSession(streamSessionId, {
|
||||
id: newId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
thinking: rText,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
activeMsgId = newId;
|
||||
lastAddedRole = 'assistant';
|
||||
} else {
|
||||
updateMessageById(streamSessionId, activeMsgId, msg => ({
|
||||
...msg,
|
||||
thinking: (msg.thinking || '') + rText,
|
||||
}));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'reasoning-end':
|
||||
case 'text-start':
|
||||
case 'text-end':
|
||||
case 'start':
|
||||
case 'finish':
|
||||
case 'start-step':
|
||||
case 'finish-step':
|
||||
break;
|
||||
case 'tool-call': {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const typedChunk = chunk as ToolCallChunk;
|
||||
updateMessageById(streamSessionId, activeMsgId, msg => ({
|
||||
...msg,
|
||||
toolCalls: [...(msg.toolCalls || []), {
|
||||
id: typedChunk.toolCallId,
|
||||
name: typedChunk.toolName,
|
||||
arguments: (typedChunk.input ?? typedChunk.args) as Record<string, unknown>,
|
||||
}],
|
||||
executionStatus: 'running',
|
||||
statusText: undefined,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'tool-result': {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const typedChunk = chunk as ToolResultChunk;
|
||||
// Mark the assistant message's tool execution as completed
|
||||
updateMessageById(streamSessionId, activeMsgId, msg =>
|
||||
msg.role === 'assistant' && msg.executionStatus === 'running'
|
||||
? { ...msg, executionStatus: 'completed', statusText: undefined } : msg,
|
||||
);
|
||||
const toolOutput = typedChunk.output ?? typedChunk.result;
|
||||
addMessageToSession(streamSessionId, {
|
||||
id: generateId(),
|
||||
role: 'tool',
|
||||
content: '',
|
||||
toolResults: [{
|
||||
toolCallId: typedChunk.toolCallId,
|
||||
content: typeof toolOutput === 'string'
|
||||
? toolOutput
|
||||
: JSON.stringify(toolOutput),
|
||||
isError: false,
|
||||
}],
|
||||
timestamp: Date.now(),
|
||||
executionStatus: 'completed',
|
||||
});
|
||||
lastAddedRole = 'tool';
|
||||
break;
|
||||
}
|
||||
case 'tool-approval-request': {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const typedChunk = chunk as ToolApprovalRequestChunk;
|
||||
pendingApprovalInfo = {
|
||||
approvalId: typedChunk.approvalId,
|
||||
toolCallId: typedChunk.toolCall.toolCallId,
|
||||
toolName: typedChunk.toolCall.toolName,
|
||||
toolArgs: typedChunk.toolCall.args ?? typedChunk.toolCall.input ?? {},
|
||||
};
|
||||
updateMessageById(streamSessionId, activeMsgId, msg => ({
|
||||
...msg,
|
||||
pendingApproval: {
|
||||
...pendingApprovalInfo!,
|
||||
status: 'pending' as const,
|
||||
},
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const typedChunk = chunk as ErrorChunk;
|
||||
updateMessageById(streamSessionId, activeMsgId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
|
||||
}));
|
||||
addMessageToSession(streamSessionId, {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
errorInfo: classifyError(String(typedChunk.error)),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
reader.releaseLock();
|
||||
}
|
||||
return pendingApprovalInfo;
|
||||
}, [maxIterations, addMessageToSession, updateMessageById]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// sendToExternalAgent
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const sendToExternalAgent = useCallback(async (
|
||||
sessionId: string,
|
||||
trimmed: string,
|
||||
agentConfig: ExternalAgentConfig,
|
||||
abortController: AbortController,
|
||||
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string }>,
|
||||
context: SendToExternalContext,
|
||||
) => {
|
||||
const bridge = getNetcattyBridge();
|
||||
|
||||
if (agentConfig.acpCommand && bridge) {
|
||||
const requestId = `acp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
|
||||
// Push terminal session metadata to MCP bridge
|
||||
if (bridge?.aiMcpUpdateSessions) {
|
||||
await bridge.aiMcpUpdateSessions(context.terminalSessions, sessionId);
|
||||
}
|
||||
|
||||
// Pass only the provider ID — the main process resolves and decrypts the API key itself,
|
||||
// avoiding plaintext key transit across the IPC boundary.
|
||||
const openaiProvider = context.providers.find(p => p.providerId === 'openai' && p.enabled && p.apiKey);
|
||||
const agentProviderId = openaiProvider?.id;
|
||||
|
||||
// Mutable flag: set after tool-result, cleared when new assistant msg is created
|
||||
let needsNewAssistantMsg = false;
|
||||
const maybeCreateAssistantMsg = () => {
|
||||
if (needsNewAssistantMsg) {
|
||||
needsNewAssistantMsg = false;
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'assistant', content: '', timestamp: Date.now(),
|
||||
model: agentConfig.name || 'external',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await runAcpAgentTurn(
|
||||
bridge,
|
||||
requestId,
|
||||
sessionId,
|
||||
agentConfig,
|
||||
trimmed,
|
||||
{
|
||||
onTextDelta: (text: string) => {
|
||||
maybeCreateAssistantMsg();
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg,
|
||||
content: msg.content + text,
|
||||
statusText: undefined,
|
||||
thinkingDurationMs: msg.thinking && !msg.thinkingDurationMs
|
||||
? Date.now() - msg.timestamp : msg.thinkingDurationMs,
|
||||
}));
|
||||
},
|
||||
onThinkingDelta: (text: string) => {
|
||||
maybeCreateAssistantMsg();
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg, thinking: (msg.thinking || '') + text,
|
||||
}));
|
||||
},
|
||||
onThinkingDone: () => {
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg, thinkingDurationMs: msg.thinkingDurationMs || (Date.now() - msg.timestamp),
|
||||
}));
|
||||
},
|
||||
onToolCall: (toolName: string, args: Record<string, unknown>) => {
|
||||
maybeCreateAssistantMsg();
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg,
|
||||
toolCalls: [...(msg.toolCalls || []), { id: `tc_${Date.now()}`, name: toolName, arguments: args }],
|
||||
executionStatus: 'running',
|
||||
statusText: undefined,
|
||||
}));
|
||||
},
|
||||
onToolResult: (toolCallId: string, result: string) => {
|
||||
updateLastMessage(sessionId, msg =>
|
||||
msg.role === 'assistant' && msg.executionStatus === 'running'
|
||||
? { ...msg, executionStatus: 'completed', statusText: undefined } : msg,
|
||||
);
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'tool', content: '',
|
||||
toolResults: [{ toolCallId, content: result, isError: false }],
|
||||
timestamp: Date.now(), executionStatus: 'completed',
|
||||
});
|
||||
needsNewAssistantMsg = true;
|
||||
},
|
||||
onStatus: (message: string) => {
|
||||
maybeCreateAssistantMsg();
|
||||
updateLastMessage(sessionId, msg => ({ ...msg, statusText: message }));
|
||||
},
|
||||
onError: (error: string) => {
|
||||
reportStreamError(sessionId, abortController.signal, error);
|
||||
setStreamingForScope(sessionId, false);
|
||||
},
|
||||
onDone: () => {},
|
||||
},
|
||||
abortController.signal,
|
||||
agentProviderId,
|
||||
context.selectedAgentModel,
|
||||
attachedImages.length > 0 ? attachedImages : undefined,
|
||||
);
|
||||
} else {
|
||||
// Fallback: spawn as raw process
|
||||
await runExternalAgentTurn(
|
||||
agentConfig,
|
||||
trimmed,
|
||||
{
|
||||
onTextDelta: (text: string) => {
|
||||
updateLastMessage(sessionId, msg => ({ ...msg, content: msg.content + text }));
|
||||
},
|
||||
onError: (error: string) => {
|
||||
reportStreamError(sessionId, abortController.signal, error);
|
||||
setStreamingForScope(sessionId, false);
|
||||
},
|
||||
onDone: () => {},
|
||||
},
|
||||
bridge as unknown as Parameters<typeof runExternalAgentTurn>[3],
|
||||
abortController.signal,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
addMessageToSession, updateLastMessage, setStreamingForScope, reportStreamError,
|
||||
]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// sendToCattyAgent
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const sendToCattyAgent = useCallback(async (
|
||||
sessionId: string,
|
||||
sendScopeKey: string,
|
||||
trimmed: string,
|
||||
abortController: AbortController,
|
||||
currentSession: AISession | undefined,
|
||||
assistantMsgId: string,
|
||||
context: SendToCattyContext,
|
||||
) => {
|
||||
const bridge = getNetcattyBridge();
|
||||
const tools = createCattyTools(bridge, {
|
||||
sessions: context.terminalSessions,
|
||||
workspaceId: context.scopeTargetId,
|
||||
workspaceName: context.scopeLabel,
|
||||
}, context.commandBlocklist, context.globalPermissionMode);
|
||||
|
||||
const systemPrompt = buildSystemPrompt({
|
||||
scopeType: context.scopeType, scopeLabel: context.scopeLabel,
|
||||
hosts: context.terminalSessions.map(s => ({
|
||||
sessionId: s.sessionId, hostname: s.hostname, label: s.label,
|
||||
os: s.os, username: s.username, connected: s.connected,
|
||||
})),
|
||||
permissionMode: context.globalPermissionMode,
|
||||
});
|
||||
|
||||
// Guard: activeProvider must exist for Catty agent path
|
||||
if (!context.activeProvider) {
|
||||
reportStreamError(sessionId, abortController.signal, 'No AI provider configured. Please configure a provider in Settings → AI.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create model with placeholder API key — the main process injects the real
|
||||
// decrypted key when the HTTP request is proxied through IPC, so plaintext
|
||||
// keys never transit the renderer ↔ main IPC boundary.
|
||||
let model;
|
||||
try {
|
||||
model = createModelFromConfig({
|
||||
...context.activeProvider,
|
||||
defaultModel: context.activeModelId || context.activeProvider.defaultModel || '',
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[Catty] Model creation failed:', e);
|
||||
reportStreamError(sessionId, abortController.signal, `Model creation failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Issue #5: Build SDK messages including tool-call and tool-result messages
|
||||
// so the LLM maintains full conversation context
|
||||
const sdkMessages: Array<ModelMessage> = [];
|
||||
for (const m of (currentSession?.messages ?? [])) {
|
||||
if (m.role === 'user') {
|
||||
sdkMessages.push({ role: 'user', content: m.content });
|
||||
} else if (m.role === 'assistant') {
|
||||
if (m.toolCalls?.length) {
|
||||
// Build assistant content parts: text + tool calls
|
||||
const contentParts: Array<
|
||||
{ type: 'text'; text: string } |
|
||||
{ type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }
|
||||
> = [];
|
||||
if (m.content) {
|
||||
contentParts.push({ type: 'text' as const, text: m.content });
|
||||
}
|
||||
for (const tc of m.toolCalls) {
|
||||
contentParts.push({
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: tc.id,
|
||||
toolName: tc.name,
|
||||
input: tc.arguments ?? {},
|
||||
});
|
||||
}
|
||||
sdkMessages.push({ role: 'assistant', content: contentParts });
|
||||
} else if (m.content) {
|
||||
sdkMessages.push({ role: 'assistant', content: m.content });
|
||||
}
|
||||
} else if (m.role === 'tool' && m.toolResults?.length) {
|
||||
// Map tool results to SDK tool message format
|
||||
sdkMessages.push({
|
||||
role: 'tool',
|
||||
content: m.toolResults.map(tr => ({
|
||||
type: 'tool-result' as const,
|
||||
toolCallId: tr.toolCallId,
|
||||
toolName: '',
|
||||
output: { type: 'text' as const, value: tr.content },
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
sdkMessages.push({ role: 'user', content: trimmed });
|
||||
|
||||
const approvalInfo = await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId);
|
||||
|
||||
if (approvalInfo) {
|
||||
context.setPendingApproval({
|
||||
sessionId, scopeKey: sendScopeKey, sdkMessages, approvalInfo, model, systemPrompt, tools,
|
||||
});
|
||||
return; // Keep streaming flag — waiting for user approval
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Catty] streamText error:', err);
|
||||
reportStreamError(sessionId, abortController.signal, err);
|
||||
} finally {
|
||||
// Clear any lingering statusText when the stream finishes
|
||||
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
|
||||
setStreamingForScope(sessionId, false);
|
||||
abortControllersRef.current.delete(sessionId);
|
||||
context.autoTitleSession(sessionId, trimmed);
|
||||
}
|
||||
}, [
|
||||
processCattyStream, reportStreamError, setStreamingForScope,
|
||||
updateLastMessage,
|
||||
]);
|
||||
|
||||
return {
|
||||
streamingSessionIds,
|
||||
setStreamingForScope,
|
||||
abortControllersRef,
|
||||
processCattyStream,
|
||||
sendToCattyAgent,
|
||||
sendToExternalAgent,
|
||||
reportStreamError,
|
||||
};
|
||||
}
|
||||
76
components/ai/hooks/useConversationExport.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* useConversationExport — Encapsulates conversation export logic for the AI chat panel.
|
||||
*
|
||||
* Handles:
|
||||
* - Export in markdown, JSON, and plain text formats
|
||||
* - Object URL lifecycle management (creation, revocation, cleanup on unmount)
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import type { AISession } from '../../../infrastructure/ai/types';
|
||||
import { exportAsMarkdown, exportAsJSON, exportAsPlainText, getExportFilename } from '../../../infrastructure/ai/conversationExport';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook return type
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export interface UseConversationExportReturn {
|
||||
/** Trigger a download of the active session in the given format. */
|
||||
handleExport: (format: 'md' | 'json' | 'txt') => void;
|
||||
/** Ref to active object URLs for cleanup on unmount (exposed for the parent cleanup effect). */
|
||||
activeObjectUrlsRef: React.MutableRefObject<Set<string>>;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook implementation
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export function useConversationExport(
|
||||
activeSession: AISession | null,
|
||||
): UseConversationExportReturn {
|
||||
// Ref to track active object URLs for cleanup on unmount (Issue #19)
|
||||
const activeObjectUrlsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Clean up object URLs on unmount
|
||||
useEffect(() => {
|
||||
const urls = activeObjectUrlsRef.current;
|
||||
return () => {
|
||||
urls.forEach(url => URL.revokeObjectURL(url));
|
||||
urls.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleExport = useCallback((format: 'md' | 'json' | 'txt') => {
|
||||
if (!activeSession) return;
|
||||
let content: string;
|
||||
switch (format) {
|
||||
case 'md': content = exportAsMarkdown(activeSession); break;
|
||||
case 'json': content = exportAsJSON(activeSession); break;
|
||||
case 'txt': content = exportAsPlainText(activeSession); break;
|
||||
}
|
||||
const filename = getExportFilename(activeSession, format);
|
||||
// Create a download blob
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
// Track URL for cleanup on unmount (Issue #19)
|
||||
activeObjectUrlsRef.current.add(url);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
// Revoke after a generous delay to ensure download completes, then remove from tracking set
|
||||
const revokeTimeout = setTimeout(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
activeObjectUrlsRef.current.delete(url);
|
||||
}, 60_000); // 60 seconds to be safe for large files
|
||||
// If component unmounts before timeout, cleanup effect will revoke it
|
||||
void revokeTimeout; // suppress unused warning
|
||||
}, [activeSession]);
|
||||
|
||||
return {
|
||||
handleExport,
|
||||
activeObjectUrlsRef,
|
||||
};
|
||||
}
|
||||
280
components/ai/hooks/useToolApproval.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* useToolApproval — Encapsulates the tool approval workflow for the AI chat panel.
|
||||
*
|
||||
* Handles:
|
||||
* - Pending approval context management
|
||||
* - Approval timeout (auto-clear after 5 minutes)
|
||||
* - handleApprovalResponse (approve/reject from InlineApprovalCard)
|
||||
* - Resuming the Catty stream after approval
|
||||
*/
|
||||
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import type { ModelMessage } from 'ai';
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
ChatMessage,
|
||||
} from '../../../infrastructure/ai/types';
|
||||
import { buildSystemPrompt } from '../../../infrastructure/ai/cattyAgent/systemPrompt';
|
||||
import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
|
||||
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
|
||||
import type {
|
||||
ApprovalInfo,
|
||||
PendingApprovalContext,
|
||||
TerminalSessionInfo,
|
||||
} from './useAIChatStreaming';
|
||||
import { getNetcattyBridge } from './useAIChatStreaming';
|
||||
import type { createModelFromConfig } from '../../../infrastructure/ai/sdk/providers';
|
||||
|
||||
function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook parameters
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export interface UseToolApprovalParams {
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateLastMessage: (sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
||||
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
||||
setStreamingForScope: (key: string, val: boolean) => void;
|
||||
abortControllersRef: React.MutableRefObject<Map<string, AbortController>>;
|
||||
processCattyStream: (
|
||||
streamSessionId: string,
|
||||
model: ReturnType<typeof createModelFromConfig>,
|
||||
systemPrompt: string,
|
||||
tools: ReturnType<typeof createCattyTools>,
|
||||
sdkMessages: Array<ModelMessage>,
|
||||
signal: AbortSignal,
|
||||
currentAssistantMsgId: string,
|
||||
) => Promise<ApprovalInfo | null>;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook return type
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export interface UseToolApprovalReturn {
|
||||
/** Ref to the current pending approval context (null when none). */
|
||||
pendingApprovalContextRef: React.MutableRefObject<PendingApprovalContext | null>;
|
||||
/** Set or clear the pending approval context (manages timeout). */
|
||||
setPendingApproval: (ctx: PendingApprovalContext | null) => void;
|
||||
/** Handle a user's approve/reject response from InlineApprovalCard. */
|
||||
handleApprovalResponse: (
|
||||
messageId: string,
|
||||
approved: boolean,
|
||||
approvalContext: ToolApprovalContext,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Context values needed by handleApprovalResponse that change frequently. */
|
||||
export interface ToolApprovalContext {
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeTargetId?: string;
|
||||
scopeLabel?: string;
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
commandBlocklist?: string[];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook implementation
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export function useToolApproval({
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
setStreamingForScope,
|
||||
abortControllersRef,
|
||||
processCattyStream,
|
||||
t,
|
||||
}: UseToolApprovalParams): UseToolApprovalReturn {
|
||||
// Pending approval context — stores SDK state needed to resume after user approves/rejects
|
||||
const pendingApprovalContextRef = useRef<PendingApprovalContext | null>(null);
|
||||
|
||||
// Timeout ID for auto-clearing stale pending approval (Issue #14)
|
||||
const pendingApprovalTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
/** Set pending approval context with a 5-minute auto-clear timeout. */
|
||||
const setPendingApproval = useCallback((ctx: PendingApprovalContext | null) => {
|
||||
// Clear any existing timeout
|
||||
if (pendingApprovalTimeoutRef.current) {
|
||||
clearTimeout(pendingApprovalTimeoutRef.current);
|
||||
pendingApprovalTimeoutRef.current = null;
|
||||
}
|
||||
pendingApprovalContextRef.current = ctx;
|
||||
if (ctx) {
|
||||
pendingApprovalTimeoutRef.current = setTimeout(() => {
|
||||
// Auto-clear after 5 minutes if user never responds
|
||||
if (pendingApprovalContextRef.current?.sessionId === ctx.sessionId) {
|
||||
pendingApprovalContextRef.current = null;
|
||||
setStreamingForScope(ctx.sessionId, false);
|
||||
abortControllersRef.current.get(ctx.sessionId)?.abort();
|
||||
abortControllersRef.current.delete(ctx.sessionId);
|
||||
// Notify the user that the approval timed out
|
||||
updateLastMessage(ctx.sessionId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
|
||||
}));
|
||||
addMessageToSession(ctx.sessionId, {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: t('ai.chat.approvalTimeout'),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
pendingApprovalTimeoutRef.current = null;
|
||||
}, 5 * 60 * 1000); // 5 minutes
|
||||
}
|
||||
}, [setStreamingForScope, abortControllersRef, updateLastMessage, addMessageToSession, t]);
|
||||
|
||||
// Handle inline approval response (approve/reject from InlineApprovalCard)
|
||||
const handleApprovalResponse = useCallback(async (
|
||||
messageId: string,
|
||||
approved: boolean,
|
||||
approvalContext: ToolApprovalContext,
|
||||
) => {
|
||||
const ctx = pendingApprovalContextRef.current;
|
||||
if (!ctx) return;
|
||||
// Destructure all needed values BEFORE clearing the ref to avoid race conditions
|
||||
const { sessionId: sid, scopeKey: sk, sdkMessages, approvalInfo, model: ctxModel } = ctx;
|
||||
// Clear pending approval (and its timeout) via setPendingApproval
|
||||
setPendingApproval(null);
|
||||
|
||||
// Update the message's pendingApproval status using message ID
|
||||
updateMessageById(sid, messageId, msg => ({
|
||||
...msg,
|
||||
pendingApproval: msg.pendingApproval
|
||||
? { ...msg.pendingApproval, status: approved ? 'approved' as const : 'denied' as const }
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
if (!approved) {
|
||||
// User rejected — add denial text and stop
|
||||
updateMessageById(sid, messageId, msg => ({
|
||||
...msg,
|
||||
content: msg.content + (msg.content ? '\n\n' : '') + t('ai.chat.toolDenied'),
|
||||
statusText: '',
|
||||
executionStatus: 'completed',
|
||||
}));
|
||||
setStreamingForScope(sid, false);
|
||||
abortControllersRef.current.delete(sid);
|
||||
return;
|
||||
}
|
||||
|
||||
// User approved — construct SDK messages with approval response and resume
|
||||
const resumeMessages: Array<Record<string, unknown>> = [
|
||||
...sdkMessages,
|
||||
// The assistant message that contained the tool call + approval request
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: approvalInfo.toolCallId,
|
||||
toolName: approvalInfo.toolName,
|
||||
input: approvalInfo.toolArgs,
|
||||
},
|
||||
{
|
||||
type: 'tool-approval-request',
|
||||
approvalId: approvalInfo.approvalId,
|
||||
toolCallId: approvalInfo.toolCallId,
|
||||
},
|
||||
],
|
||||
},
|
||||
// The user's approval response
|
||||
{
|
||||
role: 'tool',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-approval-response',
|
||||
approvalId: approvalInfo.approvalId,
|
||||
approved: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Create a new assistant message placeholder for the continuation
|
||||
const newAssistantMsgId = generateId();
|
||||
addMessageToSession(sid, {
|
||||
id: newAssistantMsgId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllersRef.current.set(sid, abortController);
|
||||
|
||||
try {
|
||||
// Rebuild tools and system prompt with the latest permission mode to prevent
|
||||
// stale closure issues (e.g. user changed permission mode during approval wait)
|
||||
const bridge = getNetcattyBridge();
|
||||
const freshTools = createCattyTools(bridge, {
|
||||
sessions: approvalContext.terminalSessions,
|
||||
workspaceId: approvalContext.scopeTargetId,
|
||||
workspaceName: approvalContext.scopeLabel,
|
||||
}, approvalContext.commandBlocklist, approvalContext.globalPermissionMode);
|
||||
const freshSystemPrompt = buildSystemPrompt({
|
||||
scopeType: approvalContext.scopeType, scopeLabel: approvalContext.scopeLabel,
|
||||
hosts: approvalContext.terminalSessions.map(s => ({
|
||||
sessionId: s.sessionId, hostname: s.hostname, label: s.label,
|
||||
os: s.os, username: s.username, connected: s.connected,
|
||||
})),
|
||||
permissionMode: approvalContext.globalPermissionMode,
|
||||
});
|
||||
const newApprovalInfo = await processCattyStream(sid, ctxModel, freshSystemPrompt, freshTools, resumeMessages as unknown as ModelMessage[], abortController.signal, newAssistantMsgId);
|
||||
|
||||
if (newApprovalInfo) {
|
||||
// Another approval needed — save context for the next round (with timeout)
|
||||
setPendingApproval({
|
||||
sessionId: sid,
|
||||
scopeKey: sk,
|
||||
sdkMessages: resumeMessages,
|
||||
approvalInfo: newApprovalInfo,
|
||||
model: ctxModel,
|
||||
systemPrompt: freshSystemPrompt,
|
||||
tools: freshTools,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Catty resume] streamText error:', err);
|
||||
if (!abortController.signal.aborted) {
|
||||
const errorStr = err instanceof Error ? err.message : String(err);
|
||||
updateMessageById(sid, newAssistantMsgId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
|
||||
}));
|
||||
addMessageToSession(sid, {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
errorInfo: classifyError(errorStr),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (!pendingApprovalContextRef.current || pendingApprovalContextRef.current.sessionId !== sid) {
|
||||
// Clear any lingering statusText when the resumed stream finishes
|
||||
updateLastMessage(sid, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
|
||||
setStreamingForScope(sid, false);
|
||||
abortControllersRef.current.delete(sid);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
processCattyStream, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
setStreamingForScope, abortControllersRef, t, setPendingApproval,
|
||||
]);
|
||||
|
||||
return {
|
||||
pendingApprovalContextRef,
|
||||
setPendingApproval,
|
||||
handleApprovalResponse,
|
||||
};
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* Edit Key Panel - Edit existing SSH key
|
||||
*/
|
||||
|
||||
import { Info } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { SSHKey } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
|
||||
interface EditKeyPanelProps {
|
||||
draftKey: Partial<SSHKey>;
|
||||
_originalKey: SSHKey; // Reserved for future diff/comparison feature
|
||||
setDraftKey: (key: Partial<SSHKey>) => void;
|
||||
onExport: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export const EditKeyPanel: React.FC<EditKeyPanelProps> = ({
|
||||
draftKey,
|
||||
_originalKey, // Reserved for future diff/comparison feature
|
||||
setDraftKey,
|
||||
onExport,
|
||||
onSave,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('keychain.field.labelRequired')}</Label>
|
||||
<Input
|
||||
value={draftKey.label || ''}
|
||||
onChange={e => setDraftKey({ ...draftKey, label: e.target.value })}
|
||||
placeholder={t('keychain.field.labelPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-destructive">{t('keychain.field.privateKeyRequired')}</Label>
|
||||
<Textarea
|
||||
value={draftKey.privateKey || ''}
|
||||
onChange={e => setDraftKey({ ...draftKey, privateKey: e.target.value })}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
className="min-h-[180px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">{t('keychain.field.publicKey')}</Label>
|
||||
<Textarea
|
||||
value={draftKey.publicKey || ''}
|
||||
onChange={e => setDraftKey({ ...draftKey, publicKey: e.target.value })}
|
||||
placeholder="ssh-ed25519 AAAA..."
|
||||
className="min-h-[80px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">{t('terminal.auth.certificate')}</Label>
|
||||
<Textarea
|
||||
value={draftKey.certificate || ''}
|
||||
onChange={e => setDraftKey({ ...draftKey, certificate: e.target.value })}
|
||||
placeholder={t('keychain.field.certificatePlaceholder')}
|
||||
className="min-h-[60px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Key Export section */}
|
||||
<div className="pt-4 mt-4 border-t border-border/60">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-sm font-medium">{t('keychain.export.title')}</span>
|
||||
<div className="h-4 w-4 rounded-full bg-muted flex items-center justify-center">
|
||||
<Info size={10} className="text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full h-11" onClick={onExport}>
|
||||
{t('keychain.export.exportToHost')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Save button */}
|
||||
<Button
|
||||
className="w-full h-11 mt-4"
|
||||
disabled={!draftKey.label?.trim() || !draftKey.privateKey?.trim()}
|
||||
onClick={onSave}
|
||||
>
|
||||
{t('common.saveChanges')}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,246 +0,0 @@
|
||||
/**
|
||||
* Export Key Panel - Export SSH key to remote host
|
||||
*/
|
||||
|
||||
import { ChevronRight, Info } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useKeychainBackend } from '../../application/state/useKeychainBackend';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { resolveHostAuth } from '../../domain/sshAuth';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Host, Identity, SSHKey } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
import { toast } from '../ui/toast';
|
||||
import { getKeyIcon, getKeyTypeDisplay, isMacOS } from './utils';
|
||||
|
||||
interface ExportKeyPanelProps {
|
||||
keyItem: SSHKey;
|
||||
_hosts: Host[]; // Reserved for future inline host list/validation
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
exportHost: Host | null;
|
||||
_setExportHost: (host: Host | null) => void; // Host selection handled by onShowHostSelector callback
|
||||
onShowHostSelector: () => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_EXPORT_SCRIPT = `DIR="$HOME/$1"
|
||||
FILE="$DIR/$2"
|
||||
if [ ! -d "$DIR" ]; then
|
||||
mkdir -p "$DIR"
|
||||
chmod 700 "$DIR"
|
||||
fi
|
||||
if [ ! -f "$FILE" ]; then
|
||||
touch "$FILE"
|
||||
chmod 600 "$FILE"
|
||||
fi
|
||||
echo $3 >> "$FILE"`;
|
||||
|
||||
export const ExportKeyPanel: React.FC<ExportKeyPanelProps> = ({
|
||||
keyItem,
|
||||
_hosts, // Reserved for future inline host list/validation
|
||||
keys,
|
||||
identities,
|
||||
exportHost,
|
||||
_setExportHost, // Host selection handled by onShowHostSelector callback
|
||||
onShowHostSelector,
|
||||
onSaveHost,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { execCommand } = useKeychainBackend();
|
||||
const [exportLocation, setExportLocation] = useState('.ssh');
|
||||
const [exportFilename, setExportFilename] = useState('authorized_keys');
|
||||
const [exportAdvancedOpen, setExportAdvancedOpen] = useState(false);
|
||||
const [exportScript, setExportScript] = useState(DEFAULT_EXPORT_SCRIPT);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const isMac = isMacOS();
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!exportHost || !keyItem.publicKey) return;
|
||||
|
||||
setIsExporting(true);
|
||||
|
||||
try {
|
||||
const exportAuth = resolveHostAuth({ host: exportHost, keys, identities });
|
||||
|
||||
// Check for authentication method
|
||||
if (!exportAuth.password && !exportAuth.key?.privateKey) {
|
||||
throw new Error(t('keychain.export.missingCredentials'));
|
||||
}
|
||||
|
||||
const hostPrivateKey = exportAuth.key?.privateKey;
|
||||
|
||||
// Escape the public key for shell
|
||||
const escapedPublicKey = keyItem.publicKey.replace(/'/g, "'\\''");
|
||||
|
||||
// Build the command by replacing $1, $2, $3
|
||||
const scriptWithVars = exportScript
|
||||
.replace(/\$1/g, exportLocation)
|
||||
.replace(/\$2/g, exportFilename)
|
||||
.replace(/\$3/g, `'${escapedPublicKey}'`);
|
||||
|
||||
const command = scriptWithVars;
|
||||
|
||||
// Execute via SSH
|
||||
const result = await execCommand({
|
||||
hostname: exportHost.hostname,
|
||||
username: exportAuth.username,
|
||||
port: exportHost.port || 22,
|
||||
password: exportAuth.password,
|
||||
privateKey: hostPrivateKey,
|
||||
command,
|
||||
timeout: 30000,
|
||||
enableKeyboardInteractive: true,
|
||||
sessionId: `export-key:${exportHost.id}:${keyItem.id}`,
|
||||
});
|
||||
|
||||
// Check result
|
||||
const exitCode = result?.code;
|
||||
const hasError = result?.stderr?.trim();
|
||||
if (exitCode === 0 || (exitCode == null && !hasError)) {
|
||||
// Update host to use this key for authentication
|
||||
if (onSaveHost) {
|
||||
const updatedHost: Host = {
|
||||
...exportHost,
|
||||
identityFileId: keyItem.id,
|
||||
authMethod: 'key',
|
||||
};
|
||||
onSaveHost(updatedHost);
|
||||
}
|
||||
toast.success(
|
||||
t('keychain.export.successMessage', { host: exportHost.label }),
|
||||
t('keychain.export.successTitle'),
|
||||
);
|
||||
onClose();
|
||||
} else {
|
||||
const errorMsg = hasError || result?.stdout?.trim() || `Command exited with code ${exitCode}`;
|
||||
toast.error(
|
||||
t('keychain.export.failedMessage', { error: errorMsg }),
|
||||
t('keychain.export.failedTitle'),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
toast.error(
|
||||
t('keychain.export.failedGeneric', { message }),
|
||||
t('keychain.export.failedTitle'),
|
||||
);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Key info card */}
|
||||
<div className="flex items-center gap-3 p-3 bg-card border border-border/80 rounded-lg">
|
||||
<div className={cn(
|
||||
"h-10 w-10 rounded-md flex items-center justify-center",
|
||||
keyItem.certificate
|
||||
? "bg-emerald-500/15 text-emerald-500"
|
||||
: "bg-primary/15 text-primary"
|
||||
)}>
|
||||
{getKeyIcon(keyItem)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold truncate">{keyItem.label}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('auth.keyType', { type: getKeyTypeDisplay(keyItem, isMac) })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export to field */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-muted-foreground">{t('keychain.export.exportToRequired')}</Label>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 text-primary text-sm"
|
||||
onClick={onShowHostSelector}
|
||||
>
|
||||
{t('keychain.export.selectHost')}
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={exportHost?.label || ''}
|
||||
readOnly
|
||||
placeholder={t('keychain.export.selectHostPlaceholder')}
|
||||
className="bg-muted/50 cursor-pointer"
|
||||
onClick={onShowHostSelector}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Location field */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">{t('keychain.export.locationLabel')}</Label>
|
||||
<Input
|
||||
value={exportLocation}
|
||||
onChange={e => setExportLocation(e.target.value)}
|
||||
placeholder=".ssh"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filename field */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">{t('keychain.export.filenameLabel')}</Label>
|
||||
<Input
|
||||
value={exportFilename}
|
||||
onChange={e => setExportFilename(e.target.value)}
|
||||
placeholder="authorized_keys"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info note */}
|
||||
<div className="flex items-start gap-2 p-3 bg-muted/50 border border-border/60 rounded-lg">
|
||||
<Info size={14} className="mt-0.5 text-muted-foreground shrink-0" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('keychain.export.note.supportsOnly')}{' '}
|
||||
<span className="font-semibold text-foreground">UNIX</span>{' '}
|
||||
{t('keychain.export.note.systems')}{' '}
|
||||
{t('keychain.export.note.use')}{' '}
|
||||
<span className="font-semibold text-foreground">{t('keychain.export.advanced')}</span>{' '}
|
||||
{t('keychain.export.note.customize')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Advanced collapsible */}
|
||||
<Collapsible open={exportAdvancedOpen} onOpenChange={setExportAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="w-full justify-between px-0 h-10 hover:bg-transparent hover:text-current">
|
||||
<span className="font-medium">{t('keychain.export.advanced')}</span>
|
||||
<ChevronRight size={16} className={cn(
|
||||
"transition-transform",
|
||||
exportAdvancedOpen && "rotate-90"
|
||||
)} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 pt-2">
|
||||
<Label className="text-muted-foreground">{t('keychain.export.scriptRequired')}</Label>
|
||||
<Textarea
|
||||
value={exportScript}
|
||||
onChange={e => setExportScript(e.target.value)}
|
||||
className="min-h-[180px] font-mono text-xs"
|
||||
placeholder={t('keychain.export.scriptPlaceholder')}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Export button */}
|
||||
<Button
|
||||
className="w-full h-11"
|
||||
disabled={!exportHost || !exportLocation || !exportFilename || isExporting}
|
||||
onClick={handleExport}
|
||||
>
|
||||
{isExporting ? t('keychain.export.exporting') : t('keychain.export.exportAndAttach')}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -15,8 +15,6 @@ export { IdentityCard } from './IdentityCard';
|
||||
export { KeyCard } from './KeyCard';
|
||||
|
||||
// Panel components
|
||||
export { EditKeyPanel } from './EditKeyPanel';
|
||||
export { ExportKeyPanel } from './ExportKeyPanel';
|
||||
export { GenerateStandardPanel } from './GenerateStandardPanel';
|
||||
export { IdentityPanel } from './IdentityPanel';
|
||||
export { ImportKeyPanel } from './ImportKeyPanel';
|
||||
|
||||
@@ -5,16 +5,18 @@
|
||||
import { Copy,Loader2,Pencil,Play,Square,Trash2 } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { PortForwardingRule } from '../../domain/models';
|
||||
import { Host, PortForwardingRule } from '../../domain/models';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { ContextMenu,ContextMenuContent,ContextMenuItem,ContextMenuSeparator,ContextMenuTrigger } from '../ui/context-menu';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
|
||||
import { getStatusColor,getTypeColor } from './utils';
|
||||
|
||||
export type ViewMode = 'grid' | 'list';
|
||||
|
||||
export interface RuleCardProps {
|
||||
rule: PortForwardingRule;
|
||||
host?: Host; // The relay host for this rule (for tooltip display)
|
||||
viewMode: ViewMode;
|
||||
isSelected: boolean;
|
||||
isPending: boolean;
|
||||
@@ -28,6 +30,7 @@ export interface RuleCardProps {
|
||||
|
||||
export const RuleCard: React.FC<RuleCardProps> = ({
|
||||
rule,
|
||||
host,
|
||||
viewMode,
|
||||
isSelected,
|
||||
isPending,
|
||||
@@ -74,12 +77,39 @@ export const RuleCard: React.FC<RuleCardProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span className="truncate">
|
||||
{rule.type === 'dynamic'
|
||||
? t('pf.rule.summary.dynamic', { bindAddress: rule.bindAddress, localPort: rule.localPort })
|
||||
: t('pf.rule.summary.default', { bindAddress: rule.bindAddress, localPort: rule.localPort, remoteHost: rule.remoteHost, remotePort: rule.remotePort })
|
||||
}
|
||||
</span>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="truncate cursor-default">
|
||||
{rule.type === 'dynamic'
|
||||
? t('pf.rule.summary.dynamic', { bindAddress: rule.bindAddress, localPort: rule.localPort })
|
||||
: t('pf.rule.summary.default', { bindAddress: rule.bindAddress, localPort: rule.localPort, remoteHost: rule.remoteHost, remotePort: rule.remotePort })
|
||||
}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-xs">
|
||||
<div className="space-y-1 text-xs">
|
||||
{host ? (
|
||||
<>
|
||||
<div className="font-medium">{t('pf.tooltip.relayHost')}</div>
|
||||
<div>{t('pf.tooltip.hostLabel')}: {host.label}</div>
|
||||
<div>{t('pf.tooltip.hostAddress')}: {host.username}@{host.hostname}:{host.port}</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-muted-foreground">{t('pf.tooltip.noHost')}</div>
|
||||
)}
|
||||
<div className="border-t border-border/40 pt-1 mt-1">
|
||||
{rule.type === 'dynamic'
|
||||
? t('pf.tooltip.dynamicDesc')
|
||||
: rule.type === 'local'
|
||||
? t('pf.tooltip.localDesc')
|
||||
: t('pf.tooltip.remoteDesc')
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 { useCustomThemes } from '../../application/state/customThemeStore';
|
||||
import { Button } from '../ui/button';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
@@ -74,6 +75,8 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
|
||||
return { darkThemes: dark, lightThemes: light };
|
||||
}, []);
|
||||
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
// Handle theme selection - select and close
|
||||
const handleThemeSelect = useCallback((themeId: string) => {
|
||||
onSelect(themeId);
|
||||
@@ -164,6 +167,25 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Themes Section */}
|
||||
{customThemes.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
|
||||
{t('terminal.customTheme.section')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{customThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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 { ScrollArea } from "../ui/scroll-area";
|
||||
import { TabsContent } from "../ui/tabs";
|
||||
@@ -32,29 +34,78 @@ export const Toggle: React.FC<ToggleProps> = ({ checked, onChange, disabled }) =
|
||||
|
||||
interface SelectProps {
|
||||
value: string;
|
||||
options: { value: string; label: string }[];
|
||||
options: { value: string; label: string; icon?: React.ReactNode }[];
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const Select: React.FC<SelectProps> = ({ value, options, onChange, className, disabled }) => (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"h-9 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",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
export const Select: React.FC<SelectProps> = ({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
className,
|
||||
disabled,
|
||||
placeholder,
|
||||
}) => {
|
||||
const selectedOption = options.find((opt) => opt.value === 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 placeholder={placeholder}>
|
||||
<span className="flex items-center gap-2">
|
||||
{selectedOption?.icon}
|
||||
{selectedOption?.label}
|
||||
</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)]">
|
||||
{options.map((opt) => (
|
||||
<SelectPrimitive.Item
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
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 className="flex items-center gap-2">
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</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 const SectionHeader: React.FC<{ title: string; className?: string }> = ({
|
||||
title,
|
||||
@@ -87,4 +138,3 @@ export const SettingsTabContent: React.FC<{
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
);
|
||||
|
||||
|
||||