Compare commits
258 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aad02a914 | ||
|
|
76baf87c29 | ||
|
|
2a75f863f8 | ||
|
|
262bc57a21 | ||
|
|
9563ae9dcc | ||
|
|
349b215d3d | ||
|
|
7639191c50 | ||
|
|
c3224d30c6 | ||
|
|
40d80fe535 | ||
|
|
ce1a00bed9 | ||
|
|
7df88f5bf7 | ||
|
|
eeb42b1d20 | ||
|
|
23475fb1ce | ||
|
|
fadd84606a | ||
|
|
d3e1a96702 | ||
|
|
91fd44cccf | ||
|
|
5b6f45c896 | ||
|
|
c924259fc0 | ||
|
|
f896f2a071 | ||
|
|
1851a8de71 | ||
|
|
53dd266f42 | ||
|
|
5e05d25c2b | ||
|
|
2d57015ac5 | ||
|
|
579dab56c2 | ||
|
|
f1fea53af6 | ||
|
|
aabae00970 | ||
|
|
9136569809 | ||
|
|
f2bcbe5123 | ||
|
|
3dcb792a55 | ||
|
|
5ca996d2d2 | ||
|
|
9ea1c3a92e | ||
|
|
af85401a69 | ||
|
|
5d3af6d107 | ||
|
|
68ab65764e | ||
|
|
514bea824a | ||
|
|
de874fc8c5 | ||
|
|
14ba1e779c | ||
|
|
0c1e269718 | ||
|
|
a96f5c332c | ||
|
|
a0b8d74582 | ||
|
|
e6166a1de3 | ||
|
|
ae797e5fb1 | ||
|
|
9a7d4decff | ||
|
|
fa29515095 | ||
|
|
34f9d2a663 | ||
|
|
90d161c1b5 | ||
|
|
7a5b6f506e | ||
|
|
c49346f6cc | ||
|
|
39a398aa2b | ||
|
|
0b7c52523e | ||
|
|
cb63f105aa | ||
|
|
316e46de4b | ||
|
|
1af5182b59 | ||
|
|
35194036cb | ||
|
|
6a077a3855 | ||
|
|
43f4687bb9 | ||
|
|
bbb888ae1e | ||
|
|
c74b78a49d | ||
|
|
7b2590e54e | ||
|
|
a7f42ec93e | ||
|
|
a886d509f8 | ||
|
|
d6fea6c328 | ||
|
|
b6169f1735 | ||
|
|
c97470a085 | ||
|
|
98cb9d09df | ||
|
|
9deb39dec2 | ||
|
|
bb45279d4e | ||
|
|
6b1d9ee409 | ||
|
|
c0c0378df0 | ||
|
|
093951150c | ||
|
|
a0418039c4 | ||
|
|
559e71cfcc | ||
|
|
a0a2567fa5 | ||
|
|
d080a43ae6 | ||
|
|
2c551cf5e8 | ||
|
|
c54aa52191 | ||
|
|
b8c838059a | ||
|
|
007b4bd389 | ||
|
|
13fd198243 | ||
|
|
2c562463c4 | ||
|
|
859d4b8156 | ||
|
|
c6e07cf149 | ||
|
|
0ab18ce186 | ||
|
|
f814719b32 | ||
|
|
ee6b05892d | ||
|
|
0f98ffd4f7 | ||
|
|
7ca5d0c832 | ||
|
|
1a76d34696 | ||
|
|
0b2d1b613b | ||
|
|
ded989b374 | ||
|
|
04c6348bc0 | ||
|
|
54297859e3 | ||
|
|
d236adcd48 | ||
|
|
4971f18bbe | ||
|
|
15687bd56e | ||
|
|
76675ec515 | ||
|
|
7c6304c355 | ||
|
|
8fdcbf87c2 | ||
|
|
0326ba7556 | ||
|
|
964230a737 | ||
|
|
5d551ee8e9 | ||
|
|
ec4e209972 | ||
|
|
c141fbc11e | ||
|
|
8e61ccac91 | ||
|
|
7c5047f22e | ||
|
|
c10100a314 | ||
|
|
5a294aa306 | ||
|
|
54b3ba2c01 | ||
|
|
f25822fdae | ||
|
|
69f433c161 | ||
|
|
6087343203 | ||
|
|
bb63de2658 | ||
|
|
fd938a84e4 | ||
|
|
c2e629ad61 | ||
|
|
4bf61c02a0 | ||
|
|
4747217929 | ||
|
|
fb3cdd0661 | ||
|
|
11ca8fba87 | ||
|
|
7ffc4b4c7f | ||
|
|
fe27dd8a9d | ||
|
|
eca11e9d2a | ||
|
|
779aa31ef8 | ||
|
|
2c8670a6c6 | ||
|
|
a94293d31e | ||
|
|
04b62f7ba3 | ||
|
|
45794b7f6f | ||
|
|
314072a631 | ||
|
|
c9f1951e28 | ||
|
|
7f83b22c95 | ||
|
|
b7082ab198 | ||
|
|
9369495e22 | ||
|
|
e3fdb1f7ff | ||
|
|
b9bc6b95e5 | ||
|
|
5cbaae8d2f | ||
|
|
915e571c63 | ||
|
|
86a43655e1 | ||
|
|
e47d86874f | ||
|
|
369de6fff2 | ||
|
|
3aa414ad05 | ||
|
|
356c27d0fb | ||
|
|
ae94e7e529 | ||
|
|
5828503ffc | ||
|
|
1c0f45e410 | ||
|
|
5c791cebe5 | ||
|
|
0ce6b0f777 | ||
|
|
6fca38a209 | ||
|
|
52541a6066 | ||
|
|
6d35301436 | ||
|
|
5d29c8d91a | ||
|
|
196b1f8dbb | ||
|
|
f1065745bc | ||
|
|
c67befa0e9 | ||
|
|
cea83d6cb1 | ||
|
|
293ee46b26 | ||
|
|
a6af1dffed | ||
|
|
0a3e61af4b | ||
|
|
9e4a79acd7 | ||
|
|
a62353bb41 | ||
|
|
d2ab27ab92 | ||
|
|
65f62983b6 | ||
|
|
56d3109d23 | ||
|
|
34ab6c0e98 | ||
|
|
3db9b0aa26 | ||
|
|
fe49ea74e2 | ||
|
|
be91740582 | ||
|
|
ad15d8ceb5 | ||
|
|
c37fe8f9e0 | ||
|
|
b0924c14b1 | ||
|
|
774c25086e | ||
|
|
05c0d43bc4 | ||
|
|
baac8670d3 | ||
|
|
c84bf497f2 | ||
|
|
ac5f708eba | ||
|
|
ecba2560c9 | ||
|
|
ff638c64cd | ||
|
|
3db6465340 | ||
|
|
2b4f8d33c9 | ||
|
|
bc6c0a2ef6 | ||
|
|
9cccc943ff | ||
|
|
cecda50ce2 | ||
|
|
c136006108 | ||
|
|
ba073219e5 | ||
|
|
034e5ea3bc | ||
|
|
6b24e38326 | ||
|
|
b972866c8e | ||
|
|
8c541fb6e2 | ||
|
|
b73e60fb6d | ||
|
|
a40e2f1ca7 | ||
|
|
834a677cfe | ||
|
|
55ee08315a | ||
|
|
a712b96d57 | ||
|
|
f5b745ec63 | ||
|
|
3a5dd62791 | ||
|
|
1233277277 | ||
|
|
6f5361c715 | ||
|
|
bea785abae | ||
|
|
27829d7a4b | ||
|
|
4d09227bed | ||
|
|
16415299ae | ||
|
|
dfc9a4efdd | ||
|
|
254c6da4ca | ||
|
|
81063419de | ||
|
|
fee7da5aad | ||
|
|
66b4908686 | ||
|
|
9e6e9eab87 | ||
|
|
41606eacf0 | ||
|
|
795970b524 | ||
|
|
5b52413d97 | ||
|
|
3c17476809 | ||
|
|
874a2b19df | ||
|
|
a9c862fe96 | ||
|
|
cbd53ed2a3 | ||
|
|
c2b94ea3bd | ||
|
|
6189c31af2 | ||
|
|
a0dce5d4a6 | ||
|
|
dcaf25ae57 | ||
|
|
3fd5e1128b | ||
|
|
cb8c06e152 | ||
|
|
cabc82e1df | ||
|
|
91191d6603 | ||
|
|
17e98090ad | ||
|
|
ab371a53be | ||
|
|
67706e4db3 | ||
|
|
53aaf06d6c | ||
|
|
ac8e9c0dfc | ||
|
|
f4bbe62a1d | ||
|
|
57e131a16e | ||
|
|
ea6f9e138c | ||
|
|
5177ce2028 | ||
|
|
9f44112479 | ||
|
|
6999f362a3 | ||
|
|
fc546c2430 | ||
|
|
f7e4953038 | ||
|
|
922376fa06 | ||
|
|
3d4ca46c9b | ||
|
|
1d8f203f5b | ||
|
|
41d079a806 | ||
|
|
93c95959d3 | ||
|
|
e7300429f8 | ||
|
|
c7743d082a | ||
|
|
56a4fe905d | ||
|
|
b17775307f | ||
|
|
be7aa4ae52 | ||
|
|
f4872099bd | ||
|
|
4e2089d7e2 | ||
|
|
5f28320c57 | ||
|
|
4e26852482 | ||
|
|
c4fb19cafb | ||
|
|
09e6526142 | ||
|
|
7ce110c3fb | ||
|
|
667ee18ed3 | ||
|
|
f969b1b73d | ||
|
|
58a4bf892a | ||
|
|
5052a8231f | ||
|
|
13c9cf16fd | ||
|
|
63558b5301 | ||
|
|
c2b4d43531 | ||
|
|
4d5c0eed69 |
80
.github/workflows/build.yml
vendored
80
.github/workflows/build.yml
vendored
@@ -93,6 +93,8 @@ jobs:
|
||||
name: build-linux-x64
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
npm_config_arch: x64
|
||||
npm_config_target_arch: x64
|
||||
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 }}
|
||||
@@ -121,11 +123,23 @@ jobs:
|
||||
echo "Setting version to ${VERSION}"
|
||||
npm pkg set version="${VERSION}"
|
||||
|
||||
- name: Prepare node-pty Linux runtime
|
||||
env:
|
||||
npm_config_arch: x64
|
||||
run: bash scripts/ensure-node-pty-linux.sh prepare x64
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
npm_config_arch: x64
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:linux-x64
|
||||
|
||||
- name: Verify packaged node-pty Linux runtime
|
||||
run: bash scripts/ensure-node-pty-linux.sh verify x64
|
||||
|
||||
- name: Verify packaged deb artifact
|
||||
run: bash scripts/verify-linux-deb-artifact.sh amd64
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -147,6 +161,8 @@ jobs:
|
||||
container:
|
||||
image: debian:bullseye
|
||||
env:
|
||||
npm_config_arch: arm64
|
||||
npm_config_target_arch: arm64
|
||||
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 }}
|
||||
@@ -155,7 +171,9 @@ jobs:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y curl build-essential python3 git libfuse2 file rpm
|
||||
apt-get install -y curl build-essential python3 git libfuse2 file rpm \
|
||||
libglib2.0-0 libgtk-3-0 libnss3 libxss1 libxtst6 libasound2 \
|
||||
libatk-bridge2.0-0 libdrm2 libgbm1 libx11-xcb1 libxcb-dri3-0
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
apt-get install -y nodejs
|
||||
|
||||
@@ -176,12 +194,23 @@ jobs:
|
||||
echo "Setting version to ${VERSION}"
|
||||
npm pkg set version="${VERSION}"
|
||||
|
||||
- name: Prepare node-pty Linux runtime
|
||||
env:
|
||||
npm_config_arch: arm64
|
||||
run: bash scripts/ensure-node-pty-linux.sh prepare arm64
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
npm_config_arch: arm64
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:linux-arm64
|
||||
|
||||
- name: Verify packaged node-pty Linux runtime
|
||||
run: bash scripts/ensure-node-pty-linux.sh verify arm64
|
||||
|
||||
- name: Verify packaged deb artifact
|
||||
run: bash scripts/verify-linux-deb-artifact.sh arm64
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -201,6 +230,7 @@ jobs:
|
||||
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -214,6 +244,54 @@ jobs:
|
||||
- name: List artifacts
|
||||
run: ls -la artifacts/
|
||||
|
||||
- name: Verify update metadata files
|
||||
run: |
|
||||
missing=0
|
||||
for f in latest-mac.yml latest.yml latest-linux.yml latest-linux-arm64.yml; do
|
||||
if [ ! -f "artifacts/$f" ]; then
|
||||
echo "::warning::Missing $f in merged artifacts, attempting recovery..."
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
if [ "$missing" = "1" ]; then
|
||||
echo "Re-downloading individual artifacts to recover missing files..."
|
||||
for name in netcatty-macos netcatty-windows netcatty-linux-x64 netcatty-linux-arm64; do
|
||||
tmpdir="/tmp/artifact-${name}"
|
||||
gh run download ${{ github.run_id }} --name "${name}" --dir "${tmpdir}" 2>/dev/null || true
|
||||
if [ -d "${tmpdir}" ]; then
|
||||
for yml in "${tmpdir}"/latest*.yml; do
|
||||
[ -f "$yml" ] && cp -v "$yml" artifacts/
|
||||
done
|
||||
fi
|
||||
done
|
||||
echo "After recovery:"
|
||||
ls -la artifacts/*.yml
|
||||
fi
|
||||
# Final check — fail if any update yml is still missing
|
||||
for f in latest-mac.yml latest.yml latest-linux.yml latest-linux-arm64.yml; do
|
||||
if [ ! -f "artifacts/$f" ]; then
|
||||
echo "::error::$f is still missing after recovery attempt"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "All update metadata files present."
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Verify downloaded Linux amd64 deb artifact
|
||||
run: |
|
||||
deb_file="$(find artifacts -maxdepth 1 -type f -name '*-linux-amd64.deb' -print | sort | head -n 1)"
|
||||
test -n "${deb_file}"
|
||||
bash scripts/verify-linux-deb-artifact.sh amd64 "${deb_file}"
|
||||
|
||||
- name: Verify downloaded Linux arm64 deb artifact metadata
|
||||
env:
|
||||
VERIFY_LOAD: "0"
|
||||
run: |
|
||||
deb_file="$(find artifacts -maxdepth 1 -type f -name '*-linux-arm64.deb' -print | sort | head -n 1)"
|
||||
test -n "${deb_file}"
|
||||
bash scripts/verify-linux-deb-artifact.sh arm64 "${deb_file}"
|
||||
|
||||
- name: Generate Release Body
|
||||
run: node .github/scripts/generate-release-note.js
|
||||
env:
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -37,6 +37,9 @@ coverage
|
||||
|
||||
# Claude Code
|
||||
/.claude/
|
||||
|
||||
# Codex
|
||||
/.codex/
|
||||
/CLAUDE.md
|
||||
|
||||
# AI / Superpowers generated docs (local only)
|
||||
|
||||
514
App.tsx
514
App.tsx
@@ -1,6 +1,7 @@
|
||||
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { Suspense, lazy, useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from './application/state/activeTabStore';
|
||||
import { useAutoSync } from './application/state/useAutoSync';
|
||||
import { useImmersiveMode } from './application/state/useImmersiveMode';
|
||||
import { useManagedSourceSync } from './application/state/useManagedSourceSync';
|
||||
import { usePortForwardingAutoStart } from './application/state/usePortForwardingAutoStart';
|
||||
import { usePortForwardingState } from './application/state/usePortForwardingState';
|
||||
@@ -14,10 +15,15 @@ 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 { resolveHostTerminalThemeId } from './domain/terminalAppearance';
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from './application/state/customThemeStore';
|
||||
import { applySyncPayload } from './application/syncPayload';
|
||||
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
|
||||
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
|
||||
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
|
||||
import { STORAGE_KEY_DEBUG_HOTKEYS } from './infrastructure/config/storageKeys';
|
||||
import { TopTabs } from './components/TopTabs';
|
||||
import { Button } from './components/ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './components/ui/dialog';
|
||||
@@ -28,7 +34,8 @@ import { VaultView, VaultSection } from './components/VaultView';
|
||||
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
|
||||
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
|
||||
import { cn } from './lib/utils';
|
||||
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
|
||||
import { classifyLocalShellType } from './lib/localShell';
|
||||
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalSession, TerminalTheme } from './types';
|
||||
import { LogView as LogViewType } from './application/state/useSessionState';
|
||||
import type { SftpView as SftpViewComponent } from './components/SftpView';
|
||||
import type { TerminalLayer as TerminalLayerComponent } from './components/TerminalLayer';
|
||||
@@ -97,8 +104,7 @@ const LazyCreateWorkspaceDialog = lazy(() =>
|
||||
const IS_DEV = import.meta.env.DEV;
|
||||
const HOTKEY_DEBUG =
|
||||
IS_DEV &&
|
||||
typeof window !== "undefined" &&
|
||||
window.localStorage?.getItem("debug.hotkeys") === "1";
|
||||
localStorageAdapter.readString(STORAGE_KEY_DEBUG_HOTKEYS) === "1";
|
||||
|
||||
const LazySftpView = lazy(() =>
|
||||
import('./components/SftpView').then((m) => ({ default: m.SftpView })),
|
||||
@@ -171,6 +177,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const {
|
||||
setTheme,
|
||||
resolvedTheme,
|
||||
terminalThemeId,
|
||||
setTerminalThemeId,
|
||||
currentTerminalTheme,
|
||||
terminalFontFamilyId,
|
||||
@@ -185,11 +192,14 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
sftpAutoOpenSidebar,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
sessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
reapplyCurrentTheme,
|
||||
immersiveMode,
|
||||
} = settings;
|
||||
|
||||
const {
|
||||
@@ -269,6 +279,74 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// isMacClient is used for window controls styling
|
||||
const isMacClient = typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Immersive Mode — derive UI chrome colors from the active terminal's theme
|
||||
// ---------------------------------------------------------------------------
|
||||
const activeTabId = useActiveTabId();
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
// Resolve the effective TerminalTheme for the currently focused terminal tab
|
||||
const hostById = useMemo(
|
||||
() => new Map(hosts.map((host) => [host.id, host])),
|
||||
[hosts],
|
||||
);
|
||||
const sessionById = useMemo(
|
||||
() => new Map(sessions.map((session) => [session.id, session])),
|
||||
[sessions],
|
||||
);
|
||||
const workspaceById = useMemo(
|
||||
() => new Map(workspaces.map((workspace) => [workspace.id, workspace])),
|
||||
[workspaces],
|
||||
);
|
||||
const themeById = useMemo(
|
||||
() => new Map([...customThemes, ...TERMINAL_THEMES].map((theme) => [theme.id, theme])),
|
||||
[customThemes],
|
||||
);
|
||||
const activeTerminalTheme = useMemo<TerminalTheme | null>(() => {
|
||||
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
|
||||
|
||||
const resolveTheme = (s: TerminalSession): TerminalTheme => {
|
||||
const host = hostById.get(s.hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
return themeById.get(themeId) || currentTerminalTheme;
|
||||
};
|
||||
|
||||
// Workspace
|
||||
const workspace = workspaceById.get(activeTabId);
|
||||
if (workspace) {
|
||||
// Focus mode: use the focused (or first remaining) session's theme
|
||||
if (workspace.viewMode === 'focus') {
|
||||
const wsSessionIds = collectSessionIds(workspace.root);
|
||||
const focused = (workspace.focusedSessionId
|
||||
? sessionById.get(workspace.focusedSessionId)
|
||||
: null)
|
||||
?? wsSessionIds.map((id) => sessionById.get(id)).find(Boolean);
|
||||
return focused ? resolveTheme(focused) : null;
|
||||
}
|
||||
// Split mode: require all sessions to share the same theme
|
||||
const sessionIds = collectSessionIds(workspace.root);
|
||||
const wsSessions = sessionIds
|
||||
.map((id) => sessionById.get(id))
|
||||
.filter(Boolean) as TerminalSession[];
|
||||
if (wsSessions.length === 0) return null;
|
||||
const firstTheme = resolveTheme(wsSessions[0]);
|
||||
const allSame = wsSessions.every(s => resolveTheme(s).id === firstTheme.id);
|
||||
return allSame ? firstTheme : null;
|
||||
}
|
||||
|
||||
// Single session tab
|
||||
const session = sessionById.get(activeTabId);
|
||||
if (!session) return null;
|
||||
return resolveTheme(session);
|
||||
}, [activeTabId, currentTerminalTheme, hostById, sessionById, themeById, workspaceById]);
|
||||
|
||||
useImmersiveMode({
|
||||
isImmersive: immersiveMode,
|
||||
activeTabId,
|
||||
activeTerminalTheme,
|
||||
restoreOriginalTheme: reapplyCurrentTheme,
|
||||
});
|
||||
|
||||
// Get port forwarding rules and import function
|
||||
const { rules: portForwardingRules, importRules: importPortForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
|
||||
|
||||
@@ -314,10 +392,148 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, [handleSyncNow]);
|
||||
|
||||
// Update check hook - checks for new versions on startup
|
||||
const { updateState, dismissUpdate, openReleasePage, installUpdate } = useUpdateCheck();
|
||||
const { updateState, dismissUpdate, installUpdate } = useUpdateCheck();
|
||||
|
||||
// Window controls - must be before update toast effect which uses openSettingsWindow
|
||||
const { openSettingsWindow } = useWindowControls();
|
||||
const _handleTrayJumpToSession = useEffectEvent((sessionId: string) => {
|
||||
const session = sessions.find((item) => item.id === sessionId);
|
||||
if (session?.workspaceId) {
|
||||
setActiveTabId(session.workspaceId);
|
||||
setWorkspaceFocusedSession(session.workspaceId, sessionId);
|
||||
return;
|
||||
}
|
||||
setActiveTabId(sessionId);
|
||||
});
|
||||
const _handleTrayTogglePortForward = useEffectEvent((ruleId: string, start: boolean) => {
|
||||
const rule = portForwardingRules.find((item) => item.id === ruleId);
|
||||
if (!rule) return;
|
||||
const host = rule.hostId ? hosts.find((item) => item.id === rule.hostId) : undefined;
|
||||
if (!host) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (start) {
|
||||
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
return;
|
||||
}
|
||||
|
||||
void stopTunnel(ruleId);
|
||||
});
|
||||
const _handleTrayPanelConnect = useEffectEvent((hostId: string) => {
|
||||
const host = hosts.find((item) => item.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 _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => {
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
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 || isMonacoElement) && !isXtermInput && e.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
|
||||
const isTerminalElement =
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
|
||||
const isTerminalInPath = Boolean(
|
||||
e.composedPath?.().some(
|
||||
(node) =>
|
||||
node instanceof HTMLElement &&
|
||||
(node.classList.contains("xterm") ||
|
||||
node.classList.contains("xterm-helper-textarea") ||
|
||||
node.classList.contains("xterm-screen") ||
|
||||
node.classList.contains("xterm-viewport") ||
|
||||
node.hasAttribute("data-session-id")),
|
||||
),
|
||||
);
|
||||
|
||||
for (const binding of keyBindings) {
|
||||
const keyStr = isMac ? binding.mac : binding.pc;
|
||||
if (!matchesKeyBinding(e, keyStr, isMac)) continue;
|
||||
if (HOTKEY_DEBUG) console.log('[Hotkeys] Matched binding:', binding.action, keyStr);
|
||||
if (binding.category === 'sftp') {
|
||||
continue;
|
||||
}
|
||||
const terminalActions = ['copy', 'paste', 'selectAll', 'clearBuffer', 'searchTerminal'];
|
||||
if (terminalActions.includes(binding.action)) {
|
||||
if (isTerminalElement) {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (HOTKEY_DEBUG) {
|
||||
console.log('[Hotkeys] Global handle', {
|
||||
action: binding.action,
|
||||
key: e.key,
|
||||
meta: e.metaKey,
|
||||
ctrl: e.ctrlKey,
|
||||
alt: e.altKey,
|
||||
shift: e.shiftKey,
|
||||
targetTag: target?.tagName,
|
||||
isTerminalElement,
|
||||
isTerminalInPath,
|
||||
});
|
||||
}
|
||||
executeHotkeyAction(binding.action, e);
|
||||
return;
|
||||
}
|
||||
});
|
||||
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isQuickSwitcherOpen) {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Show toast notification when update is available (only when auto-download is idle)
|
||||
useEffect(() => {
|
||||
@@ -349,7 +565,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, [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.
|
||||
// not when unrelated deps (installUpdate, openSettingsWindow) change their reference.
|
||||
const prevAutoDownloadStatusRef = useRef(updateState.autoDownloadStatus);
|
||||
useEffect(() => {
|
||||
const prev = prevAutoDownloadStatusRef.current;
|
||||
@@ -372,23 +588,18 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
t('update.downloadFailed.message'),
|
||||
{
|
||||
title: t('update.downloadFailed.title'),
|
||||
actionLabel: t('update.openReleases'),
|
||||
onClick: () => openReleasePage(),
|
||||
actionLabel: t('update.viewInSettings'),
|
||||
onClick: () => void openSettingsWindow(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [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, passphrase: k.passphrase, })),
|
||||
[keys]
|
||||
);
|
||||
}, [updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openSettingsWindow]);
|
||||
|
||||
// Auto-start port forwarding rules on app launch
|
||||
usePortForwardingAutoStart({
|
||||
hosts,
|
||||
keys: portForwardingKeys,
|
||||
keys,
|
||||
identities,
|
||||
});
|
||||
|
||||
// Sync tray menu data + handle tray actions
|
||||
@@ -429,111 +640,34 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
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);
|
||||
}
|
||||
_handleTrayJumpToSession(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);
|
||||
}
|
||||
_handleTrayTogglePortForward(ruleId, start);
|
||||
});
|
||||
|
||||
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);
|
||||
const unsubscribeJump = bridge.onTrayPanelJumpToSession((sessionId) => {
|
||||
_handleTrayJumpToSession(sessionId);
|
||||
});
|
||||
const unsubscribeConnect = bridge.onTrayPanelConnectToHost((hostId) => {
|
||||
_handleTrayPanelConnect(hostId);
|
||||
});
|
||||
return () => {
|
||||
unsubscribeJump?.();
|
||||
unsubscribeConnect?.();
|
||||
};
|
||||
}, [addConnectionLog, connectToHost, hosts, identities, keys, sessions, setActiveTabId, setWorkspaceFocusedSession, t]);
|
||||
}, []);
|
||||
|
||||
// Keyboard-interactive authentication (2FA/MFA) event listener
|
||||
useEffect(() => {
|
||||
@@ -656,6 +790,24 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const addConnectionLogRef = useRef(addConnectionLog);
|
||||
addConnectionLogRef.current = addConnectionLog;
|
||||
|
||||
const createLocalTerminalWithCurrentShell = useCallback(() => {
|
||||
return createLocalTerminal({
|
||||
shellType: classifyLocalShellType(terminalSettings.localShell, navigator.userAgent),
|
||||
});
|
||||
}, [createLocalTerminal, terminalSettings.localShell]);
|
||||
|
||||
const splitSessionWithCurrentShell = useCallback((sessionId: string, direction: 'horizontal' | 'vertical') => {
|
||||
return splitSession(sessionId, direction, {
|
||||
localShellType: classifyLocalShellType(terminalSettings.localShell, navigator.userAgent),
|
||||
});
|
||||
}, [splitSession, terminalSettings.localShell]);
|
||||
|
||||
const copySessionWithCurrentShell = useCallback((sessionId: string) => {
|
||||
return copySession(sessionId, {
|
||||
localShellType: classifyLocalShellType(terminalSettings.localShell, navigator.userAgent),
|
||||
});
|
||||
}, [copySession, terminalSettings.localShell]);
|
||||
|
||||
// Shared hotkey action handler - used by both global handler and terminal callback
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
|
||||
switch (action) {
|
||||
@@ -727,7 +879,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
localHostname: systemInfoRef.current.hostname,
|
||||
saved: false,
|
||||
});
|
||||
createLocalTerminal();
|
||||
createLocalTerminalWithCurrentShell();
|
||||
break;
|
||||
case 'openHosts':
|
||||
setActiveTabId('vault');
|
||||
@@ -766,7 +918,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const activeWs = workspaces.find(w => w.id === currentId);
|
||||
if (activeSession && !activeSession.workspaceId) {
|
||||
// Standalone session - split it
|
||||
splitSession(activeSession.id, 'horizontal');
|
||||
splitSessionWithCurrentShell(activeSession.id, 'horizontal');
|
||||
} else if (activeWs) {
|
||||
// In a workspace - need to determine focused session
|
||||
// For now, we'll need the terminal to handle this via context menu
|
||||
@@ -781,7 +933,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const activeWs = workspaces.find(w => w.id === currentId);
|
||||
if (activeSession && !activeSession.workspaceId) {
|
||||
// Standalone session - split it
|
||||
splitSession(activeSession.id, 'vertical');
|
||||
splitSessionWithCurrentShell(activeSession.id, 'vertical');
|
||||
} else if (activeWs) {
|
||||
// In a workspace - need to determine focused session
|
||||
if (IS_DEV) console.log('[Hotkey] Split vertical in workspace - use context menu on specific terminal');
|
||||
@@ -821,7 +973,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminal, splitSession, moveFocusInWorkspace, toggleBroadcast]);
|
||||
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast]);
|
||||
|
||||
// Callback for terminal to invoke app-level hotkey actions
|
||||
const handleHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
|
||||
@@ -832,96 +984,21 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
useEffect(() => {
|
||||
if (hotkeyScheme === 'disabled' || isHotkeyRecording) return;
|
||||
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
if (HOTKEY_DEBUG) {
|
||||
console.log('[Hotkeys] Registering global hotkey handler, scheme:', hotkeyScheme, 'bindings count:', keyBindings.length);
|
||||
}
|
||||
|
||||
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||
// Don't handle if we're in an input or textarea (except for Escape)
|
||||
// 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");
|
||||
|
||||
// Monaco is not always contentEditable/input, so treat it as an editor surface.
|
||||
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
|
||||
const isTerminalElement =
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
|
||||
const isTerminalInPath = Boolean(
|
||||
e.composedPath?.().some(
|
||||
(node) =>
|
||||
node instanceof HTMLElement &&
|
||||
(node.classList.contains("xterm") ||
|
||||
node.classList.contains("xterm-helper-textarea") ||
|
||||
node.classList.contains("xterm-screen") ||
|
||||
node.classList.contains("xterm-viewport") ||
|
||||
node.hasAttribute("data-session-id")),
|
||||
),
|
||||
);
|
||||
|
||||
// Check each key binding
|
||||
for (const binding of keyBindings) {
|
||||
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'];
|
||||
if (terminalActions.includes(binding.action)) {
|
||||
if (isTerminalElement) {
|
||||
return; // Let terminal handle it
|
||||
}
|
||||
continue; // Ignore terminal actions outside terminal
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (HOTKEY_DEBUG) {
|
||||
console.log('[Hotkeys] Global handle', {
|
||||
action: binding.action,
|
||||
key: e.key,
|
||||
meta: e.metaKey,
|
||||
ctrl: e.ctrlKey,
|
||||
alt: e.altKey,
|
||||
shift: e.shiftKey,
|
||||
targetTag: target?.tagName,
|
||||
isTerminalElement,
|
||||
isTerminalInPath,
|
||||
});
|
||||
}
|
||||
executeHotkeyAction(binding.action, e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
_handleGlobalHotkeyKeyDown(e);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleGlobalKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleGlobalKeyDown, true);
|
||||
}, [hotkeyScheme, keyBindings, isHotkeyRecording, executeHotkeyAction]);
|
||||
}, [hotkeyScheme, isHotkeyRecording]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isQuickSwitcherOpen) {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
}
|
||||
_handleEscapeKeyDown(e);
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [isQuickSwitcherOpen]);
|
||||
}, []);
|
||||
|
||||
const quickResults = useMemo(() => {
|
||||
if (!isQuickSwitcherOpen) return [];
|
||||
@@ -934,7 +1011,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
)
|
||||
: hosts;
|
||||
return filtered;
|
||||
}, [hosts, quickSearch, isQuickSwitcherOpen]);
|
||||
}, [quickSearch, hosts, isQuickSwitcherOpen]);
|
||||
|
||||
const handleDeleteHost = useCallback((hostId: string) => {
|
||||
const target = hosts.find(h => h.id === hostId);
|
||||
@@ -967,7 +1044,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Wrapper to create local terminal with logging
|
||||
const handleCreateLocalTerminal = useCallback(() => {
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
const sessionId = createLocalTerminal();
|
||||
const sessionId = createLocalTerminalWithCurrentShell();
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: '',
|
||||
@@ -980,7 +1057,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
localHostname: hostname,
|
||||
saved: false,
|
||||
});
|
||||
}, [addConnectionLog, createLocalTerminal]);
|
||||
}, [addConnectionLog, createLocalTerminalWithCurrentShell]);
|
||||
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host) => {
|
||||
@@ -1023,10 +1100,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, [addConnectionLog, connectToHost, identities, keys]);
|
||||
|
||||
// Wrapper to create serial session with logging
|
||||
const handleConnectSerial = useCallback((config: SerialConfig) => {
|
||||
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
const portName = config.path.split('/').pop() || config.path;
|
||||
const sessionId = createSerialSession(config);
|
||||
const sessionId = createSerialSession(config, options);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: '',
|
||||
@@ -1067,31 +1144,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
});
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Updated log with terminalData');
|
||||
|
||||
// Auto-save session log if enabled
|
||||
if (sessionLogsEnabled && sessionLogsDir && data) {
|
||||
import('./infrastructure/services/netcattyBridge').then(({ netcattyBridge }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.autoSaveSessionLog) {
|
||||
bridge.autoSaveSessionLog({
|
||||
terminalData: data,
|
||||
hostLabel: matchingLog.hostLabel,
|
||||
hostname: matchingLog.hostname,
|
||||
hostId: matchingLog.hostId,
|
||||
startTime: matchingLog.startTime,
|
||||
format: sessionLogsFormat,
|
||||
directory: sessionLogsDir,
|
||||
}).then(result => {
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Auto-save result:', result);
|
||||
}).catch(err => {
|
||||
console.error('[handleTerminalDataCapture] Auto-save failed:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
// Auto-save is now handled by real-time streaming in the main process
|
||||
// via sessionLogStreamManager. No renderer-side fallback needed.
|
||||
} else {
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] No matching log found!');
|
||||
}
|
||||
}, [sessions, connectionLogs, updateConnectionLog, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat]);
|
||||
}, [sessions, connectionLogs, updateConnectionLog]);
|
||||
|
||||
// Check if host has multiple protocols enabled
|
||||
const hasMultipleProtocols = useCallback((host: Host) => {
|
||||
@@ -1209,7 +1267,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
|
||||
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", immersiveMode && activeTerminalTheme && "immersive-transition")} onContextMenu={handleRootContextMenu}>
|
||||
<TopTabs
|
||||
theme={resolvedTheme}
|
||||
hosts={hosts}
|
||||
@@ -1222,7 +1280,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
isMacClient={isMacClient}
|
||||
onCloseSession={closeSession}
|
||||
onRenameSession={startSessionRename}
|
||||
onCopySession={copySession}
|
||||
onCopySession={copySessionWithCurrentShell}
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
onCloseWorkspace={closeWorkspace}
|
||||
onCloseLogView={closeLogView}
|
||||
@@ -1230,6 +1288,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onToggleTheme={handleToggleTheme}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onSyncNow={handleSyncNowManual}
|
||||
isImmersiveActive={immersiveMode && activeTerminalTheme !== null}
|
||||
onStartSessionDrag={setDraggingSessionId}
|
||||
onEndSessionDrag={handleEndSessionDrag}
|
||||
onReorderTabs={reorderTabs}
|
||||
@@ -1251,6 +1310,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessions={sessions}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
terminalThemeId={terminalThemeId}
|
||||
terminalFontSize={terminalFontSize}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
@@ -1279,7 +1340,20 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
/>
|
||||
</VaultViewContainer>
|
||||
|
||||
<SftpViewMount hosts={hosts} keys={keys} identities={identities} updateHosts={updateHosts} />
|
||||
<SftpViewMount
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
updateHosts={updateHosts}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
sftpShowHiddenFiles={sftpShowHiddenFiles}
|
||||
sftpUseCompressedUpload={sftpUseCompressedUpload}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
/>
|
||||
|
||||
<TerminalLayerMount
|
||||
hosts={hosts}
|
||||
@@ -1316,7 +1390,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onSetDraggingSessionId={setDraggingSessionId}
|
||||
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
||||
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
|
||||
onSplitSession={splitSession}
|
||||
onSplitSession={splitSessionWithCurrentShell}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
updateHosts={updateHosts}
|
||||
@@ -1324,8 +1398,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
sftpShowHiddenFiles={sftpShowHiddenFiles}
|
||||
sftpUseCompressedUpload={sftpUseCompressedUpload}
|
||||
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
sessionLogsEnabled={sessionLogsEnabled}
|
||||
sessionLogsDir={sessionLogsDir}
|
||||
sessionLogsFormat={sessionLogsFormat}
|
||||
/>
|
||||
|
||||
{/* Log Views - readonly terminal replays */}
|
||||
|
||||
@@ -59,6 +59,8 @@
|
||||
- [ビルドとパッケージ](#ビルドとパッケージ)
|
||||
- [技術スタック](#技術スタック)
|
||||
- [コントリビューション](#コントリビューション)
|
||||
- [コントリビューター](#コントリビューター)
|
||||
- [Star 履歴](#star-履歴)
|
||||
- [ライセンス](#ライセンス)
|
||||
|
||||
---
|
||||
@@ -110,37 +112,37 @@
|
||||
<a name="デモ"></a>
|
||||
# デモ
|
||||
|
||||
GIF で機能をさっと確認できます(素材は `screenshots/gifs/`):
|
||||
動画で機能をさっと確認できます(素材は `screenshots/gifs/`):
|
||||
|
||||
### Vault ビュー:グリッド / リスト / ツリー
|
||||
状況に合わせて見え方を切り替え。グリッドで全体像、リストで密度、ツリーで階層を扱えます。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
|
||||
|
||||
### 分割ターミナル + セッション管理
|
||||
複数セッションを分割ペインで並べて作業。関連タスクを横並びにしてコンテキストスイッチを減らします。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
|
||||
|
||||
### SFTP:ドラッグ&ドロップ + 内蔵エディタ
|
||||
ドラッグ&ドロップでファイルを移動し、内蔵エディタでそのまま編集できます。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
|
||||
|
||||
### ドラッグでアップロード
|
||||
ファイルをそのままドロップしてアップロードを開始。ダイアログ操作を減らせます。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
|
||||
|
||||
### カスタムテーマ
|
||||
テーマを調整して自分の好みに合わせた見た目に。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
|
||||
|
||||
### キーワードハイライト
|
||||
重要な出力(エラー/警告/マーカーなど)を見つけやすくするために、ハイライトをカスタマイズできます。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
|
||||
|
||||
---
|
||||
|
||||
@@ -196,6 +198,7 @@ Netcatty は接続したホストの OS を検出し、ホスト一覧でアイ
|
||||
<img src="public/distro/opensuse.svg" width="48" alt="openSUSE" title="openSUSE">
|
||||
<img src="public/distro/oracle.svg" width="48" alt="Oracle Linux" title="Oracle Linux">
|
||||
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
|
||||
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -305,6 +308,17 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
|
||||
|
||||
---
|
||||
|
||||
<a name="コントリビューター"></a>
|
||||
# コントリビューター
|
||||
|
||||
貢献してくださったすべての方に感謝します!
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<a name="ライセンス"></a>
|
||||
# ライセンス
|
||||
|
||||
@@ -312,6 +326,19 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
|
||||
|
||||
---
|
||||
|
||||
<a name="star-履歴"></a>
|
||||
# Star 履歴
|
||||
|
||||
<a href="https://star-history.com/#binaricat/Netcatty&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
❤️ を込めて作成 by <a href="https://ko-fi.com/binaricat">binaricat</a>
|
||||
</p>
|
||||
|
||||
96
README.md
96
README.md
@@ -5,13 +5,13 @@
|
||||
<h1 align="center">Netcatty</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Modern SSH Client, SFTP Browser & Terminal Manager</strong><br/>
|
||||
<strong>🔥 AI-Powered SSH Client, SFTP Browser & Terminal Manager 🚀</strong><br/>
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A beautiful, feature-rich SSH workspace built with Electron, React, and xterm.js.<br/>
|
||||
Split terminals, Vault views, SFTP workflows, custom themes, and keyword highlighting — all in one.
|
||||
🔥 Built-in AI Agent · Split terminals · Vault views · SFTP workflows · Custom themes — all in one.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -42,10 +42,52 @@
|
||||
|
||||
[](screenshots/main-window-dark.png)
|
||||
|
||||
---
|
||||
|
||||
<a name="catty-agent"></a>
|
||||
# 🔥 Catty Agent — Your IT Ops AI Partner
|
||||
|
||||
> 🚀 **Boost your IT ops daily work with AI power.** Catty Agent is the built-in AI assistant that understands your servers, executes commands, and handles complex multi-host operations — all through natural conversation.
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/ai-feature.png" alt="Catty Agent Interface" width="800">
|
||||
</p>
|
||||
|
||||
### 🔥 What can Catty Agent do?
|
||||
|
||||
- 🚀 **Natural language server management** — just tell it what you need, no more memorizing commands
|
||||
- 🔥 **Real-time server diagnostics** — check status, inspect logs, monitor resources through conversation
|
||||
- 🚀 **Multi-host orchestration** — coordinate tasks across multiple servers simultaneously
|
||||
- 🔥 **Intelligent context awareness** — understands your server environment and provides tailored responses
|
||||
- 🚀 **One-click complex operations** — set up clusters, deploy services, and more with simple instructions
|
||||
|
||||
### 🎬 AI in Action
|
||||
|
||||
#### 🔥 Single Host — Intelligent Server Diagnostics
|
||||
|
||||
Ask Catty Agent to check a server's health, and it runs the right commands, analyzes the output, and gives you a clear summary — all in seconds.
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/eecf08f1-80bd-49db-886d-b36e93388865
|
||||
|
||||
|
||||
|
||||
|
||||
#### 🚀 Multi-Host — Docker Swarm Cluster Setup
|
||||
|
||||
Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one conversation. It handles the init, token exchange, and node joining — you just tell it what you want.
|
||||
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/282027aa-5c9e-4bb1-b2c3-5eea9df2b203
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
# Contents <!-- omit in toc -->
|
||||
|
||||
- [🔥 Catty Agent — AI Partner](#catty-agent)
|
||||
- [What is Netcatty](#what-is-netcatty)
|
||||
- [Why Netcatty](#why-netcatty)
|
||||
- [Features](#features)
|
||||
@@ -59,6 +101,8 @@
|
||||
- [Build & Package](#build--package)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Contributing](#contributing)
|
||||
- [Contributors](#contributors)
|
||||
- [Star History](#star-history)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
@@ -111,37 +155,53 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
|
||||
<a name="demos"></a>
|
||||
# Demos
|
||||
|
||||
GIF previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
|
||||
Video previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
|
||||
|
||||
|
||||
### Split terminals + session management
|
||||
Work in multiple sessions at once with split panes. Keep related tasks side-by-side and reduce context switching.
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
|
||||
|
||||
|
||||
|
||||
### SFTP: drag & drop + built-in editor
|
||||
Move files with drag & drop, then edit quickly using the built-in editor without leaving the app.
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### Drag file upload
|
||||
Drop files into the app to kick off uploads without hunting through dialogs.
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
|
||||
|
||||
|
||||
|
||||
|
||||
### Custom themes
|
||||
Make Netcatty yours: customize themes and UI appearance.
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
|
||||
|
||||
|
||||
|
||||
|
||||
### Keyword highlighting
|
||||
Highlight important terminal output so errors, warnings, and key events stand out at a glance.
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -197,6 +257,7 @@ Netcatty automatically detects and displays OS icons for connected hosts:
|
||||
<img src="public/distro/opensuse.svg" width="48" alt="openSUSE" title="openSUSE">
|
||||
<img src="public/distro/oracle.svg" width="48" alt="Oracle Linux" title="Oracle Linux">
|
||||
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
|
||||
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
|
||||
</p>
|
||||
|
||||
<a name="getting-started"></a>
|
||||
@@ -309,7 +370,9 @@ See [agents.md](agents.md) for architecture overview and coding conventions.
|
||||
|
||||
Thanks to all the people who contribute!
|
||||
|
||||
See: https://github.com/binaricat/Netcatty/graphs/contributors
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
@@ -320,6 +383,19 @@ This project is licensed under the **GPL-3.0 License** - see the [LICENSE](LICEN
|
||||
|
||||
---
|
||||
|
||||
<a name="star-history"></a>
|
||||
# Star History
|
||||
|
||||
<a href="https://star-history.com/#binaricat/Netcatty&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
Made with ❤️ by <a href="https://ko-fi.com/binaricat">binaricat</a>
|
||||
</p>
|
||||
|
||||
@@ -59,6 +59,8 @@
|
||||
- [构建与打包](#构建与打包)
|
||||
- [技术栈](#技术栈)
|
||||
- [参与贡献](#参与贡献)
|
||||
- [贡献者](#贡献者)
|
||||
- [Star 历史](#star-历史)
|
||||
- [开源协议](#开源协议)
|
||||
|
||||
---
|
||||
@@ -111,37 +113,37 @@
|
||||
<a name="演示"></a>
|
||||
# 演示
|
||||
|
||||
GIF 预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
|
||||
视频预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
|
||||
|
||||
### Vault 视图:网格 / 列表 / 树形
|
||||
根据不同场景自由切换视图:网格适合总览,列表适合密集浏览,树形适合层级导航与整理。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
|
||||
|
||||
### 分屏终端 + 会话管理
|
||||
用分屏把多个会话并排放在同一个工作区里,降低来回切换窗口/标签页的成本。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
|
||||
|
||||
### SFTP:拖拽 + 内置编辑器
|
||||
通过拖拽完成文件传输,并用内置编辑器快速修改文件内容,不用来回切换工具。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
|
||||
|
||||
### 拖拽文件上传
|
||||
把文件直接拖进应用即可触发上传流程,省去多层对话框与路径选择。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
|
||||
|
||||
### 自定义主题
|
||||
按自己的审美与习惯定制主题与界面外观,让日常使用更顺手。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
|
||||
|
||||
### 关键词高亮
|
||||
让关键输出一眼可见:错误、告警或特定标记被高亮后更容易扫到与定位。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
|
||||
|
||||
---
|
||||
|
||||
@@ -197,6 +199,7 @@ Netcatty 会自动识别并在主机列表中展示对应的系统图标:
|
||||
<img src="public/distro/opensuse.svg" width="48" alt="openSUSE" title="openSUSE">
|
||||
<img src="public/distro/oracle.svg" width="48" alt="Oracle Linux" title="Oracle Linux">
|
||||
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
|
||||
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
|
||||
</p>
|
||||
|
||||
<a name="快速开始"></a>
|
||||
@@ -309,7 +312,9 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
感谢所有参与贡献的人!
|
||||
|
||||
查看:https://github.com/binaricat/Netcatty/graphs/contributors
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
@@ -320,6 +325,19 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
---
|
||||
|
||||
<a name="star-历史"></a>
|
||||
# Star 历史
|
||||
|
||||
<a href="https://star-history.com/#binaricat/Netcatty&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
用 ❤️ 制作,作者 <a href="https://ko-fi.com/binaricat">binaricat</a>
|
||||
</p>
|
||||
|
||||
@@ -5,6 +5,9 @@ const en: Messages = {
|
||||
'common.save': 'Save',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.close': 'Close',
|
||||
'common.reset': 'Reset',
|
||||
'common.zoomIn': 'Zoom in',
|
||||
'common.zoomOut': 'Zoom out',
|
||||
'common.settings': 'Settings',
|
||||
'common.search': 'Search',
|
||||
'common.searchPlaceholder': 'Search...',
|
||||
@@ -30,6 +33,7 @@ const en: Messages = {
|
||||
'common.back': 'Back',
|
||||
'common.apply': 'Apply',
|
||||
'common.use': 'Use',
|
||||
'common.useGlobal': 'Use global',
|
||||
'common.saveChanges': 'Save Changes',
|
||||
'common.advanced': 'Advanced',
|
||||
'common.left': 'Left',
|
||||
@@ -95,6 +99,21 @@ const en: Messages = {
|
||||
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
|
||||
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
|
||||
|
||||
// Settings > System > Crash Logs
|
||||
'settings.system.crashLogs.title': 'Crash Logs',
|
||||
'settings.system.crashLogs.description': 'View error logs from the main process to help diagnose unexpected behavior.',
|
||||
'settings.system.crashLogs.noLogs': 'No crash logs found.',
|
||||
'settings.system.crashLogs.entries': '{count} entries',
|
||||
'settings.system.crashLogs.clear': 'Clear all logs',
|
||||
'settings.system.crashLogs.cleared': 'Cleared {count} log file(s).',
|
||||
'settings.system.crashLogs.source': 'Source',
|
||||
'settings.system.crashLogs.time': 'Time',
|
||||
'settings.system.crashLogs.message': 'Message',
|
||||
'settings.system.crashLogs.stack': 'Stack Trace',
|
||||
'settings.system.crashLogs.hint': 'Crash logs are retained for 30 days and automatically rotated.',
|
||||
'settings.system.crashLogs.collapse': 'Collapse',
|
||||
'settings.system.crashLogs.expand': 'Show details',
|
||||
|
||||
// Settings > System > Software Update
|
||||
'settings.update.title': 'Software Update',
|
||||
'settings.update.currentVersion': 'Current version',
|
||||
@@ -212,6 +231,9 @@ const en: Messages = {
|
||||
'settings.appearance.themeColor.desc': 'Pick a preset palette for each theme',
|
||||
'settings.appearance.themeColor.light': 'Light palette',
|
||||
'settings.appearance.themeColor.dark': 'Dark palette',
|
||||
'settings.appearance.immersiveMode': 'Immersive Mode',
|
||||
'settings.appearance.immersiveMode.desc':
|
||||
'When enabled, the UI chrome (tab bar, sidebar, status bar) adapts its colors to match the active terminal theme for a visually cohesive experience.',
|
||||
'settings.appearance.customCss': 'Custom CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately.',
|
||||
@@ -292,6 +314,9 @@ const en: Messages = {
|
||||
'settings.terminal.behavior.scrollOnPaste': 'Scroll on paste',
|
||||
'settings.terminal.behavior.scrollOnPaste.desc':
|
||||
'Scroll terminal to bottom when pasting text',
|
||||
'settings.terminal.behavior.smoothScrolling': 'Smooth scrolling',
|
||||
'settings.terminal.behavior.smoothScrolling.desc':
|
||||
'Animate terminal viewport scrolling instead of jumping instantly',
|
||||
'settings.terminal.behavior.linkModifier': 'Link modifier key',
|
||||
'settings.terminal.behavior.linkModifier.desc': 'Hold this key to click on links in terminal',
|
||||
'settings.terminal.behavior.linkModifier.none': 'None (click directly)',
|
||||
@@ -330,6 +355,15 @@ const en: Messages = {
|
||||
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use Canvas on low-memory devices. Changes take effect on new terminal sessions.',
|
||||
'settings.terminal.rendering.auto': 'Auto',
|
||||
|
||||
// Settings > Terminal > Autocomplete
|
||||
'settings.terminal.section.autocomplete': 'Autocomplete',
|
||||
'settings.terminal.autocomplete.enabled': 'Enable autocomplete',
|
||||
'settings.terminal.autocomplete.enabled.desc': 'Show command suggestions based on history and command specs as you type.',
|
||||
'settings.terminal.autocomplete.ghostText': 'Ghost text',
|
||||
'settings.terminal.autocomplete.ghostText.desc': 'Show inline gray suggestion text after the cursor (like fish shell).',
|
||||
'settings.terminal.autocomplete.popupMenu': 'Popup menu',
|
||||
'settings.terminal.autocomplete.popupMenu.desc': 'Show a floating list of multiple suggestions.',
|
||||
|
||||
// Settings > Shortcuts
|
||||
'settings.shortcuts.section.scheme': 'Hotkey Scheme',
|
||||
'settings.shortcuts.scheme.label': 'Keyboard shortcuts',
|
||||
@@ -579,6 +613,8 @@ const en: Messages = {
|
||||
'sftp.filter.placeholder': 'Filter by filename...',
|
||||
'sftp.bookmark.add': 'Bookmark this path',
|
||||
'sftp.bookmark.remove': 'Remove bookmark',
|
||||
'sftp.bookmark.addGlobal': '+Global',
|
||||
'sftp.bookmark.addGlobalTooltip': 'Save as global bookmark (shared across all hosts)',
|
||||
'sftp.bookmark.empty': 'No bookmarks yet',
|
||||
'sftp.columns.name': 'Name',
|
||||
'sftp.columns.modified': 'Modified',
|
||||
@@ -609,12 +645,14 @@ const en: Messages = {
|
||||
'sftp.path.doubleClickToEdit': 'Double-click to edit path',
|
||||
'sftp.showHiddenPaths': 'Hidden paths',
|
||||
'sftp.task.waiting': 'Waiting...',
|
||||
'sftp.transfer.preparing': 'preparing...',
|
||||
'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.transfers.calculatingTotal': 'Calculating total size...',
|
||||
'sftp.goUp': 'Go up',
|
||||
'sftp.goToTerminalCwd': 'Go to terminal directory',
|
||||
'sftp.encoding.label': 'Filename Encoding',
|
||||
@@ -688,6 +726,7 @@ const en: Messages = {
|
||||
'sftp.upload.phase.compressed': 'Compressed',
|
||||
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': 'Copy file path',
|
||||
'sftp.context.openWith': 'Open with...',
|
||||
'sftp.context.edit': 'Edit',
|
||||
'sftp.context.preview': 'Preview',
|
||||
@@ -745,6 +784,13 @@ const en: Messages = {
|
||||
'settings.sftp.autoSync.desc': 'Automatically sync file changes back to the remote server when opening files with external applications',
|
||||
'settings.sftp.autoSync.enable': 'Enable auto-sync',
|
||||
'settings.sftp.autoSync.enableDesc': 'When you save a file in an external application, changes will be automatically uploaded to the remote server',
|
||||
|
||||
// Settings > SFTP Auto Open Sidebar
|
||||
'settings.sftp.autoOpenSidebar': 'Auto-open sidebar on connect',
|
||||
'settings.sftp.autoOpenSidebar.desc': 'Automatically open the SFTP file browser sidebar when connecting to a host',
|
||||
'settings.sftp.autoOpenSidebar.enable': 'Enable auto-open sidebar',
|
||||
'settings.sftp.autoOpenSidebar.enableDesc': 'The SFTP sidebar will open automatically when a terminal session connects to a remote host',
|
||||
|
||||
'sftp.autoSync.success': 'File synced to remote: {fileName}',
|
||||
'sftp.autoSync.error': 'Failed to sync file: {error}',
|
||||
|
||||
@@ -832,6 +878,29 @@ const en: Messages = {
|
||||
'hostDetails.section.credentials': 'Credentials',
|
||||
'hostDetails.section.portCredentials': 'Port & Credentials',
|
||||
'hostDetails.section.appearance': 'Appearance',
|
||||
'hostDetails.distro.title': 'Linux Distribution',
|
||||
'hostDetails.distro.desc': 'Auto-detect on connect, or override the distro icon manually.',
|
||||
'hostDetails.distro.mode': 'Source',
|
||||
'hostDetails.distro.mode.auto': 'Auto-detect',
|
||||
'hostDetails.distro.mode.manual': 'Manual override',
|
||||
'hostDetails.distro.detectedLabel': 'Current',
|
||||
'hostDetails.distro.manualLabel': 'Override',
|
||||
'hostDetails.distro.pending': 'Detect after first connection',
|
||||
'hostDetails.distro.unknown': 'Unknown',
|
||||
'hostDetails.distro.option.linux': 'Generic Linux',
|
||||
'hostDetails.distro.option.ubuntu': 'Ubuntu',
|
||||
'hostDetails.distro.option.debian': 'Debian',
|
||||
'hostDetails.distro.option.centos': 'CentOS',
|
||||
'hostDetails.distro.option.rocky': 'Rocky Linux',
|
||||
'hostDetails.distro.option.fedora': 'Fedora',
|
||||
'hostDetails.distro.option.arch': 'Arch Linux',
|
||||
'hostDetails.distro.option.alpine': 'Alpine',
|
||||
'hostDetails.distro.option.amazon': 'Amazon Linux',
|
||||
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.section.mosh': 'Mosh',
|
||||
'hostDetails.username.placeholder': 'Username',
|
||||
'hostDetails.password.placeholder': 'Password',
|
||||
@@ -840,9 +909,12 @@ const en: Messages = {
|
||||
'hostDetails.password.save': 'Save password',
|
||||
'hostDetails.identity.suggestions': 'Identities',
|
||||
'hostDetails.identity.missing': 'Identity not found',
|
||||
'hostDetails.credential.keyCertificate': 'Key, Certificate',
|
||||
'hostDetails.credential.keyCertificate': 'Key, Certificate, Local Key File',
|
||||
'hostDetails.credential.key': 'Key',
|
||||
'hostDetails.credential.certificate': 'Certificate',
|
||||
'hostDetails.credential.localKeyFile': 'Local Key File',
|
||||
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
|
||||
'hostDetails.credential.browseKeyFile': 'Browse...',
|
||||
'hostDetails.credential.missing': 'Credential not found',
|
||||
'hostDetails.keys.search': 'Search keys...',
|
||||
'hostDetails.keys.empty': 'No keys available',
|
||||
@@ -853,6 +925,10 @@ const en: Messages = {
|
||||
'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.deviceType': 'Device Type',
|
||||
'hostDetails.deviceType': 'Network Device Mode',
|
||||
'hostDetails.deviceType.desc': 'Enable for network equipment (switches, routers, firewalls) connected via SSH. Commands are sent as-is without shell wrapping, compatible with vendor CLIs like Huawei VRP and Cisco IOS.',
|
||||
'hostDetails.deviceType.warning': 'AI agent commands will be sent directly without exit code tracking. Only enable for devices that do not run a standard shell.',
|
||||
'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.',
|
||||
@@ -1048,6 +1124,7 @@ const en: Messages = {
|
||||
'terminal.progress.disconnected': 'Disconnected',
|
||||
'terminal.progress.cancelling': 'Cancelling...',
|
||||
'terminal.progress.startOver': 'Start over',
|
||||
'terminal.connection.dismissDisconnectedDialog': 'Dismiss disconnected notice',
|
||||
'terminal.connection.chainOf': 'Chain {current} of {total}',
|
||||
'terminal.connection.showLogs': 'Show logs',
|
||||
'terminal.connection.hideLogs': 'Hide logs',
|
||||
@@ -1060,6 +1137,8 @@ const en: Messages = {
|
||||
'terminal.themeModal.tab.theme': 'Theme',
|
||||
'terminal.themeModal.tab.font': 'Font',
|
||||
'terminal.themeModal.tab.custom': 'Custom',
|
||||
'terminal.themeModal.globalTheme': 'Global Theme',
|
||||
'terminal.themeModal.globalFont': 'Global Font',
|
||||
'terminal.themeModal.fontSize': 'Font Size',
|
||||
'terminal.themeModal.livePreview': 'Live Preview',
|
||||
'terminal.themeModal.themeType': '{type} theme',
|
||||
@@ -1458,6 +1537,7 @@ const en: Messages = {
|
||||
'serial.field.localEchoDesc': 'Echo typed characters locally (for devices without remote echo)',
|
||||
'serial.field.lineMode': 'Line Mode',
|
||||
'serial.field.lineModeDesc': 'Buffer input and send on Enter (instead of character-by-character)',
|
||||
'serial.field.charset': 'Charset',
|
||||
'serial.connectionError': 'Failed to connect to serial port',
|
||||
'serial.field.baudRatePlaceholder': 'Select or enter baud rate...',
|
||||
'serial.field.baudRateEmpty': 'Enter a custom baud rate',
|
||||
@@ -1524,6 +1604,10 @@ const en: Messages = {
|
||||
'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.providers.advancedParams': 'Advanced Parameters',
|
||||
'ai.providers.advancedParams.hint': 'Leave blank to use provider defaults.',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': 'e.g. 4096',
|
||||
'ai.providers.advancedParams.default': 'Provider default',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
@@ -1548,7 +1632,7 @@ const en: Messages = {
|
||||
|
||||
// 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.description': "Anthropic's agentic coding assistant. Uses claude-agent-acp for ACP protocol streaming.",
|
||||
'ai.claude.detecting': 'Detecting...',
|
||||
'ai.claude.detected': 'Detected',
|
||||
'ai.claude.notFound': 'Not found',
|
||||
@@ -1565,7 +1649,6 @@ const en: Messages = {
|
||||
// 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',
|
||||
|
||||
@@ -5,6 +5,9 @@ const zhCN: Messages = {
|
||||
'common.save': '保存',
|
||||
'common.cancel': '取消',
|
||||
'common.close': '关闭',
|
||||
'common.reset': '重置',
|
||||
'common.zoomIn': '放大',
|
||||
'common.zoomOut': '缩小',
|
||||
'common.settings': '设置',
|
||||
'common.search': '搜索',
|
||||
'common.connect': '连接',
|
||||
@@ -20,6 +23,7 @@ const zhCN: Messages = {
|
||||
'common.back': '返回',
|
||||
'common.apply': '应用',
|
||||
'common.use': '使用',
|
||||
'common.useGlobal': '跟随全局',
|
||||
'common.left': '左侧',
|
||||
'common.right': '右侧',
|
||||
'common.more': '更多',
|
||||
@@ -79,6 +83,21 @@ const zhCN: Messages = {
|
||||
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
|
||||
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
|
||||
|
||||
// Settings > System > Crash Logs
|
||||
'settings.system.crashLogs.title': '崩溃日志',
|
||||
'settings.system.crashLogs.description': '查看主进程错误日志,帮助诊断异常行为。',
|
||||
'settings.system.crashLogs.noLogs': '未找到崩溃日志。',
|
||||
'settings.system.crashLogs.entries': '{count} 条记录',
|
||||
'settings.system.crashLogs.clear': '清除所有日志',
|
||||
'settings.system.crashLogs.cleared': '已清除 {count} 个日志文件。',
|
||||
'settings.system.crashLogs.source': '来源',
|
||||
'settings.system.crashLogs.time': '时间',
|
||||
'settings.system.crashLogs.message': '消息',
|
||||
'settings.system.crashLogs.stack': '堆栈跟踪',
|
||||
'settings.system.crashLogs.hint': '崩溃日志保留 30 天,超期自动清理。',
|
||||
'settings.system.crashLogs.collapse': '收起',
|
||||
'settings.system.crashLogs.expand': '查看详情',
|
||||
|
||||
// Settings > System > Software Update
|
||||
'settings.update.title': '软件更新',
|
||||
'settings.update.currentVersion': '当前版本',
|
||||
@@ -196,6 +215,9 @@ const zhCN: Messages = {
|
||||
'settings.appearance.themeColor.desc': '为浅色与深色主题选择预设配色',
|
||||
'settings.appearance.themeColor.light': '浅色主题',
|
||||
'settings.appearance.themeColor.dark': '深色主题',
|
||||
'settings.appearance.immersiveMode': '沉浸模式',
|
||||
'settings.appearance.immersiveMode.desc':
|
||||
'启用后,UI 外观(标签栏、侧边栏、状态栏)会自动适配当前终端主题的配色,营造视觉一体化的沉浸体验。',
|
||||
'settings.appearance.customCss': '自定义 CSS',
|
||||
'settings.appearance.customCss.desc': '使用自定义 CSS 个性化界面,修改会立即生效。',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
@@ -406,6 +428,8 @@ const zhCN: Messages = {
|
||||
'sftp.filter.placeholder': '按文件名筛选...',
|
||||
'sftp.bookmark.add': '收藏此路径',
|
||||
'sftp.bookmark.remove': '取消收藏',
|
||||
'sftp.bookmark.addGlobal': '+全局',
|
||||
'sftp.bookmark.addGlobalTooltip': '保存为全局收藏(所有主机共享)',
|
||||
'sftp.bookmark.empty': '暂无收藏路径',
|
||||
'sftp.columns.name': '名称',
|
||||
'sftp.columns.modified': '修改时间',
|
||||
@@ -436,12 +460,14 @@ const zhCN: Messages = {
|
||||
'sftp.path.doubleClickToEdit': '双击编辑路径',
|
||||
'sftp.showHiddenPaths': '隐藏的路径',
|
||||
'sftp.task.waiting': '等待中...',
|
||||
'sftp.transfer.preparing': '准备中...',
|
||||
'sftp.status.loading': '加载中...',
|
||||
'sftp.status.uploading': '上传中...',
|
||||
'sftp.status.ready': '就绪',
|
||||
'sftp.transfers': '传输',
|
||||
'sftp.transfers.active': '{count} 个进行中',
|
||||
'sftp.transfers.clearCompleted': '清除已完成',
|
||||
'sftp.transfers.calculatingTotal': '正在统计总大小...',
|
||||
'sftp.goUp': '上一级',
|
||||
'sftp.goToTerminalCwd': '定位到终端当前目录',
|
||||
'sftp.encoding.label': '文件名编码',
|
||||
@@ -531,6 +557,29 @@ const zhCN: Messages = {
|
||||
'hostDetails.section.credentials': '凭据',
|
||||
'hostDetails.section.portCredentials': '端口与凭据',
|
||||
'hostDetails.section.appearance': '外观',
|
||||
'hostDetails.distro.title': 'Linux 发行版',
|
||||
'hostDetails.distro.desc': '可在连接后自动探测,也可以手动覆盖图标所用的发行版。',
|
||||
'hostDetails.distro.mode': '来源',
|
||||
'hostDetails.distro.mode.auto': '自动探测',
|
||||
'hostDetails.distro.mode.manual': '手动覆盖',
|
||||
'hostDetails.distro.detectedLabel': '当前值',
|
||||
'hostDetails.distro.manualLabel': '手动指定',
|
||||
'hostDetails.distro.pending': '首次连接后自动探测',
|
||||
'hostDetails.distro.unknown': '未知',
|
||||
'hostDetails.distro.option.linux': '通用 Linux',
|
||||
'hostDetails.distro.option.ubuntu': 'Ubuntu',
|
||||
'hostDetails.distro.option.debian': 'Debian',
|
||||
'hostDetails.distro.option.centos': 'CentOS',
|
||||
'hostDetails.distro.option.rocky': 'Rocky Linux',
|
||||
'hostDetails.distro.option.fedora': 'Fedora',
|
||||
'hostDetails.distro.option.arch': 'Arch Linux',
|
||||
'hostDetails.distro.option.alpine': 'Alpine',
|
||||
'hostDetails.distro.option.amazon': 'Amazon Linux',
|
||||
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.section.mosh': 'Mosh',
|
||||
'hostDetails.username.placeholder': '用户名',
|
||||
'hostDetails.password.placeholder': '密码',
|
||||
@@ -539,9 +588,12 @@ const zhCN: Messages = {
|
||||
'hostDetails.password.save': '保存密码',
|
||||
'hostDetails.identity.suggestions': '身份',
|
||||
'hostDetails.identity.missing': '身份不存在',
|
||||
'hostDetails.credential.keyCertificate': '密钥 / 证书',
|
||||
'hostDetails.credential.keyCertificate': '密钥 / 证书 / 本地密钥',
|
||||
'hostDetails.credential.key': '密钥',
|
||||
'hostDetails.credential.certificate': '证书',
|
||||
'hostDetails.credential.localKeyFile': '本地密钥文件',
|
||||
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
|
||||
'hostDetails.credential.browseKeyFile': '浏览…',
|
||||
'hostDetails.credential.missing': '凭据不存在',
|
||||
'hostDetails.keys.search': '搜索密钥…',
|
||||
'hostDetails.keys.empty': '暂无密钥',
|
||||
@@ -552,6 +604,10 @@ const zhCN: Messages = {
|
||||
'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.deviceType': '设备类型',
|
||||
'hostDetails.deviceType': '网络设备模式',
|
||||
'hostDetails.deviceType.desc': '适用于通过 SSH 连接的网络设备(交换机、路由器、防火墙)。命令将原样发送,不进行 Shell 包装,兼容华为 VRP、Cisco IOS 等厂商 CLI。',
|
||||
'hostDetails.deviceType.warning': 'AI 代理命令将直接发送,无法获取退出码。仅建议在设备不运行标准 Shell 时启用。',
|
||||
'hostDetails.section.legacyAlgorithms': '旧版算法',
|
||||
'hostDetails.legacyAlgorithms': '允许旧版算法',
|
||||
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法(diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
|
||||
@@ -719,6 +775,7 @@ const zhCN: Messages = {
|
||||
'terminal.progress.disconnected': '已断开',
|
||||
'terminal.progress.cancelling': '正在取消...',
|
||||
'terminal.progress.startOver': '重新开始',
|
||||
'terminal.connection.dismissDisconnectedDialog': '关闭断连提示',
|
||||
'terminal.connection.chainOf': 'Chain {current} / {total}',
|
||||
'terminal.connection.showLogs': '显示日志',
|
||||
'terminal.connection.hideLogs': '隐藏日志',
|
||||
@@ -731,6 +788,8 @@ const zhCN: Messages = {
|
||||
'terminal.themeModal.tab.theme': '主题',
|
||||
'terminal.themeModal.tab.font': '字体',
|
||||
'terminal.themeModal.tab.custom': '自定义',
|
||||
'terminal.themeModal.globalTheme': '全局主题',
|
||||
'terminal.themeModal.globalFont': '全局字体',
|
||||
'terminal.themeModal.fontSize': '字体大小',
|
||||
'terminal.themeModal.livePreview': '实时预览',
|
||||
'terminal.themeModal.themeType': '{type} 主题',
|
||||
@@ -1003,6 +1062,7 @@ const zhCN: Messages = {
|
||||
'sftp.upload.phase.compressed': '压缩传输',
|
||||
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': '复制文件路径',
|
||||
'sftp.context.openWith': '打开方式...',
|
||||
'sftp.context.edit': '编辑',
|
||||
'sftp.context.preview': '预览',
|
||||
@@ -1060,6 +1120,13 @@ const zhCN: Messages = {
|
||||
'settings.sftp.autoSync.desc': '使用外部应用程序打开文件时,自动将文件更改同步回远程服务器',
|
||||
'settings.sftp.autoSync.enable': '启用自动同步',
|
||||
'settings.sftp.autoSync.enableDesc': '在外部应用程序中保存文件时,更改将自动上传到远程服务器',
|
||||
|
||||
// Settings > SFTP 自动打开侧栏
|
||||
'settings.sftp.autoOpenSidebar': '连接时自动打开侧栏',
|
||||
'settings.sftp.autoOpenSidebar.desc': '连接到主机时自动打开 SFTP 文件浏览器侧栏',
|
||||
'settings.sftp.autoOpenSidebar.enable': '启用自动打开侧栏',
|
||||
'settings.sftp.autoOpenSidebar.enableDesc': '当终端会话连接到远程主机时,SFTP 侧栏将自动打开',
|
||||
|
||||
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
|
||||
'sftp.autoSync.error': '同步文件失败:{error}',
|
||||
|
||||
@@ -1165,6 +1232,8 @@ const zhCN: Messages = {
|
||||
'settings.terminal.behavior.scrollOnKeyPress.desc': '按键(例如 Enter)时将终端滚动到底部',
|
||||
'settings.terminal.behavior.scrollOnPaste': '粘贴时自动滚动',
|
||||
'settings.terminal.behavior.scrollOnPaste.desc': '粘贴文本时将终端滚动到底部',
|
||||
'settings.terminal.behavior.smoothScrolling': '平滑滚动',
|
||||
'settings.terminal.behavior.smoothScrolling.desc': '滚动终端视口时使用平滑动画',
|
||||
'settings.terminal.behavior.linkModifier': '链接修饰键',
|
||||
'settings.terminal.behavior.linkModifier.desc': '按住此键再点击终端中的链接',
|
||||
'settings.terminal.behavior.linkModifier.none': '无(直接点击)',
|
||||
@@ -1203,6 +1272,15 @@ const zhCN: Messages = {
|
||||
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 Canvas。更改将在新终端会话中生效。',
|
||||
'settings.terminal.rendering.auto': '自动',
|
||||
|
||||
// Settings > Terminal > Autocomplete
|
||||
'settings.terminal.section.autocomplete': '自动补全',
|
||||
'settings.terminal.autocomplete.enabled': '启用自动补全',
|
||||
'settings.terminal.autocomplete.enabled.desc': '输入时根据历史命令和命令规范显示补全建议。',
|
||||
'settings.terminal.autocomplete.ghostText': '行内建议',
|
||||
'settings.terminal.autocomplete.ghostText.desc': '在光标后显示灰色的建议文本(类似 fish shell)。',
|
||||
'settings.terminal.autocomplete.popupMenu': '弹出菜单',
|
||||
'settings.terminal.autocomplete.popupMenu.desc': '显示包含多个建议的浮动列表。',
|
||||
|
||||
// Settings > Shortcuts
|
||||
'settings.shortcuts.section.scheme': '快捷键方案',
|
||||
'settings.shortcuts.scheme.label': '键盘快捷键',
|
||||
@@ -1473,6 +1551,7 @@ const zhCN: Messages = {
|
||||
'serial.field.localEchoDesc': '本地回显输入字符(用于没有远程回显的设备)',
|
||||
'serial.field.lineMode': '行模式',
|
||||
'serial.field.lineModeDesc': '缓冲输入,按回车后发送(而不是逐字符发送)',
|
||||
'serial.field.charset': '字符编码',
|
||||
'serial.connectionError': '连接串口失败',
|
||||
'serial.field.baudRatePlaceholder': '选择或输入波特率...',
|
||||
'serial.field.baudRateEmpty': '输入自定义波特率',
|
||||
@@ -1539,6 +1618,10 @@ const zhCN: Messages = {
|
||||
'ai.providers.noMatchingModels': '没有匹配的模型',
|
||||
'ai.providers.clickToLoadModels': '点击加载模型',
|
||||
'ai.providers.showingModels': '显示前 100 个,共 {count} 个模型。输入以筛选。',
|
||||
'ai.providers.advancedParams': '高级参数',
|
||||
'ai.providers.advancedParams.hint': '留空则使用提供商默认值。',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
|
||||
'ai.providers.advancedParams.default': '提供商默认',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
@@ -1563,7 +1646,7 @@ const zhCN: Messages = {
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': 'Anthropic 的智能编程助手。使用 claude-code-acp 进行 ACP 协议流式传输。',
|
||||
'ai.claude.description': 'Anthropic 的智能编程助手。使用 claude-agent-acp 进行 ACP 协议流式传输。',
|
||||
'ai.claude.detecting': '检测中...',
|
||||
'ai.claude.detected': '已检测到',
|
||||
'ai.claude.notFound': '未找到',
|
||||
@@ -1580,7 +1663,6 @@ const zhCN: Messages = {
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
|
||||
'ai.chat.toolDenied': '操作已被用户拒绝。',
|
||||
'ai.chat.toolApprovalTitle': '需要权限确认',
|
||||
'ai.chat.toolApproved': '已批准',
|
||||
'ai.chat.toolApprovalHint': '按回车批准,按 Esc 拒绝',
|
||||
'ai.chat.approve': '批准',
|
||||
|
||||
38
application/notification.ts
Normal file
38
application/notification.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Application-layer notification port.
|
||||
*
|
||||
* UI layers (e.g. toast) register their implementation via `setNotify`.
|
||||
* Application code calls `notify.*` without importing any UI module.
|
||||
*/
|
||||
|
||||
export interface NotifyOptions {
|
||||
title?: string;
|
||||
duration?: number;
|
||||
onClick?: () => void;
|
||||
actionLabel?: string;
|
||||
}
|
||||
|
||||
type NotifyFn = (message: string, titleOrOptions?: string | NotifyOptions) => void;
|
||||
|
||||
interface Notify {
|
||||
success: NotifyFn;
|
||||
error: NotifyFn;
|
||||
warning: NotifyFn;
|
||||
info: NotifyFn;
|
||||
}
|
||||
|
||||
const noop: NotifyFn = () => {};
|
||||
|
||||
let _impl: Notify = { success: noop, error: noop, warning: noop, info: noop };
|
||||
|
||||
/** Called once by the UI layer to wire up the real implementation. */
|
||||
export function setNotify(impl: Notify): void {
|
||||
_impl = impl;
|
||||
}
|
||||
|
||||
export const notify: Notify = {
|
||||
success: (...args) => _impl.success(...args),
|
||||
error: (...args) => _impl.error(...args),
|
||||
warning: (...args) => _impl.warning(...args),
|
||||
info: (...args) => _impl.info(...args),
|
||||
};
|
||||
@@ -6,6 +6,7 @@ type Listener = () => void;
|
||||
class ActiveTabStore {
|
||||
private activeTabId: string = 'vault';
|
||||
private listeners = new Set<Listener>();
|
||||
private pendingNotify = false;
|
||||
|
||||
getActiveTabId = () => this.activeTabId;
|
||||
|
||||
@@ -13,7 +14,10 @@ class ActiveTabStore {
|
||||
if (this.activeTabId !== id) {
|
||||
this.activeTabId = id;
|
||||
// Defer listener notification to avoid "setState during render" if called from a render phase
|
||||
if (this.pendingNotify) return;
|
||||
this.pendingNotify = true;
|
||||
Promise.resolve().then(() => {
|
||||
this.pendingNotify = false;
|
||||
this.listeners.forEach(listener => listener());
|
||||
});
|
||||
}
|
||||
|
||||
46
application/state/sessionActivity.ts
Normal file
46
application/state/sessionActivity.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { TerminalSession } from '../../types';
|
||||
|
||||
type SessionActivityMap = Record<string, boolean>;
|
||||
|
||||
export const getValidSessionActivityIds = (sessions: TerminalSession[]): Set<string> => {
|
||||
return new Set(sessions.map((session) => session.id));
|
||||
};
|
||||
|
||||
export const shouldMarkSessionActivity = (
|
||||
activeTabId: string | null,
|
||||
session: Pick<TerminalSession, 'id' | 'workspaceId'>,
|
||||
): boolean => {
|
||||
return activeTabId !== session.id && activeTabId !== session.workspaceId;
|
||||
};
|
||||
|
||||
export const getSessionActivityIdsToClear = (
|
||||
activeTabId: string | null,
|
||||
sessions: TerminalSession[],
|
||||
): string[] => {
|
||||
if (!activeTabId || activeTabId === 'vault' || activeTabId === 'sftp') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const activeSession = sessions.find((session) => session.id === activeTabId);
|
||||
if (activeSession) {
|
||||
return [activeSession.id];
|
||||
}
|
||||
|
||||
return sessions
|
||||
.filter((session) => session.workspaceId === activeTabId)
|
||||
.map((session) => session.id);
|
||||
};
|
||||
|
||||
export const buildWorkspaceActivityMap = (
|
||||
sessions: TerminalSession[],
|
||||
sessionActivityMap: SessionActivityMap,
|
||||
): Map<string, boolean> => {
|
||||
const workspaceActivityMap = new Map<string, boolean>();
|
||||
|
||||
for (const session of sessions) {
|
||||
if (!session.workspaceId || !sessionActivityMap[session.id]) continue;
|
||||
workspaceActivityMap.set(session.workspaceId, true);
|
||||
}
|
||||
|
||||
return workspaceActivityMap;
|
||||
};
|
||||
78
application/state/sessionActivityStore.ts
Normal file
78
application/state/sessionActivityStore.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
class SessionActivityStore {
|
||||
private snapshot: Record<string, boolean> = {};
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getSnapshot = () => this.snapshot;
|
||||
|
||||
subscribe = (listener: Listener) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
private emit() {
|
||||
this.listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
setTabActive = (tabId: string, hasActivity: boolean) => {
|
||||
const alreadyActive = !!this.snapshot[tabId];
|
||||
if (alreadyActive === hasActivity) return;
|
||||
|
||||
if (hasActivity) {
|
||||
this.snapshot = { ...this.snapshot, [tabId]: true };
|
||||
} else {
|
||||
const { [tabId]: _removed, ...rest } = this.snapshot;
|
||||
this.snapshot = rest;
|
||||
}
|
||||
|
||||
this.emit();
|
||||
};
|
||||
|
||||
clearTab = (tabId: string) => {
|
||||
this.setTabActive(tabId, false);
|
||||
};
|
||||
|
||||
clearTabs = (tabIds: Iterable<string>) => {
|
||||
let changed = false;
|
||||
const next = { ...this.snapshot };
|
||||
|
||||
for (const tabId of tabIds) {
|
||||
if (!next[tabId]) continue;
|
||||
delete next[tabId];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
this.snapshot = next;
|
||||
this.emit();
|
||||
};
|
||||
|
||||
prune = (validTabIds: Set<string>) => {
|
||||
let changed = false;
|
||||
const next: Record<string, boolean> = {};
|
||||
|
||||
for (const tabId of Object.keys(this.snapshot)) {
|
||||
if (validTabIds.has(tabId)) {
|
||||
next[tabId] = true;
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
this.snapshot = next;
|
||||
this.emit();
|
||||
};
|
||||
}
|
||||
|
||||
export const sessionActivityStore = new SessionActivityStore();
|
||||
|
||||
export const useSessionActivityMap = () => {
|
||||
return useSyncExternalStore(
|
||||
sessionActivityStore.subscribe,
|
||||
sessionActivityStore.getSnapshot,
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ export interface SftpPane {
|
||||
loading: boolean;
|
||||
reconnecting: boolean;
|
||||
error: string | null;
|
||||
connectionLogs: string[];
|
||||
selectedFiles: Set<string>;
|
||||
filter: string;
|
||||
filenameEncoding: SftpFilenameEncoding;
|
||||
@@ -33,6 +34,7 @@ export const createEmptyPane = (
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
filenameEncoding: "auto",
|
||||
|
||||
@@ -34,7 +34,7 @@ interface UseSftpConnectionsParams {
|
||||
}
|
||||
|
||||
interface UseSftpConnectionsResult {
|
||||
connect: (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean }) => Promise<void>;
|
||||
connect: (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void }) => Promise<void>;
|
||||
disconnect: (side: "left" | "right") => Promise<void>;
|
||||
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
|
||||
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||||
@@ -69,7 +69,7 @@ export const useSftpConnections = ({
|
||||
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
|
||||
|
||||
const connect = useCallback(
|
||||
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean }) => {
|
||||
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void }) => {
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
|
||||
let activeTabId: string | null = null;
|
||||
@@ -88,6 +88,11 @@ export const useSftpConnections = ({
|
||||
|
||||
if (!activeTabId) return;
|
||||
|
||||
// Notify caller of the tab ID synchronously, before any async work.
|
||||
// This allows callers to map metadata (e.g. connection keys) to the tab
|
||||
// immediately, avoiding race conditions with deferred effects.
|
||||
options?.onTabCreated?.(activeTabId);
|
||||
|
||||
const connectionId = `${side}-${Date.now()}`;
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
@@ -118,12 +123,15 @@ export const useSftpConnections = ({
|
||||
if (currentPane?.connection && !currentPane.connection.isLocal) {
|
||||
const oldSftpId = sftpSessionsRef.current.get(currentPane.connection.id);
|
||||
if (oldSftpId) {
|
||||
// Delete the mapping BEFORE the async closeSftp call to prevent
|
||||
// concurrent code from using a stale sftpId that the backend may
|
||||
// have already removed during the await.
|
||||
sftpSessionsRef.current.delete(currentPane.connection.id);
|
||||
try {
|
||||
await netcattyBridge.get()?.closeSftp(oldSftpId);
|
||||
} catch {
|
||||
// Ignore errors when closing stale SFTP sessions
|
||||
}
|
||||
sftpSessionsRef.current.delete(currentPane.connection.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,6 +159,7 @@ export const useSftpConnections = ({
|
||||
loading: true,
|
||||
reconnecting: false,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
filenameEncoding, // Reset encoding for new connection
|
||||
}));
|
||||
|
||||
@@ -205,13 +214,57 @@ export const useSftpConnections = ({
|
||||
loading: true,
|
||||
reconnecting: prev.reconnecting,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
files: prev.reconnecting ? prev.files : (sharedHostCache?.files ?? []),
|
||||
filenameEncoding, // Reset encoding for new connection
|
||||
}));
|
||||
|
||||
// Subscribe to SFTP connection progress events for auth logging
|
||||
const sftpSessionId = `sftp-${connectionId}`;
|
||||
let unsubSftpProgress: (() => void) | undefined;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.onSftpConnectionProgress) {
|
||||
unsubSftpProgress = bridge.onSftpConnectionProgress((sid, label, status, detail) => {
|
||||
if (sid !== sftpSessionId) return;
|
||||
let logLine: string;
|
||||
switch (status) {
|
||||
case 'connecting':
|
||||
logLine = `Connecting to ${label}...`;
|
||||
break;
|
||||
case 'authenticating':
|
||||
logLine = `${label} - Key exchange complete`;
|
||||
break;
|
||||
case 'auth-attempt':
|
||||
if (detail?.endsWith('rejected')) {
|
||||
logLine = `${label} - ✗ ${detail}`;
|
||||
} else if (detail === 'all methods exhausted') {
|
||||
logLine = `${label} - ✗ All authentication methods exhausted`;
|
||||
} else if (detail === 'waiting for user input...' || detail === 'user responded') {
|
||||
logLine = `${label} - ${detail}`;
|
||||
} else {
|
||||
logLine = `${label} - Trying ${detail}...`;
|
||||
}
|
||||
break;
|
||||
case 'connected':
|
||||
logLine = `${label} - Connected`;
|
||||
break;
|
||||
case 'error':
|
||||
logLine = `${label} - Error${detail ? `: ${detail}` : ''}`;
|
||||
break;
|
||||
default:
|
||||
logLine = `${label} - ${status}${detail ? `: ${detail}` : ''}`;
|
||||
}
|
||||
// Only update if this is still the active request (avoids stale logs leaking)
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connectionLogs: [...prev.connectionLogs, logLine],
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const credentials = getHostCredentials(host);
|
||||
const bridge = netcattyBridge.get();
|
||||
const openSftp = bridge?.openSftp;
|
||||
if (!openSftp) throw new Error("SFTP bridge unavailable");
|
||||
|
||||
@@ -270,8 +323,24 @@ export const useSftpConnections = ({
|
||||
let homeDir = sharedHostCache?.homeDir ?? startPath;
|
||||
|
||||
if (!sharedHostCache) {
|
||||
const statSftp = netcattyBridge.get()?.statSftp;
|
||||
if (statSftp) {
|
||||
// Detect home directory: SSH exec `echo ~` → SFTP realpath('.') → hardcoded fallback
|
||||
const bridge = netcattyBridge.get();
|
||||
let detected = false;
|
||||
|
||||
if (bridge?.getSftpHomeDir) {
|
||||
try {
|
||||
const result = await bridge.getSftpHomeDir(sftpId);
|
||||
if (result?.success && result.homeDir) {
|
||||
startPath = result.homeDir;
|
||||
homeDir = result.homeDir;
|
||||
detected = true;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to hardcoded candidates
|
||||
}
|
||||
}
|
||||
|
||||
if (!detected) {
|
||||
const candidates: string[] = [];
|
||||
if (credentials.username === "root") {
|
||||
candidates.push("/root");
|
||||
@@ -281,63 +350,33 @@ export const useSftpConnections = ({
|
||||
} 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
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (credentials.username === "root") {
|
||||
try {
|
||||
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
|
||||
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 === "/") {
|
||||
const statSftp = bridge?.statSftp;
|
||||
if (statSftp) {
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
|
||||
if (rootFiles) {
|
||||
startPath = "/root";
|
||||
homeDir = "/root";
|
||||
const stat = await statSftp(sftpId, candidate, filenameEncoding);
|
||||
if (stat?.type === "directory") {
|
||||
startPath = candidate;
|
||||
homeDir = candidate;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Fallback path not available
|
||||
// Ignore missing/permission errors
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
|
||||
if (rootFiles) {
|
||||
startPath = "/root";
|
||||
homeDir = "/root";
|
||||
// Fallback: probe candidates via listSftp when statSftp is unavailable
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const files = await bridge?.listSftp(sftpId, candidate, filenameEncoding);
|
||||
if (files) {
|
||||
startPath = candidate;
|
||||
homeDir = candidate;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Ignore missing/permission errors
|
||||
}
|
||||
} catch {
|
||||
// Fallback path not available
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -413,6 +452,7 @@ export const useSftpConnections = ({
|
||||
files,
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
connectionLogs: [], // Clear after successful connect to avoid replay during navigation
|
||||
}));
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
@@ -430,6 +470,8 @@ export const useSftpConnections = ({
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
} finally {
|
||||
unsubSftpProgress?.();
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -454,32 +496,39 @@ export const useSftpConnections = ({
|
||||
!initialConnectDoneRef.current &&
|
||||
leftTabs.tabs.length === 0
|
||||
) {
|
||||
initialConnectDoneRef.current = true;
|
||||
setTimeout(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
initialConnectDoneRef.current = true;
|
||||
connect("left", "local");
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}
|
||||
}, [autoConnectLocalOnMount, connect, leftTabs.tabs.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const attemptReconnect = async (side: "left" | "right") => {
|
||||
const reconnectTimers: number[] = [];
|
||||
|
||||
const scheduleReconnect = (side: "left" | "right") => {
|
||||
const lastHost = lastConnectedHostRef.current[side];
|
||||
if (lastHost && reconnectingRef.current[side]) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
if (reconnectingRef.current[side]) {
|
||||
connect(side, lastHost);
|
||||
}
|
||||
}
|
||||
if (!lastHost || !reconnectingRef.current[side]) return;
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
if (!reconnectingRef.current[side]) return;
|
||||
void connect(side, lastHost);
|
||||
}, 1000);
|
||||
reconnectTimers.push(timer);
|
||||
};
|
||||
|
||||
if (leftPane.reconnecting && reconnectingRef.current.left) {
|
||||
attemptReconnect("left");
|
||||
scheduleReconnect("left");
|
||||
}
|
||||
if (rightPane.reconnecting && reconnectingRef.current.right) {
|
||||
attemptReconnect("right");
|
||||
scheduleReconnect("right");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [leftPane.reconnecting, rightPane.reconnecting, connect]);
|
||||
|
||||
return () => {
|
||||
reconnectTimers.forEach((timer) => window.clearTimeout(timer));
|
||||
};
|
||||
}, [leftPane.reconnecting, rightPane.reconnecting, connect, lastConnectedHostRef, reconnectingRef]);
|
||||
|
||||
const disconnect = useCallback(
|
||||
async (side: "left" | "right") => {
|
||||
|
||||
@@ -49,6 +49,7 @@ export const useSftpDirectoryListing = () => {
|
||||
sizeFormatted: formatFileSize(size),
|
||||
lastModified,
|
||||
lastModifiedFormatted: formatDate(lastModified),
|
||||
permissions: f.permissions,
|
||||
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ export type { UploadResult };
|
||||
|
||||
interface UseSftpExternalOperationsParams {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
refresh: (side: "left" | "right") => Promise<void>;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
|
||||
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
|
||||
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
|
||||
clearDirCacheEntry?: (connectionId: string, path: string) => void;
|
||||
@@ -524,6 +524,7 @@ export const useSftpExternalOperations = (
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
const uploadPaneId = pane.id;
|
||||
// Create a new upload controller for this upload
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
@@ -550,7 +551,7 @@ export const useSftpExternalOperations = (
|
||||
controller
|
||||
);
|
||||
|
||||
await refresh(side);
|
||||
await refresh(side, { tabId: uploadPaneId });
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error("[SFTP] Upload failed:", error);
|
||||
@@ -594,6 +595,9 @@ export const useSftpExternalOperations = (
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
// Capture the pane ID now so we can refresh the correct tab after
|
||||
// upload, even if focus switches during the transfer.
|
||||
const uploadPaneId = pane.id;
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
const uploadTargetPath = options?.targetPath || pane.connection.currentPath;
|
||||
@@ -623,17 +627,14 @@ export const useSftpExternalOperations = (
|
||||
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);
|
||||
// Refresh the specific tab that initiated the upload (not whichever
|
||||
// tab is active now — focus may have switched during the transfer).
|
||||
// Also invalidate the upload target's cache entry so returning to
|
||||
// that path triggers a fresh listing.
|
||||
if (clearDirCacheEntry) {
|
||||
clearDirCacheEntry(pane.connection.id, uploadTargetPath);
|
||||
}
|
||||
await refresh(side, { tabId: uploadPaneId });
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error("[SFTP] Upload failed:", error);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Host, Identity, SSHKey } from "../../../domain/models";
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
|
||||
import { resolveHostAuth } from "../../../domain/sshAuth";
|
||||
|
||||
interface UseSftpHostCredentialsParams {
|
||||
@@ -24,22 +25,32 @@ export const useSftpHostCredentials = ({
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
username: host.proxyConfig.username,
|
||||
password: host.proxyConfig.password,
|
||||
password: sanitizeCredentialValue(host.proxyConfig.password),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
let jumpHosts: NetcattyJumpHost[] | undefined;
|
||||
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
|
||||
jumpHosts = host.hostChain.hostIds
|
||||
.map((hostId) => hosts.find((h) => h.id === hostId))
|
||||
.filter((h): h is Host => !!h)
|
||||
.map((jumpHost) => {
|
||||
.map((jumpHost, index) => {
|
||||
const jumpAuth = resolveHostAuth({
|
||||
host: jumpHost,
|
||||
keys,
|
||||
identities,
|
||||
});
|
||||
const jumpKey = jumpAuth.key;
|
||||
const hasConfiguredJumpProxyEndpoint =
|
||||
index === 0 &&
|
||||
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
|
||||
if (
|
||||
hasConfiguredJumpProxyEndpoint &&
|
||||
jumpHost.proxyConfig?.username &&
|
||||
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
|
||||
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
|
||||
) {
|
||||
throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
|
||||
}
|
||||
return {
|
||||
hostname: jumpHost.hostname,
|
||||
port: jumpHost.port || 22,
|
||||
@@ -52,9 +63,23 @@ export const useSftpHostCredentials = ({
|
||||
keyId: jumpAuth.keyId,
|
||||
keySource: jumpKey?.source,
|
||||
label: jumpHost.label,
|
||||
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
|
||||
? {
|
||||
type: jumpHost.proxyConfig.type,
|
||||
host: jumpHost.proxyConfig.host,
|
||||
port: jumpHost.proxyConfig.port,
|
||||
username: jumpHost.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
|
||||
}
|
||||
: undefined,
|
||||
identityFilePaths: jumpHost.identityFilePaths,
|
||||
};
|
||||
});
|
||||
}
|
||||
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts?.[0]?.proxy;
|
||||
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxyConfig?.password) {
|
||||
throw new Error("Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.");
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: host.hostname,
|
||||
@@ -70,6 +95,7 @@ export const useSftpHostCredentials = ({
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
sudo: host.sftpSudo,
|
||||
identityFilePaths: host.identityFilePaths,
|
||||
};
|
||||
},
|
||||
[hosts, identities, keys],
|
||||
|
||||
@@ -29,8 +29,8 @@ interface UseSftpPaneActionsParams {
|
||||
}
|
||||
|
||||
interface UseSftpPaneActionsResult {
|
||||
navigateTo: (side: "left" | "right", path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
refresh: (side: "left" | "right") => Promise<void>;
|
||||
navigateTo: (side: "left" | "right", path: string, options?: { force?: boolean; tabId?: string }) => Promise<void>;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
|
||||
navigateUp: (side: "left" | "right") => Promise<void>;
|
||||
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
|
||||
toggleSelection: (side: "left" | "right", fileName: string, multiSelect: boolean) => void;
|
||||
@@ -114,23 +114,18 @@ export const useSftpPaneActions = ({
|
||||
async (
|
||||
side: "left" | "right",
|
||||
path: string,
|
||||
options?: { force?: boolean },
|
||||
options?: { force?: boolean; tabId?: string },
|
||||
) => {
|
||||
console.log("[SFTP navigateTo] called", { side, path, force: options?.force });
|
||||
|
||||
const pane = getActivePane(side);
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
const activeTabId = sideTabs.activeTabId;
|
||||
// When tabId is specified, target that specific tab instead of the active one.
|
||||
// This allows refreshing a background tab (e.g. after a transfer completes
|
||||
// while focus has switched to another host).
|
||||
const targetTabId = options?.tabId ?? sideTabs.activeTabId;
|
||||
const pane = options?.tabId
|
||||
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
|
||||
: getActivePane(side);
|
||||
|
||||
console.log("[SFTP navigateTo] state check", {
|
||||
paneId: pane?.id,
|
||||
hasConnection: !!pane?.connection,
|
||||
activeTabId,
|
||||
currentPath: pane?.connection?.currentPath,
|
||||
});
|
||||
|
||||
if (!pane?.connection || !activeTabId) {
|
||||
console.log("[SFTP navigateTo] No pane/connection/activeTabId, returning early");
|
||||
if (!pane?.connection || !targetTabId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -146,15 +141,14 @@ export const useSftpPaneActions = ({
|
||||
Date.now() - cached.timestamp < dirCacheTtlMs &&
|
||||
cached.files
|
||||
) {
|
||||
console.log("[SFTP navigateTo] Using cached files for path", { path, cacheKey });
|
||||
tabNavSeqRef.current.set(activeTabId, requestId);
|
||||
lastConfirmedRef.current.set(activeTabId, {
|
||||
tabNavSeqRef.current.set(targetTabId, requestId);
|
||||
lastConfirmedRef.current.set(targetTabId, {
|
||||
connectionId,
|
||||
path,
|
||||
files: cached.files,
|
||||
selectedFiles: new Set(),
|
||||
});
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
updateTab(side, targetTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
? { ...prev.connection, currentPath: path }
|
||||
@@ -180,29 +174,28 @@ export const useSftpPaneActions = ({
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[SFTP navigateTo] Fetching files from server for path", { path });
|
||||
// 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);
|
||||
const existing = lastConfirmedRef.current.get(targetTabId);
|
||||
if (!existing || existing.connectionId !== connectionId || !pane.loading) {
|
||||
lastConfirmedRef.current.set(activeTabId, {
|
||||
lastConfirmedRef.current.set(targetTabId, {
|
||||
connectionId,
|
||||
path: pane.connection.currentPath,
|
||||
files: pane.files,
|
||||
selectedFiles: pane.selectedFiles,
|
||||
});
|
||||
}
|
||||
const confirmed = lastConfirmedRef.current.get(activeTabId)!;
|
||||
const confirmed = lastConfirmedRef.current.get(targetTabId)!;
|
||||
const previousPath = confirmed.path;
|
||||
const previousFiles = confirmed.files;
|
||||
const previousSelection = confirmed.selectedFiles;
|
||||
tabNavSeqRef.current.set(activeTabId, requestId);
|
||||
tabNavSeqRef.current.set(targetTabId, 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) => ({
|
||||
updateTab(side, targetTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
? { ...prev.connection, currentPath: path }
|
||||
@@ -221,16 +214,17 @@ export const useSftpPaneActions = ({
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: null,
|
||||
files: [],
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: "SFTP session lost. Please reconnect.",
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
}));
|
||||
// For background tabs (explicit tabId), update that tab directly
|
||||
// instead of handleSessionError which targets the active tab.
|
||||
if (options?.tabId) {
|
||||
updateTab(side, targetTabId, (prev) => ({
|
||||
...prev,
|
||||
error: "sftp.error.sessionLost",
|
||||
loading: false,
|
||||
}));
|
||||
} else {
|
||||
handleSessionError(side, new Error("SFTP session lost"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -240,16 +234,15 @@ export const useSftpPaneActions = ({
|
||||
if (isSessionError(err)) {
|
||||
sftpSessionsRef.current.delete(pane.connection.id);
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: null,
|
||||
files: [],
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: "SFTP session expired. Please reconnect.",
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
}));
|
||||
if (options?.tabId) {
|
||||
updateTab(side, targetTabId, (prev) => ({
|
||||
...prev,
|
||||
error: "sftp.error.sessionLost",
|
||||
loading: false,
|
||||
}));
|
||||
} else {
|
||||
handleSessionError(side, err as Error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw err as Error;
|
||||
@@ -257,27 +250,15 @@ export const useSftpPaneActions = ({
|
||||
}
|
||||
|
||||
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) {
|
||||
// Side-level sequence was bumped by another tab's navigation or
|
||||
// a connect/disconnect. Check if THIS tab's request is still current.
|
||||
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
|
||||
// This tab also has a newer navigation — drop completely.
|
||||
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;
|
||||
// Side was superseded by another tab, but this tab's request is
|
||||
// still current. The fetched files are valid — fall through to
|
||||
// apply them instead of restoring previousPath.
|
||||
}
|
||||
|
||||
dirCacheRef.current.set(cacheKey, {
|
||||
@@ -285,14 +266,14 @@ export const useSftpPaneActions = ({
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
lastConfirmedRef.current.set(activeTabId, {
|
||||
lastConfirmedRef.current.set(targetTabId, {
|
||||
connectionId,
|
||||
path,
|
||||
files,
|
||||
selectedFiles: new Set(),
|
||||
});
|
||||
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
updateTab(side, targetTabId, (prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
? { ...prev.connection, currentPath: path }
|
||||
@@ -311,24 +292,13 @@ export const useSftpPaneActions = ({
|
||||
}
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== requestId) {
|
||||
if (tabNavSeqRef.current.get(activeTabId) !== requestId) {
|
||||
if (tabNavSeqRef.current.get(targetTabId) !== 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;
|
||||
// Side superseded by another tab, but this tab's request is
|
||||
// current — fall through to show the error on this tab.
|
||||
}
|
||||
updateTab(side, activeTabId, (prev) => {
|
||||
updateTab(side, targetTabId, (prev) => {
|
||||
if (prev.connection?.id !== connectionId) {
|
||||
return prev;
|
||||
}
|
||||
@@ -358,16 +328,24 @@ export const useSftpPaneActions = ({
|
||||
listRemoteFiles,
|
||||
sftpSessionsRef,
|
||||
clearCacheForConnection,
|
||||
handleSessionError,
|
||||
isSessionError,
|
||||
],
|
||||
);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (side: "left" | "right") => {
|
||||
const pane = getActivePane(side);
|
||||
async (side: "left" | "right", options?: { tabId?: string }) => {
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
const pane = options?.tabId
|
||||
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
|
||||
: getActivePane(side);
|
||||
if (pane?.connection) {
|
||||
await navigateTo(side, pane.connection.currentPath, { force: true });
|
||||
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId });
|
||||
} else if (!pane?.connection && pane?.error) {
|
||||
// For background tabs, don't trigger reconnection (it operates on
|
||||
// the active tab). Just leave the error state for the user to see
|
||||
// when they switch back to that tab.
|
||||
if (options?.tabId) return;
|
||||
const lastHost = lastConnectedHostRef.current[side];
|
||||
if (lastHost && !reconnectingRef.current[side]) {
|
||||
reconnectingRef.current[side] = true;
|
||||
@@ -384,7 +362,7 @@ export const useSftpPaneActions = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[getActivePane, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef],
|
||||
[getActivePane, leftTabsRef, rightTabsRef, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef],
|
||||
);
|
||||
|
||||
const navigateUp = useCallback(
|
||||
@@ -405,42 +383,24 @@ export const useSftpPaneActions = ({
|
||||
|
||||
const openEntry = useCallback(
|
||||
async (side: "left" | "right", entry: SftpFileEntry) => {
|
||||
console.log("[SFTP openEntry] called", { side, entryName: entry.name, entryType: entry.type });
|
||||
|
||||
const pane = getActivePane(side);
|
||||
console.log("[SFTP openEntry] getActivePane result", {
|
||||
paneId: pane?.id,
|
||||
hasConnection: !!pane?.connection,
|
||||
currentPath: pane?.connection?.currentPath,
|
||||
});
|
||||
|
||||
if (!pane?.connection) {
|
||||
console.log("[SFTP openEntry] No pane or connection, returning early");
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.name === "..") {
|
||||
const currentPath = pane.connection.currentPath;
|
||||
const isAtRoot = currentPath === "/" || isWindowsRoot(currentPath);
|
||||
console.log("[SFTP openEntry] Navigating up from '..'", {
|
||||
currentPath,
|
||||
isAtRoot,
|
||||
isWindowsRoot: isWindowsRoot(currentPath),
|
||||
});
|
||||
|
||||
if (!isAtRoot) {
|
||||
const parentPath = getParentPath(currentPath);
|
||||
console.log("[SFTP openEntry] Calculated parent path", { currentPath, parentPath });
|
||||
await navigateTo(side, parentPath);
|
||||
} else {
|
||||
console.log("[SFTP openEntry] Already at root, not navigating");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNavigableDirectory(entry)) {
|
||||
const newPath = joinPath(pane.connection.currentPath, entry.name);
|
||||
console.log("[SFTP openEntry] Navigating into directory", { currentPath: pane.connection.currentPath, entryName: entry.name, newPath });
|
||||
await navigateTo(side, newPath);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { createEmptyPane, EMPTY_LEFT_PANE_ID, EMPTY_RIGHT_PANE_ID, SftpPane, SftpSideTabs } from "./types";
|
||||
import { logger } from "../../../lib/logger";
|
||||
|
||||
export interface SftpTabsState {
|
||||
interface SftpTabsState {
|
||||
leftTabs: SftpSideTabs;
|
||||
rightTabs: SftpSideTabs;
|
||||
leftTabsRef: React.MutableRefObject<SftpSideTabs>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
FileConflict,
|
||||
SftpFileEntry,
|
||||
@@ -14,7 +14,7 @@ import { joinPath } from "./utils";
|
||||
|
||||
interface UseSftpTransfersParams {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
refresh: (side: "left" | "right") => Promise<void>;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
|
||||
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
|
||||
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
|
||||
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||||
@@ -64,66 +64,10 @@ export const useSftpTransfers = ({
|
||||
const [transfers, setTransfers] = useState<TransferTask[]>([]);
|
||||
const [conflicts, setConflicts] = useState<FileConflict[]>([]);
|
||||
|
||||
const progressIntervalsRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
// Track cancelled task IDs for checking during async operations
|
||||
const cancelledTasksRef = useRef<Set<string>>(new Set());
|
||||
const completionHandlersRef = useRef<Map<string, (result: TransferResult) => void | Promise<void>>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const intervalsRef = progressIntervalsRef.current;
|
||||
return () => {
|
||||
intervalsRef.forEach((interval) => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
intervalsRef.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startProgressSimulation = useCallback(
|
||||
(taskId: string, estimatedBytes: number) => {
|
||||
const existing = progressIntervalsRef.current.get(taskId);
|
||||
if (existing) clearInterval(existing);
|
||||
|
||||
const baseSpeed = Math.max(50000, Math.min(500000, estimatedBytes / 10));
|
||||
const variability = 0.3;
|
||||
|
||||
let transferred = 0;
|
||||
const interval = setInterval(() => {
|
||||
const speedFactor = 1 + (Math.random() - 0.5) * variability;
|
||||
const chunkSize = Math.floor(baseSpeed * speedFactor * 0.1);
|
||||
transferred = Math.min(transferred + chunkSize, estimatedBytes);
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== taskId || t.status !== "transferring") return t;
|
||||
return {
|
||||
...t,
|
||||
transferredBytes: transferred,
|
||||
totalBytes: estimatedBytes,
|
||||
speed: chunkSize * 10,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (transferred >= estimatedBytes * 0.95) {
|
||||
clearInterval(interval);
|
||||
progressIntervalsRef.current.delete(taskId);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
progressIntervalsRef.current.set(taskId, interval);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const stopProgressSimulation = useCallback((taskId: string) => {
|
||||
const interval = progressIntervalsRef.current.get(taskId);
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
progressIntervalsRef.current.delete(taskId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearCancelledTask = useCallback((taskId: string) => {
|
||||
cancelledTasksRef.current.delete(taskId);
|
||||
}, []);
|
||||
@@ -207,114 +151,64 @@ export const useSftpTransfers = ({
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
if (netcattyBridge.get()?.startStreamTransfer) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
transferId: task.id,
|
||||
sourcePath: task.sourcePath,
|
||||
targetPath: task.targetPath,
|
||||
sourceType: sourceIsLocal ? ("local" as const) : ("sftp" as const),
|
||||
targetType: targetIsLocal ? ("local" as const) : ("sftp" as const),
|
||||
sourceSftpId: sourceSftpId || undefined,
|
||||
targetSftpId: targetSftpId || undefined,
|
||||
totalBytes: task.totalBytes || undefined,
|
||||
sourceEncoding: sourceIsLocal ? undefined : sourceEncoding,
|
||||
targetEncoding: targetIsLocal ? undefined : targetEncoding,
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
transferId: task.id,
|
||||
sourcePath: task.sourcePath,
|
||||
targetPath: task.targetPath,
|
||||
sourceType: sourceIsLocal ? ("local" as const) : ("sftp" as const),
|
||||
targetType: targetIsLocal ? ("local" as const) : ("sftp" as const),
|
||||
sourceSftpId: sourceSftpId || undefined,
|
||||
targetSftpId: targetSftpId || undefined,
|
||||
totalBytes: task.totalBytes || undefined,
|
||||
sourceEncoding: sourceIsLocal ? undefined : sourceEncoding,
|
||||
targetEncoding: targetIsLocal ? undefined : targetEncoding,
|
||||
};
|
||||
|
||||
const onProgress = (
|
||||
transferred: number,
|
||||
total: number,
|
||||
speed: number,
|
||||
) => {
|
||||
// Bubble up streaming progress to parent (for directory transfers)
|
||||
onStreamProgress?.(transferred, total, speed);
|
||||
const onProgress = (
|
||||
transferred: number,
|
||||
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: normalizedTransferred,
|
||||
totalBytes: normalizedTotal,
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onComplete = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onError = (error: string) => {
|
||||
reject(new Error(error));
|
||||
};
|
||||
|
||||
netcattyBridge.require().startStreamTransfer!(
|
||||
options,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onError,
|
||||
).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
let content: ArrayBuffer | string;
|
||||
|
||||
if (sourceIsLocal) {
|
||||
content =
|
||||
(await netcattyBridge.get()?.readLocalFile?.(task.sourcePath)) ||
|
||||
new ArrayBuffer(0);
|
||||
} else if (sourceSftpId) {
|
||||
if (netcattyBridge.get()?.readSftpBinary) {
|
||||
content = await netcattyBridge.get()!.readSftpBinary!(
|
||||
sourceSftpId,
|
||||
task.sourcePath,
|
||||
sourceEncoding,
|
||||
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;
|
||||
// Clamp to [previous, total] — the backend normalizes progress
|
||||
// but we guard against any non-monotonic edge cases.
|
||||
const normalizedTransferred = Math.max(
|
||||
t.transferredBytes,
|
||||
Math.min(transferred, normalizedTotal > 0 ? normalizedTotal : transferred),
|
||||
);
|
||||
return {
|
||||
...t,
|
||||
transferredBytes: normalizedTransferred,
|
||||
totalBytes: normalizedTotal,
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
content =
|
||||
(await netcattyBridge.get()?.readSftp(sourceSftpId, task.sourcePath, sourceEncoding)) || "";
|
||||
}
|
||||
} else {
|
||||
throw new Error("No source connection");
|
||||
}
|
||||
};
|
||||
|
||||
if (targetIsLocal) {
|
||||
if (content instanceof ArrayBuffer) {
|
||||
await netcattyBridge.get()?.writeLocalFile?.(task.targetPath, content);
|
||||
} else {
|
||||
const encoder = new TextEncoder();
|
||||
await netcattyBridge.get()?.writeLocalFile?.(
|
||||
task.targetPath,
|
||||
encoder.encode(content).buffer,
|
||||
);
|
||||
}
|
||||
} else if (targetSftpId) {
|
||||
if (content instanceof ArrayBuffer && netcattyBridge.get()?.writeSftpBinary) {
|
||||
await netcattyBridge.get()!.writeSftpBinary!(
|
||||
targetSftpId,
|
||||
task.targetPath,
|
||||
content,
|
||||
targetEncoding,
|
||||
);
|
||||
} else {
|
||||
const text =
|
||||
content instanceof ArrayBuffer
|
||||
? new TextDecoder().decode(content)
|
||||
: content;
|
||||
await netcattyBridge.get()?.writeSftp(targetSftpId, task.targetPath, text, targetEncoding);
|
||||
}
|
||||
} else {
|
||||
throw new Error("No target connection");
|
||||
}
|
||||
const onComplete = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onError = (error: string) => {
|
||||
reject(new Error(error));
|
||||
};
|
||||
|
||||
netcattyBridge.require().startStreamTransfer!(
|
||||
options,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onError,
|
||||
).catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
const transferDirectory = async (
|
||||
@@ -456,6 +350,7 @@ export const useSftpTransfers = ({
|
||||
// Fall back to the existing estimate below if size discovery fails.
|
||||
}
|
||||
} else if (actualFileSize === 0) {
|
||||
// Fallback stat when file wasn't in the pane's file list (e.g., filtered view)
|
||||
try {
|
||||
const sourceSftpId = sourcePane.connection?.isLocal
|
||||
? null
|
||||
@@ -463,14 +358,24 @@ export const useSftpTransfers = ({
|
||||
|
||||
if (sourcePane.connection?.isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
|
||||
if (stat) actualFileSize = stat.size;
|
||||
if (stat) {
|
||||
actualFileSize = stat.size;
|
||||
if (!task.sourceLastModified && stat.lastModified) {
|
||||
task.sourceLastModified = stat.lastModified;
|
||||
}
|
||||
}
|
||||
} else if (sourceSftpId) {
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
task.sourcePath,
|
||||
sourceEncoding,
|
||||
);
|
||||
if (stat) actualFileSize = stat.size;
|
||||
if (stat) {
|
||||
actualFileSize = stat.size;
|
||||
if (!task.sourceLastModified && stat.lastModified) {
|
||||
task.sourceLastModified = stat.lastModified;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore stat errors
|
||||
@@ -484,7 +389,6 @@ export const useSftpTransfers = ({
|
||||
? 1024 * 1024
|
||||
: 256 * 1024;
|
||||
|
||||
const hasStreamingTransfer = !!netcattyBridge.get()?.startStreamTransfer;
|
||||
|
||||
const sourceSftpId = sourcePane.connection?.isLocal
|
||||
? null
|
||||
@@ -504,8 +408,6 @@ export const useSftpTransfers = ({
|
||||
throw new Error("Target SFTP session not found");
|
||||
}
|
||||
|
||||
let useSimulatedProgress = false;
|
||||
|
||||
try {
|
||||
if (prescanCancelled) {
|
||||
throw new Error("Transfer cancelled");
|
||||
@@ -518,41 +420,14 @@ export const useSftpTransfers = ({
|
||||
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;
|
||||
let sourceStat: { size: number; mtime: number } | null = null;
|
||||
|
||||
try {
|
||||
if (sourcePane.connection.isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
|
||||
if (stat) {
|
||||
sourceStat = {
|
||||
size: stat.size,
|
||||
mtime: stat.lastModified || Date.now(),
|
||||
};
|
||||
}
|
||||
} else if (sourceSftpId) {
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
task.sourcePath,
|
||||
sourceEncoding,
|
||||
);
|
||||
if (stat) {
|
||||
sourceStat = {
|
||||
size: stat.size,
|
||||
mtime: stat.lastModified || Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// Use cached metadata from the task instead of an extra stat round-trip
|
||||
const sourceStat: { size: number; mtime: number } | null =
|
||||
(task.totalBytes > 0 || task.sourceLastModified)
|
||||
? { size: task.totalBytes, mtime: task.sourceLastModified || Date.now() }
|
||||
: null;
|
||||
|
||||
try {
|
||||
if (targetPane.connection.isLocal) {
|
||||
@@ -583,8 +458,6 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
|
||||
if (targetExists && existingStat) {
|
||||
stopProgressSimulation(task.id);
|
||||
|
||||
const newConflict: FileConflict = {
|
||||
transferId: task.id,
|
||||
fileName: task.fileName,
|
||||
@@ -654,10 +527,6 @@ export const useSftpTransfers = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (useSimulatedProgress) {
|
||||
stopProgressSimulation(task.id);
|
||||
}
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== task.id) return t;
|
||||
@@ -671,7 +540,9 @@ export const useSftpTransfers = ({
|
||||
}),
|
||||
);
|
||||
|
||||
await refresh(targetSide);
|
||||
// Refresh the specific target tab, not whichever tab happens to be
|
||||
// active now — focus may have switched during the transfer.
|
||||
await refresh(targetSide, { tabId: targetPane.id });
|
||||
const completionHandler = completionHandlersRef.current.get(task.id);
|
||||
if (completionHandler) {
|
||||
try {
|
||||
@@ -687,10 +558,6 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
return "completed";
|
||||
} catch (err) {
|
||||
if (useSimulatedProgress) {
|
||||
stopProgressSimulation(task.id);
|
||||
}
|
||||
|
||||
// Check if this was a cancellation
|
||||
const isCancelled = cancelledTasksRef.current.has(task.id) ||
|
||||
(err instanceof Error && err.message === "Transfer cancelled");
|
||||
@@ -754,18 +621,10 @@ export const useSftpTransfers = ({
|
||||
|
||||
if (!sourcePane?.connection || !targetPane?.connection) return [];
|
||||
|
||||
const sourceEncoding: SftpFilenameEncoding = sourcePane.connection.isLocal
|
||||
? "auto"
|
||||
: sourcePane.filenameEncoding || "auto";
|
||||
|
||||
const sourcePath = options?.sourcePath ?? sourcePane.connection.currentPath;
|
||||
const targetPath = targetPane.connection.currentPath;
|
||||
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
|
||||
|
||||
const sourceSftpId = sourcePane.connection.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(sourceConnectionId);
|
||||
|
||||
const newTasks: TransferTask[] = [];
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
@@ -776,25 +635,11 @@ export const useSftpTransfers = ({
|
||||
? "download"
|
||||
: "remote-to-remote";
|
||||
|
||||
let fileSize = 0;
|
||||
if (!file.isDirectory) {
|
||||
try {
|
||||
const fullPath = joinPath(sourcePath, file.name);
|
||||
if (sourcePane.connection!.isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(fullPath);
|
||||
if (stat) fileSize = stat.size;
|
||||
} else if (sourceSftpId) {
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
fullPath,
|
||||
sourceEncoding,
|
||||
);
|
||||
if (stat) fileSize = stat.size;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
// Use cached metadata from the source pane's file list to avoid
|
||||
// redundant stat calls over the network.
|
||||
const fileEntry = sourcePane.files.find((f) => f.name === file.name);
|
||||
const fileSize = file.isDirectory ? 0 : (fileEntry?.size ?? 0);
|
||||
const sourceLastModified = fileEntry?.lastModified ?? 0;
|
||||
|
||||
newTasks.push({
|
||||
id: crypto.randomUUID(),
|
||||
@@ -811,6 +656,7 @@ export const useSftpTransfers = ({
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: file.isDirectory,
|
||||
sourceLastModified,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -845,8 +691,6 @@ export const useSftpTransfers = ({
|
||||
// Add to cancelled set so async operations can check
|
||||
cancelledTasksRef.current.add(transferId);
|
||||
|
||||
stopProgressSimulation(transferId);
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === transferId
|
||||
@@ -870,7 +714,7 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
|
||||
},
|
||||
[stopProgressSimulation],
|
||||
[],
|
||||
);
|
||||
|
||||
const retryTransfer = useCallback(
|
||||
|
||||
@@ -48,39 +48,31 @@ export const joinPath = (base: string, name: string): string => {
|
||||
return `${normalizedBase}\\${name}`;
|
||||
}
|
||||
if (base === "/") return `/${name}`;
|
||||
return `${base}/${name}`;
|
||||
return `${base.replace(/\/+$/, "")}/${name}`;
|
||||
};
|
||||
|
||||
export const getParentPath = (path: string): string => {
|
||||
console.log("[SFTP getParentPath] input", { path, isWindows: isWindowsPath(path) });
|
||||
|
||||
if (isWindowsPath(path)) {
|
||||
const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, "");
|
||||
const drive = normalized.slice(0, 2);
|
||||
if (/^[A-Za-z]:$/.test(normalized) || /^[A-Za-z]:\\$/.test(normalized)) {
|
||||
console.log("[SFTP getParentPath] Windows root, returning", { result: `${drive}\\` });
|
||||
return `${drive}\\`;
|
||||
}
|
||||
const rest = normalized.slice(2).replace(/^[\\]+/, "");
|
||||
const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
|
||||
if (parts.length <= 1) {
|
||||
console.log("[SFTP getParentPath] Windows near root, returning", { result: `${drive}\\` });
|
||||
return `${drive}\\`;
|
||||
}
|
||||
parts.pop();
|
||||
const result = `${drive}\\${parts.join("\\")}`;
|
||||
console.log("[SFTP getParentPath] Windows result", { result });
|
||||
return result;
|
||||
}
|
||||
if (path === "/") {
|
||||
console.log("[SFTP getParentPath] Unix root, returning /");
|
||||
return "/";
|
||||
}
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
console.log("[SFTP getParentPath] Unix parts before pop", { parts: [...parts] });
|
||||
parts.pop();
|
||||
const result = parts.length ? `/${parts.join("/")}` : "/";
|
||||
console.log("[SFTP getParentPath] Unix result", { result, partsAfterPop: parts });
|
||||
return result;
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
@@ -32,6 +33,12 @@ function getAIBridge() {
|
||||
return (window as unknown as { netcatty?: Record<string, (...args: unknown[]) => unknown> }).netcatty;
|
||||
}
|
||||
|
||||
const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
|
||||
|
||||
function emitAIStateChanged(key: string) {
|
||||
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
|
||||
}
|
||||
|
||||
function cleanupAcpSessions(sessionIds: string[]) {
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiAcpCleanup || sessionIds.length === 0) return;
|
||||
@@ -40,6 +47,83 @@ function cleanupAcpSessions(sessionIds: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function isScopeKeyActive(scopeKey: string, activeTargetIds: Set<string>) {
|
||||
const separatorIndex = scopeKey.indexOf(':');
|
||||
if (separatorIndex === -1) return true;
|
||||
|
||||
const targetId = scopeKey.slice(separatorIndex + 1);
|
||||
if (!targetId) return true;
|
||||
|
||||
return activeTargetIds.has(targetId);
|
||||
}
|
||||
|
||||
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
const currentSessions = latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
const orphanedSessionIds = currentSessions
|
||||
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
|
||||
.map((session) => session.id);
|
||||
|
||||
if (orphanedSessionIds.length > 0) {
|
||||
const orphanedSessionIdSet = new Set(orphanedSessionIds);
|
||||
|
||||
// Determine which sessions can be restored via host-based matching
|
||||
const preservedIds = new Set<string>();
|
||||
for (const session of currentSessions) {
|
||||
if (!orphanedSessionIdSet.has(session.id)) continue;
|
||||
// Only preserve remote terminal sessions with real hostIds
|
||||
const isRestorable = session.scope.type === 'terminal'
|
||||
&& session.scope.hostIds?.length
|
||||
&& session.scope.hostIds.some((id) => !id.startsWith('local-') && !id.startsWith('serial-'));
|
||||
if (isRestorable) {
|
||||
preservedIds.add(session.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup ACP sessions for all orphans (both deleted and preserved).
|
||||
// Preserved sessions will get a new externalSessionId on next use,
|
||||
// so cleaning the old one is safe and prevents subprocess leaks.
|
||||
cleanupAcpSessions(orphanedSessionIds);
|
||||
|
||||
const nextSessions = currentSessions
|
||||
.filter((session) => !orphanedSessionIdSet.has(session.id) || preservedIds.has(session.id))
|
||||
.map((session) => {
|
||||
if (!preservedIds.has(session.id) || !session.externalSessionId) {
|
||||
return session;
|
||||
}
|
||||
// Drop transient ACP session handles so the next turn starts cleanly.
|
||||
return { ...session, externalSessionId: undefined };
|
||||
});
|
||||
|
||||
const sessionsChanged = nextSessions.length !== currentSessions.length
|
||||
|| nextSessions.some((session, index) => session !== currentSessions[index]);
|
||||
if (sessionsChanged) {
|
||||
setLatestAISessionsSnapshot(nextSessions);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(nextSessions));
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
}
|
||||
}
|
||||
|
||||
const activeSessionIdMap = latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap = { ...activeSessionIdMap };
|
||||
|
||||
for (const scopeKey of Object.keys(activeSessionIdMap)) {
|
||||
if (isScopeKeyActive(scopeKey, activeTargetIds)) continue;
|
||||
delete nextActiveSessionIdMap[scopeKey];
|
||||
activeSessionMapChanged = true;
|
||||
}
|
||||
|
||||
if (activeSessionMapChanged) {
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Maximum number of sessions to keep in localStorage. */
|
||||
const MAX_STORED_SESSIONS = 50;
|
||||
@@ -66,6 +150,30 @@ function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
||||
});
|
||||
}
|
||||
|
||||
let latestAISessionsSnapshot: AISession[] | null = null;
|
||||
let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = null;
|
||||
|
||||
function setLatestAISessionsSnapshot(sessions: AISession[]) {
|
||||
latestAISessionsSnapshot = sessions;
|
||||
}
|
||||
|
||||
function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<string, string | null>) {
|
||||
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
|
||||
}
|
||||
|
||||
function buildScopeKey(scope: AISessionScope) {
|
||||
return `${scope.type}:${scope.targetId ?? ''}`;
|
||||
}
|
||||
|
||||
function areHostIdsEqual(left?: string[], right?: string[]) {
|
||||
const leftIds = left ?? [];
|
||||
const rightIds = right ?? [];
|
||||
if (leftIds.length !== rightIds.length) return false;
|
||||
|
||||
const rightSet = new Set(rightIds);
|
||||
return leftIds.every((hostId) => rightSet.has(hostId));
|
||||
}
|
||||
|
||||
export function useAIState() {
|
||||
// ── Provider Config ──
|
||||
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
|
||||
@@ -117,7 +225,9 @@ export function useAIState() {
|
||||
sessionsRef.current = sessions;
|
||||
}, [sessions]);
|
||||
// Per-scope active session: keyed by `${scopeType}:${scopeTargetId}`
|
||||
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>({});
|
||||
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>(() =>
|
||||
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {}
|
||||
);
|
||||
|
||||
// Per-agent model selection: remembers last selected model per agent
|
||||
const [agentModelMap, setAgentModelMapRaw] = useState<Record<string, string>>(() =>
|
||||
@@ -129,8 +239,43 @@ export function useAIState() {
|
||||
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLatestAISessionsSnapshot(sessions);
|
||||
}, [sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
setLatestAIActiveSessionMapSnapshot(activeSessionIdMap);
|
||||
}, [activeSessionIdMap]);
|
||||
|
||||
useEffect(() => {
|
||||
const validSessionIds = new Set(sessions.map((session) => session.id));
|
||||
let changed = false;
|
||||
const nextActiveSessionIdMap: Record<string, string | null> = {};
|
||||
|
||||
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
const nextSessionId = sessionId && validSessionIds.has(sessionId) ? sessionId : null;
|
||||
nextActiveSessionIdMap[scopeKey] = nextSessionId;
|
||||
if (nextSessionId !== sessionId) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}, [sessions, activeSessionIdMap]);
|
||||
|
||||
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
|
||||
setActiveSessionIdMapRaw(prev => ({ ...prev, [scopeKey]: id }));
|
||||
setActiveSessionIdMapRaw(prev => {
|
||||
const next = { ...prev, [scopeKey]: id };
|
||||
setLatestAIActiveSessionMapSnapshot(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setAgentModel = useCallback((agentId: string, modelId: string) => {
|
||||
@@ -303,9 +448,22 @@ export function useAIState() {
|
||||
setHostPermissionsRaw(perms ?? []);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_SESSIONS: {
|
||||
const nextSessions = localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
|
||||
setLatestAISessionsSnapshot(nextSessions);
|
||||
setSessionsRaw(nextSessions);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_AGENT_MODEL_MAP:
|
||||
setAgentModelMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {});
|
||||
break;
|
||||
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP: {
|
||||
const nextActiveSessionIdMap =
|
||||
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_WEB_SEARCH:
|
||||
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
|
||||
break;
|
||||
@@ -315,7 +473,33 @@ export function useAIState() {
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => window.removeEventListener('storage', handleStorage);
|
||||
const handleLocalStateChanged = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (!key) return;
|
||||
switch (key) {
|
||||
case STORAGE_KEY_AI_SESSIONS:
|
||||
setSessionsRaw(
|
||||
latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [],
|
||||
);
|
||||
return;
|
||||
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP:
|
||||
setActiveSessionIdMapRaw(
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {},
|
||||
);
|
||||
return;
|
||||
default:
|
||||
handleStorage({ key } as StorageEvent);
|
||||
}
|
||||
};
|
||||
window.addEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorage);
|
||||
window.removeEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Sync initial safety settings to MCP Server on mount ──
|
||||
@@ -375,6 +559,7 @@ export function useAIState() {
|
||||
};
|
||||
setSessionsRaw(prev => {
|
||||
const next = [session, ...prev];
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
@@ -391,12 +576,19 @@ export function useAIState() {
|
||||
}
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.filter(s => s.id !== sessionId);
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
if (scopeKey) {
|
||||
setActiveSessionIdMapRaw(prev => {
|
||||
if (prev[scopeKey] === sessionId) return { ...prev, [scopeKey]: null };
|
||||
if (prev[scopeKey] === sessionId) {
|
||||
const next = { ...prev, [scopeKey]: null };
|
||||
setLatestAIActiveSessionMapSnapshot(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
return next;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
@@ -415,12 +607,19 @@ export function useAIState() {
|
||||
const next = prev.filter(s => {
|
||||
return !(s.scope.type === scopeType && s.scope.targetId === targetId);
|
||||
});
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
const scopeKey = `${scopeType}:${targetId}`;
|
||||
setActiveSessionIdMapRaw(prev => {
|
||||
if (prev[scopeKey] != null) return { ...prev, [scopeKey]: null };
|
||||
if (prev[scopeKey] != null) {
|
||||
const next = { ...prev, [scopeKey]: null };
|
||||
setLatestAIActiveSessionMapSnapshot(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
return next;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
@@ -428,6 +627,7 @@ export function useAIState() {
|
||||
const updateSessionTitle = useCallback((sessionId: string, title: string) => {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => s.id === sessionId ? { ...s, title, updatedAt: Date.now() } : s);
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
@@ -440,11 +640,67 @@ export function useAIState() {
|
||||
? { ...s, externalSessionId, updatedAt: Date.now() }
|
||||
: s
|
||||
));
|
||||
setLatestAISessionsSnapshot(next);
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
}, [debouncedPersistSessions]);
|
||||
|
||||
const retargetSessionScope = useCallback((sessionId: string, scope: AISessionScope) => {
|
||||
const currentSession = sessionsRef.current.find((session) => session.id === sessionId);
|
||||
if (!currentSession) return;
|
||||
|
||||
const currentScope = currentSession.scope;
|
||||
const scopeChanged =
|
||||
currentScope.type !== scope.type
|
||||
|| currentScope.targetId !== scope.targetId
|
||||
|| !areHostIdsEqual(currentScope.hostIds, scope.hostIds);
|
||||
|
||||
const nextScopeKey = buildScopeKey(scope);
|
||||
const currentScopeKey = buildScopeKey(currentScope);
|
||||
|
||||
if (scopeChanged) {
|
||||
setSessionsRaw((prev) => {
|
||||
let changed = false;
|
||||
const next = prev.map((session) => {
|
||||
if (session.id !== sessionId) return session;
|
||||
changed = true;
|
||||
// Clear stale ACP handle — retarget may run before orphan cleanup
|
||||
return { ...session, scope, externalSessionId: undefined };
|
||||
});
|
||||
|
||||
if (!changed) return prev;
|
||||
|
||||
sessionsRef.current = next;
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
setActiveSessionIdMapRaw((prev) => {
|
||||
let changed = false;
|
||||
const next = { ...prev };
|
||||
|
||||
if (currentScopeKey !== nextScopeKey && next[currentScopeKey] === sessionId) {
|
||||
delete next[currentScopeKey];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (next[nextScopeKey] !== sessionId) {
|
||||
next[nextScopeKey] = sessionId;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) return prev;
|
||||
|
||||
setLatestAIActiveSessionMapSnapshot(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
return next;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
// Maximum messages per session to prevent unbounded memory growth
|
||||
const MAX_MESSAGES_PER_SESSION = 500;
|
||||
|
||||
@@ -463,6 +719,7 @@ export function useAIState() {
|
||||
}
|
||||
return { ...s, messages: msgs, updatedAt: Date.now() };
|
||||
});
|
||||
setLatestAISessionsSnapshot(next);
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
@@ -476,6 +733,7 @@ export function useAIState() {
|
||||
msgs[msgs.length - 1] = updater(msgs[msgs.length - 1]);
|
||||
return { ...s, messages: msgs, updatedAt: Date.now() };
|
||||
});
|
||||
setLatestAISessionsSnapshot(next);
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
@@ -491,6 +749,7 @@ export function useAIState() {
|
||||
msgs[idx] = updater(msgs[idx]);
|
||||
return { ...s, messages: msgs, updatedAt: Date.now() };
|
||||
});
|
||||
setLatestAISessionsSnapshot(next);
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
@@ -503,29 +762,21 @@ export function useAIState() {
|
||||
}
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => s.id === sessionId ? { ...s, messages: [], updatedAt: Date.now() } : s);
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
|
||||
const removedSessionIds = sessionsRef.current
|
||||
.filter(s => s.scope.targetId && !activeTargetIds.has(s.scope.targetId))
|
||||
.map(s => s.id);
|
||||
cleanupAcpSessions(removedSessionIds);
|
||||
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]);
|
||||
cleanupOrphanedAISessions(activeTargetIds);
|
||||
setSessionsRaw(latestAISessionsSnapshot ?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? []);
|
||||
setActiveSessionIdMapRaw(
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {},
|
||||
);
|
||||
}, []);
|
||||
|
||||
// ── Provider CRUD helpers ──
|
||||
const addProvider = useCallback((provider: ProviderConfig) => {
|
||||
@@ -602,6 +853,7 @@ export function useAIState() {
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
updateSessionExternalSessionId,
|
||||
retargetSessionScope,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
|
||||
@@ -16,11 +16,11 @@ import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { collectSyncableSettings } from '../../domain/syncPayload';
|
||||
import { collectSyncableSettings } from '../syncPayload';
|
||||
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';
|
||||
import { notify } from '../notification';
|
||||
|
||||
interface AutoSyncConfig {
|
||||
// Data to sync
|
||||
@@ -189,7 +189,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
throw error;
|
||||
}
|
||||
console.error('[AutoSync] Sync failed:', error);
|
||||
toast.error(
|
||||
notify.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('sync.autoSync.failedTitle'),
|
||||
);
|
||||
@@ -218,7 +218,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (!connectedProvider) return;
|
||||
|
||||
try {
|
||||
console.log('[AutoSync] Checking remote version...');
|
||||
// Load base BEFORE downloading (downloadFromProvider overwrites the base)
|
||||
const base = await manager.loadSyncBase(connectedProvider);
|
||||
const remotePayload = await sync.downloadFromProvider(connectedProvider);
|
||||
@@ -228,12 +227,11 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const localPayload = buildPayload();
|
||||
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
|
||||
|
||||
console.log('[AutoSync] Remote is newer, merged:', mergeResult.summary);
|
||||
config.onApplyPayload(mergeResult.payload);
|
||||
// Don't save base or skip auto-sync — let the data-change effect
|
||||
// naturally trigger an upload of the merged payload (which will
|
||||
// go through syncAllProviders and save base on success).
|
||||
toast.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
|
||||
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AutoSync] Failed to check remote version:', error);
|
||||
@@ -282,7 +280,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
// Debounce sync by 3 seconds
|
||||
syncTimeoutRef.current = setTimeout(() => {
|
||||
console.log('[AutoSync] Data changed, syncing...');
|
||||
syncNow();
|
||||
}, 3000);
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* Uses useSyncExternalStore for real-time state synchronization across all components.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
import {
|
||||
type CloudProvider,
|
||||
type SecurityState,
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
isProviderReadyForSync,
|
||||
} from '../../domain/sync';
|
||||
import {
|
||||
CloudSyncManager,
|
||||
getCloudSyncManager,
|
||||
type SyncManagerState,
|
||||
} from '../../infrastructure/services/CloudSyncManager';
|
||||
@@ -83,7 +82,8 @@ export interface CloudSyncHook {
|
||||
redirectUri: string
|
||||
) => Promise<void>;
|
||||
disconnectProvider: (provider: CloudProvider) => Promise<void>;
|
||||
|
||||
resetProviderStatus: (provider: CloudProvider) => void;
|
||||
|
||||
// Sync Actions
|
||||
syncNow: (payload: SyncPayload) => Promise<Map<CloudProvider, SyncResult>>;
|
||||
syncToProvider: (provider: CloudProvider, payload: SyncPayload) => Promise<SyncResult>;
|
||||
@@ -103,12 +103,6 @@ export interface CloudSyncHook {
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export interface GitHubAuthState {
|
||||
isAuthenticating: boolean;
|
||||
deviceFlowState: DeviceFlowState | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook Implementation
|
||||
// ============================================================================
|
||||
@@ -127,17 +121,6 @@ const getSnapshot = (): SyncManagerState => {
|
||||
};
|
||||
|
||||
export const useCloudSync = (): CloudSyncHook => {
|
||||
// Force update mechanism to ensure React re-renders
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
// Subscribe to state changes and force update
|
||||
useEffect(() => {
|
||||
const unsubscribe = manager.subscribeToStateChanges(() => {
|
||||
forceUpdate(n => n + 1);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Use useSyncExternalStore for real-time state sync across all components
|
||||
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
|
||||
@@ -273,7 +256,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
throw new Error('Unexpected auth type');
|
||||
}
|
||||
const data = result.data as { url: string; redirectUri: string };
|
||||
|
||||
|
||||
// Start OAuth callback server in Electron and wait for authorization
|
||||
const bridge = netcattyBridge.get();
|
||||
const startCallback = bridge?.startOAuthCallback;
|
||||
@@ -281,32 +264,48 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
// Get state from adapter for CSRF protection
|
||||
const adapter = manager.getAdapter('google') as { getPKCEState?: () => string | null } | undefined;
|
||||
const expectedState = adapter?.getPKCEState?.() || undefined;
|
||||
|
||||
|
||||
// Start callback server and open browser
|
||||
const callbackPromise = startCallback(expectedState);
|
||||
|
||||
// Open browser after starting server
|
||||
setTimeout(() => {
|
||||
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
|
||||
|
||||
// Open browser after starting server — omit noopener/noreferrer so we can track the popup
|
||||
let popup: Window | null = null;
|
||||
let popupPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const openTimer = setTimeout(() => {
|
||||
popup = window.open(data.url, "_blank", "width=600,height=700");
|
||||
// Poll for popup closure — if user closes it, cancel the OAuth flow
|
||||
if (popup) {
|
||||
popupPollTimer = setInterval(() => {
|
||||
if (popup?.closed) {
|
||||
if (popupPollTimer) clearInterval(popupPollTimer);
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Wait for callback
|
||||
const { code } = await callbackPromise;
|
||||
|
||||
// Complete auth with the received code
|
||||
await manager.completePKCEAuth('google', code, data.redirectUri);
|
||||
|
||||
try {
|
||||
// Wait for callback
|
||||
const { code } = await callbackPromise;
|
||||
|
||||
// Complete auth with the received code
|
||||
await manager.completePKCEAuth('google', code, data.redirectUri);
|
||||
} finally {
|
||||
clearTimeout(openTimer);
|
||||
if (popupPollTimer) clearInterval(popupPollTimer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return data.url;
|
||||
}, []);
|
||||
|
||||
|
||||
const connectOneDrive = useCallback(async (): Promise<string> => {
|
||||
const result = await manager.startProviderAuth('onedrive');
|
||||
if (result.type !== 'url') {
|
||||
throw new Error('Unexpected auth type');
|
||||
}
|
||||
const data = result.data as { url: string; redirectUri: string };
|
||||
|
||||
|
||||
// Start OAuth callback server in Electron and wait for authorization
|
||||
const bridge = netcattyBridge.get();
|
||||
const startCallback = bridge?.startOAuthCallback;
|
||||
@@ -314,22 +313,38 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
// Get state from adapter for CSRF protection
|
||||
const adapter = manager.getAdapter('onedrive') as { getPKCEState?: () => string | null } | undefined;
|
||||
const expectedState = adapter?.getPKCEState?.() || undefined;
|
||||
|
||||
|
||||
// Start callback server and open browser
|
||||
const callbackPromise = startCallback(expectedState);
|
||||
|
||||
// Open browser after starting server
|
||||
setTimeout(() => {
|
||||
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
|
||||
|
||||
// Open browser after starting server — omit noopener/noreferrer so we can track the popup
|
||||
let popup: Window | null = null;
|
||||
let popupPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const openTimer = setTimeout(() => {
|
||||
popup = window.open(data.url, "_blank", "width=600,height=700");
|
||||
// Poll for popup closure — if user closes it, cancel the OAuth flow
|
||||
if (popup) {
|
||||
popupPollTimer = setInterval(() => {
|
||||
if (popup?.closed) {
|
||||
if (popupPollTimer) clearInterval(popupPollTimer);
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Wait for callback
|
||||
const { code } = await callbackPromise;
|
||||
|
||||
// Complete auth with the received code
|
||||
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
|
||||
|
||||
try {
|
||||
// Wait for callback
|
||||
const { code } = await callbackPromise;
|
||||
|
||||
// Complete auth with the received code
|
||||
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
|
||||
} finally {
|
||||
clearTimeout(openTimer);
|
||||
if (popupPollTimer) clearInterval(popupPollTimer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return data.url;
|
||||
}, []);
|
||||
|
||||
@@ -345,6 +360,10 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
await manager.disconnectProvider(provider);
|
||||
}, []);
|
||||
|
||||
const resetProviderStatus = useCallback((provider: CloudProvider): void => {
|
||||
manager.resetProviderStatus(provider);
|
||||
}, []);
|
||||
|
||||
const connectWebDAV = useCallback(async (config: WebDAVConfig): Promise<void> => {
|
||||
await manager.connectConfigProvider('webdav', config);
|
||||
}, []);
|
||||
@@ -451,7 +470,8 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
connectS3,
|
||||
completePKCEAuth,
|
||||
disconnectProvider,
|
||||
|
||||
resetProviderStatus,
|
||||
|
||||
// Sync Actions
|
||||
syncNow: syncNowWithUnlock,
|
||||
syncToProvider: syncToProviderWithUnlock,
|
||||
@@ -472,60 +492,4 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Convenience Hooks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Hook for just the security state (lighter weight)
|
||||
*/
|
||||
export const useSecurityState = () => {
|
||||
const [manager] = useState<CloudSyncManager>(() => getCloudSyncManager());
|
||||
const [securityState, setSecurityState] = useState<SecurityState>(
|
||||
() => manager.getSecurityState()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = manager.subscribe((event) => {
|
||||
if (event.type === 'SECURITY_STATE_CHANGED') {
|
||||
setSecurityState(event.state);
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [manager]);
|
||||
|
||||
return {
|
||||
securityState,
|
||||
isUnlocked: securityState === 'UNLOCKED',
|
||||
isLocked: securityState === 'LOCKED',
|
||||
hasNoKey: securityState === 'NO_KEY',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for provider status indicators
|
||||
*/
|
||||
export const useProviderStatus = (provider: CloudProvider) => {
|
||||
const [manager] = useState<CloudSyncManager>(() => getCloudSyncManager());
|
||||
const [connection, setConnection] = useState<ProviderConnection>(
|
||||
() => manager.getProviderConnection(provider)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = manager.subscribe(() => {
|
||||
setConnection(manager.getProviderConnection(provider));
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [manager, provider]);
|
||||
|
||||
return {
|
||||
...connection,
|
||||
isConnected: isProviderReadyForSync(connection),
|
||||
isSyncing: connection.status === 'syncing',
|
||||
hasError: connection.status === 'error',
|
||||
dotColor: getSyncDotColor(connection.status),
|
||||
lastSyncFormatted: formatLastSync(connection.lastSync),
|
||||
};
|
||||
};
|
||||
|
||||
export default useCloudSync;
|
||||
|
||||
79
application/state/useFileUpload.ts
Normal file
79
application/state/useFileUpload.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* useFileUpload - Handle file paste/drop with base64 conversion
|
||||
*
|
||||
* Supports images, PDFs, and other document types.
|
||||
* Ported from 1code's use-agents-file-upload.ts
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
import { getPathForFile } from '../../lib/sftpFileUtils';
|
||||
|
||||
export interface UploadedFile {
|
||||
id: string;
|
||||
filename: string;
|
||||
dataUrl: string; // data:...;base64,... for preview
|
||||
base64Data: string; // raw base64 for API
|
||||
mediaType: string; // MIME type e.g. "image/png", "application/pdf"
|
||||
filePath?: string; // original filesystem path (Electron only)
|
||||
}
|
||||
|
||||
/** Reject only known binary blobs that AI models can't process */
|
||||
const REJECTED_MIME_PREFIXES = ['video/', 'audio/'];
|
||||
|
||||
function isSupportedFile(file: File): boolean {
|
||||
// Allow files with empty MIME (common in Electron for .sh, .yaml, etc.)
|
||||
if (!file.type) return true;
|
||||
return !REJECTED_MIME_PREFIXES.some(prefix => file.type.startsWith(prefix));
|
||||
}
|
||||
|
||||
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 useFileUpload() {
|
||||
const [files, setFiles] = useState<UploadedFile[]>([]);
|
||||
|
||||
const addFiles = useCallback(async (inputFiles: File[]) => {
|
||||
const supported = inputFiles.filter(isSupportedFile);
|
||||
if (supported.length === 0) return;
|
||||
|
||||
const newFiles: UploadedFile[] = await Promise.all(
|
||||
supported.map(async (file) => {
|
||||
const id = crypto.randomUUID();
|
||||
const filename = file.name || `file-${Date.now()}`;
|
||||
const mediaType = file.type || 'application/octet-stream';
|
||||
let dataUrl = '';
|
||||
let base64Data = '';
|
||||
try {
|
||||
const result = await fileToDataUrl(file);
|
||||
dataUrl = result.dataUrl;
|
||||
base64Data = result.base64;
|
||||
} catch (err) {
|
||||
console.error('[useFileUpload] Failed to convert:', err);
|
||||
}
|
||||
const filePath = getPathForFile(file);
|
||||
return { id, filename, dataUrl, base64Data, mediaType, filePath };
|
||||
}),
|
||||
);
|
||||
|
||||
setFiles((prev) => [...prev, ...newFiles]);
|
||||
}, []);
|
||||
|
||||
const removeFile = useCallback((id: string) => {
|
||||
setFiles((prev) => prev.filter((f) => f.id !== id));
|
||||
}, []);
|
||||
|
||||
const clearFiles = useCallback(() => {
|
||||
setFiles([]);
|
||||
}, []);
|
||||
|
||||
return { files, addFiles, removeFile, clearFiles };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { KeyBinding, matchesKeyBinding } from '../../domain/models';
|
||||
|
||||
export interface HotkeyActions {
|
||||
interface HotkeyActions {
|
||||
// Tab management
|
||||
switchToTab: (tabIndex: number) => void;
|
||||
nextTab: () => void;
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
214
application/state/useImmersiveMode.ts
Normal file
214
application/state/useImmersiveMode.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Immersive Mode — makes the entire UI chrome adapt colors to match the active terminal's theme.
|
||||
*
|
||||
* Performance strategy:
|
||||
* - All built-in themes' CSS strings are pre-computed at module load (zero cost at switch time)
|
||||
* - Custom/unknown themes are computed lazily and cached
|
||||
* - A single `<style>` tag with `!important` overrides inline CSS variables atomically
|
||||
* - `useLayoutEffect` ensures the update happens before browser paint (no flash)
|
||||
*/
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { TerminalTheme } from '../../domain/models';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hex → HSL conversion (returns "H S% L%" without the hsl() wrapper)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function hexToHsl(hex: string): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const l = (max + min) / 2;
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
||||
case g: h = ((b - r) / d + 2) / 6; break;
|
||||
case b: h = ((r - g) / d + 4) / 6; break;
|
||||
}
|
||||
}
|
||||
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
|
||||
}
|
||||
|
||||
function adjustLightness(hsl: string, delta: number): string {
|
||||
const parts = hsl.split(/\s+/);
|
||||
const newL = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
|
||||
return `${parts[0]} ${parts[1]} ${Math.round(newL * 10) / 10}%`;
|
||||
}
|
||||
|
||||
function adjustSaturation(hsl: string, factor: number): string {
|
||||
const parts = hsl.split(/\s+/);
|
||||
const newS = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
|
||||
return `${parts[0]} ${Math.round(newS * 10) / 10}% ${parts[2]}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build the CSS rule string from a TerminalTheme
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CSS_VARS = [
|
||||
'background', 'foreground', 'card', 'card-foreground',
|
||||
'popover', 'popover-foreground', 'primary', 'primary-foreground',
|
||||
'secondary', 'secondary-foreground', 'muted', 'muted-foreground',
|
||||
'accent', 'accent-foreground', 'destructive', 'destructive-foreground',
|
||||
'border', 'input', 'ring',
|
||||
] as const;
|
||||
|
||||
function buildImmersiveCss(theme: TerminalTheme): string {
|
||||
const bg = hexToHsl(theme.colors.background);
|
||||
const fg = hexToHsl(theme.colors.foreground);
|
||||
const cursor = hexToHsl(theme.colors.cursor);
|
||||
const isDark = theme.type === 'dark';
|
||||
|
||||
const card = adjustLightness(bg, isDark ? 4 : -3);
|
||||
const secondary = adjustLightness(bg, isDark ? 6 : -5);
|
||||
const muted = adjustLightness(bg, isDark ? 10 : -8);
|
||||
const mutedFg = adjustSaturation(adjustLightness(fg, isDark ? -20 : 20), 0.5);
|
||||
const border = adjustLightness(bg, isDark ? 12 : -10);
|
||||
const cursorL = parseFloat(cursor.split(' ')[2] ?? '50');
|
||||
const primaryFg = cursorL > 55 ? '0 0% 0%' : '0 0% 100%';
|
||||
|
||||
const values = [
|
||||
bg, fg, card, fg, // background, foreground, card, card-foreground
|
||||
card, fg, // popover, popover-foreground
|
||||
cursor, primaryFg, // primary, primary-foreground
|
||||
secondary, fg, // secondary, secondary-foreground
|
||||
muted, mutedFg, // muted, muted-foreground
|
||||
cursor, primaryFg, // accent, accent-foreground
|
||||
'0 70% 50%', '0 0% 100%', // destructive, destructive-foreground
|
||||
border, border, cursor, // border, input, ring
|
||||
];
|
||||
|
||||
const rules = CSS_VARS.map((name, i) => `--${name}: ${values[i]} !important`).join('; ');
|
||||
return `:root { ${rules}; }`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pre-compute CSS for all built-in themes at module load — O(1) lookup at switch time
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const cssCache = new Map<string, string>();
|
||||
|
||||
// Fingerprint: id + type + 3 key colors (detects in-place edits including dark↔light)
|
||||
function themeFingerprint(t: TerminalTheme): string {
|
||||
return `${t.id}\0${t.type}\0${t.colors.background}\0${t.colors.foreground}\0${t.colors.cursor}`;
|
||||
}
|
||||
|
||||
// Pre-compute built-in themes
|
||||
for (const theme of TERMINAL_THEMES) {
|
||||
cssCache.set(themeFingerprint(theme), buildImmersiveCss(theme));
|
||||
}
|
||||
|
||||
/** Get (or lazily compute & cache) the immersive CSS for a theme. */
|
||||
function getImmersiveCss(theme: TerminalTheme): string {
|
||||
const fp = themeFingerprint(theme);
|
||||
let css = cssCache.get(fp);
|
||||
if (!css) {
|
||||
css = buildImmersiveCss(theme);
|
||||
cssCache.set(fp, css);
|
||||
}
|
||||
return css;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Style tag management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STYLE_ID = 'netcatty-immersive-override';
|
||||
|
||||
function applyImmersiveStyle(css: string, isDark: boolean, bg: string) {
|
||||
const root = document.documentElement;
|
||||
const targetClass = isDark ? 'dark' : 'light';
|
||||
if (!root.classList.contains(targetClass)) {
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(targetClass);
|
||||
}
|
||||
let style = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = STYLE_ID;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = css;
|
||||
// Sync native Electron window chrome
|
||||
netcattyBridge.get()?.setTheme?.(isDark ? 'dark' : 'light');
|
||||
netcattyBridge.get()?.setBackgroundColor?.(bg);
|
||||
}
|
||||
|
||||
function removeImmersiveStyle() {
|
||||
document.getElementById(STYLE_ID)?.remove();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useImmersiveMode({
|
||||
isImmersive,
|
||||
activeTabId,
|
||||
activeTerminalTheme,
|
||||
restoreOriginalTheme,
|
||||
}: {
|
||||
isImmersive: boolean;
|
||||
activeTabId: string;
|
||||
activeTerminalTheme: TerminalTheme | null;
|
||||
restoreOriginalTheme: () => void;
|
||||
}) {
|
||||
const overrideActiveRef = useRef(false);
|
||||
const appliedFpRef = useRef<string | null>(null);
|
||||
const restoreRef = useRef(restoreOriginalTheme);
|
||||
restoreRef.current = restoreOriginalTheme;
|
||||
|
||||
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !activeTabId.startsWith('log-');
|
||||
|
||||
// APPLY: useLayoutEffect — runs before paint, O(1) Map lookup, single DOM write
|
||||
useLayoutEffect(() => {
|
||||
if (isImmersive && isTerminalTab && activeTerminalTheme) {
|
||||
const fp = themeFingerprint(activeTerminalTheme);
|
||||
if (appliedFpRef.current === fp) return;
|
||||
overrideActiveRef.current = true;
|
||||
appliedFpRef.current = fp;
|
||||
applyImmersiveStyle(getImmersiveCss(activeTerminalTheme), activeTerminalTheme.type === 'dark', activeTerminalTheme.colors.background);
|
||||
}
|
||||
}, [isImmersive, isTerminalTab, activeTerminalTheme]);
|
||||
|
||||
// RESTORE: useEffect — runs after paint, with fade overlay
|
||||
useEffect(() => {
|
||||
if (isImmersive && isTerminalTab && activeTerminalTheme) return;
|
||||
if (!overrideActiveRef.current) return;
|
||||
overrideActiveRef.current = false;
|
||||
appliedFpRef.current = null;
|
||||
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'immersive-fade-overlay';
|
||||
overlay.style.backgroundColor = `hsl(${bg})`;
|
||||
document.body.appendChild(overlay);
|
||||
removeImmersiveStyle();
|
||||
restoreOriginalTheme();
|
||||
requestAnimationFrame(() => {
|
||||
overlay.classList.add('fade-out');
|
||||
overlay.addEventListener('transitionend', () => overlay.remove(), { once: true });
|
||||
});
|
||||
const fallback = setTimeout(() => { if (overlay.parentNode) overlay.remove(); }, 400);
|
||||
return () => { clearTimeout(fallback); if (overlay.parentNode) overlay.remove(); };
|
||||
}, [isImmersive, isTerminalTab, activeTerminalTheme, restoreOriginalTheme]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
removeImmersiveStyle();
|
||||
appliedFpRef.current = null;
|
||||
if (overrideActiveRef.current) {
|
||||
overrideActiveRef.current = false;
|
||||
restoreRef.current();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -103,8 +103,6 @@ export const useManagedSourceSync = ({
|
||||
|
||||
const writeSshConfigToFile = useCallback(
|
||||
async (source: ManagedSource, managedHosts: Host[]) => {
|
||||
console.log(`[ManagedSourceSync] writeSshConfigToFile called for ${source.groupName}, hosts:`, managedHosts.length);
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.writeLocalFile) {
|
||||
console.warn("[ManagedSourceSync] writeLocalFile not available");
|
||||
@@ -121,14 +119,9 @@ export const useManagedSourceSync = ({
|
||||
managedHosts,
|
||||
hosts,
|
||||
);
|
||||
console.log(`[ManagedSourceSync] Final content (${finalContent.length} chars)`);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const buffer = encoder.encode(finalContent);
|
||||
console.log(`[ManagedSourceSync] Writing to ${source.filePath}`);
|
||||
|
||||
await bridge.writeLocalFile(source.filePath, buffer.buffer as ArrayBuffer);
|
||||
console.log(`[ManagedSourceSync] Write successful`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("[ManagedSourceSync] Failed to write SSH config:", err);
|
||||
@@ -159,12 +152,8 @@ export const useManagedSourceSync = ({
|
||||
// This should be called before deleting a managed group to avoid stale entries
|
||||
const clearAndRemoveSource = useCallback(
|
||||
async (source: ManagedSource) => {
|
||||
console.log(`[ManagedSourceSync] Clearing managed block for ${source.groupName}`);
|
||||
// Write empty hosts list to clear the managed block
|
||||
const success = await writeSshConfigToFile(source, []);
|
||||
if (success) {
|
||||
console.log(`[ManagedSourceSync] Managed block cleared, removing source`);
|
||||
}
|
||||
// Remove the source regardless of write success
|
||||
const updatedSources = managedSourcesRef.current.filter((s) => s.id !== source.id);
|
||||
onUpdateManagedSources(updatedSources);
|
||||
@@ -179,19 +168,14 @@ export const useManagedSourceSync = ({
|
||||
async (sources: ManagedSource[]) => {
|
||||
if (sources.length === 0) return;
|
||||
|
||||
console.log(`[ManagedSourceSync] Clearing ${sources.length} managed blocks`);
|
||||
|
||||
// Clear all files in parallel
|
||||
const results = await Promise.all(
|
||||
await Promise.all(
|
||||
sources.map(async (source) => {
|
||||
const success = await writeSshConfigToFile(source, []);
|
||||
return { sourceId: source.id, success };
|
||||
})
|
||||
);
|
||||
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
console.log(`[ManagedSourceSync] Cleared ${successCount}/${sources.length} managed blocks`);
|
||||
|
||||
// Remove all sources atomically in a single update
|
||||
const sourceIdsToRemove = new Set(sources.map(s => s.id));
|
||||
const updatedSources = managedSourcesRef.current.filter(
|
||||
@@ -273,8 +257,6 @@ export const useManagedSourceSync = ({
|
||||
const prevManaged = prevHostsBySource.get(source.id) || [];
|
||||
const currManaged = currHostsBySource.get(source.id) || [];
|
||||
|
||||
console.log(`[ManagedSourceSync] Source ${source.groupName}: prev=${prevManaged.length}, curr=${currManaged.length}`);
|
||||
|
||||
if (prevManaged.length !== currManaged.length) {
|
||||
changedSourceIds.add(source.id);
|
||||
continue;
|
||||
@@ -328,7 +310,6 @@ export const useManagedSourceSync = ({
|
||||
}
|
||||
|
||||
if (changedSourceIds.size > 0) {
|
||||
console.log(`[ManagedSourceSync] Syncing sources:`, Array.from(changedSourceIds));
|
||||
syncInProgressRef.current = true;
|
||||
|
||||
Promise.all(
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* This should be used at the App level to ensure auto-start happens
|
||||
* when the application starts, not when the user navigates to the port forwarding page.
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Host, PortForwardingRule } from "../../domain/models";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
@@ -17,7 +17,8 @@ import { logger } from "../../lib/logger";
|
||||
|
||||
export interface UsePortForwardingAutoStartOptions {
|
||||
hosts: Host[];
|
||||
keys: { id: string; privateKey: string; passphrase: string }[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,10 +28,37 @@ export interface UsePortForwardingAutoStartOptions {
|
||||
export const usePortForwardingAutoStart = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
}: UsePortForwardingAutoStartOptions): void => {
|
||||
const autoStartExecutedRef = useRef(false);
|
||||
const hostsRef = useRef<Host[]>(hosts);
|
||||
const keysRef = useRef<{ id: string; privateKey: string; passphrase: string }[]>(keys);
|
||||
const keysRef = useRef<SSHKey[]>(keys);
|
||||
const identitiesRef = useRef<Identity[]>(identities);
|
||||
|
||||
const isHostAuthReady = useCallback((host: Host, seen = new Set<string>()): boolean => {
|
||||
if (!host || seen.has(host.id)) return true;
|
||||
seen.add(host.id);
|
||||
|
||||
if (host.identityId) {
|
||||
const identity = identitiesRef.current.find((candidate) => candidate.id === host.identityId);
|
||||
if (!identity) return false;
|
||||
if (identity.keyId && !keysRef.current.some((key) => key.id === identity.keyId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (host.identityFileId && !keysRef.current.some((key) => key.id === host.identityFileId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const chainIds = host.hostChain?.hostIds || [];
|
||||
for (const chainId of chainIds) {
|
||||
const chainHost = hostsRef.current.find((candidate) => candidate.id === chainId);
|
||||
if (!chainHost) return false;
|
||||
if (!isHostAuthReady(chainHost, seen)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
// Keep refs in sync
|
||||
useEffect(() => {
|
||||
@@ -41,6 +69,10 @@ export const usePortForwardingAutoStart = ({
|
||||
keysRef.current = keys;
|
||||
}, [keys]);
|
||||
|
||||
useEffect(() => {
|
||||
identitiesRef.current = identities;
|
||||
}, [identities]);
|
||||
|
||||
// Set up the reconnect callback
|
||||
useEffect(() => {
|
||||
const handleReconnect = async (
|
||||
@@ -62,7 +94,7 @@ export const usePortForwardingAutoStart = ({
|
||||
return { success: false, error: "Host not found" };
|
||||
}
|
||||
|
||||
return startPortForward(rule, host, keysRef.current, onStatusChange, true);
|
||||
return startPortForward(rule, host, hostsRef.current, keysRef.current, identitiesRef.current, onStatusChange, true);
|
||||
};
|
||||
|
||||
setReconnectCallback(handleReconnect);
|
||||
@@ -76,6 +108,17 @@ export const usePortForwardingAutoStart = ({
|
||||
if (autoStartExecutedRef.current) return;
|
||||
if (hosts.length === 0) return;
|
||||
|
||||
const storedRules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
) ?? [];
|
||||
const pendingAutoStartRules = storedRules.filter((rule) => rule.autoStart && rule.hostId);
|
||||
if (pendingAutoStartRules.some((rule) => {
|
||||
const host = hosts.find((candidate) => candidate.id === rule.hostId);
|
||||
return !host || !isHostAuthReady(host);
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as executed immediately to prevent duplicate runs
|
||||
// (React StrictMode or dependency changes could cause re-runs)
|
||||
autoStartExecutedRef.current = true;
|
||||
@@ -108,7 +151,9 @@ export const usePortForwardingAutoStart = ({
|
||||
void startPortForward(
|
||||
rule,
|
||||
host,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
(status, error) => {
|
||||
// Update the rule status in storage
|
||||
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
@@ -135,5 +180,5 @@ export const usePortForwardingAutoStart = ({
|
||||
};
|
||||
|
||||
void runAutoStart();
|
||||
}, [hosts, keys]);
|
||||
}, [hosts, identities, isHostAuthReady, keys]);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Host, PortForwardingRule } from "../../domain/models";
|
||||
import { Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
|
||||
import {
|
||||
STORAGE_KEY_PF_PREFER_FORM_MODE,
|
||||
STORAGE_KEY_PF_VIEW_MODE,
|
||||
@@ -63,7 +63,9 @@ export interface UsePortForwardingStateResult {
|
||||
startTunnel: (
|
||||
rule: PortForwardingRule,
|
||||
host: Host,
|
||||
keys: { id: string; privateKey: string; passphrase: string }[],
|
||||
hosts: Host[],
|
||||
keys: SSHKey[],
|
||||
identities: Identity[],
|
||||
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
|
||||
enableReconnect?: boolean,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
@@ -377,14 +379,16 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
async (
|
||||
rule: PortForwardingRule,
|
||||
host: Host,
|
||||
keys: { id: string; privateKey: string; passphrase: string }[],
|
||||
hosts: Host[],
|
||||
keys: SSHKey[],
|
||||
identities: Identity[],
|
||||
onStatusChange?: (
|
||||
status: PortForwardingRule["status"],
|
||||
error?: string,
|
||||
) => void,
|
||||
enableReconnect = false,
|
||||
) => {
|
||||
return startPortForward(rule, host, keys, (status, error) => {
|
||||
return startPortForward(rule, host, hosts, keys, identities, (status, error) => {
|
||||
setRuleStatus(rule.id, status, error);
|
||||
onStatusChange?.(status, error ?? undefined);
|
||||
}, enableReconnect);
|
||||
|
||||
@@ -38,7 +38,9 @@ export const useSessionState = () => {
|
||||
// Log views: stores open log replay tabs
|
||||
const [logViews, setLogViews] = useState<LogView[]>([]);
|
||||
|
||||
const createLocalTerminal = useCallback(() => {
|
||||
const createLocalTerminal = useCallback((options?: {
|
||||
shellType?: TerminalSession['shellType'];
|
||||
}) => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
const localHostId = `local-${sessionId}`;
|
||||
const newSession: TerminalSession = {
|
||||
@@ -48,13 +50,15 @@ export const useSessionState = () => {
|
||||
hostname: 'localhost',
|
||||
username: 'local',
|
||||
status: 'connecting',
|
||||
protocol: 'local',
|
||||
shellType: options?.shellType,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
return sessionId;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const createSerialSession = useCallback((config: SerialConfig) => {
|
||||
const createSerialSession = useCallback((config: SerialConfig, options?: { charset?: string }) => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
const serialHostId = `serial-${sessionId}`;
|
||||
const portName = config.path.split('/').pop() || config.path;
|
||||
@@ -67,6 +71,7 @@ export const useSessionState = () => {
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig: config,
|
||||
charset: options?.charset,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
@@ -99,6 +104,7 @@ export const useSessionState = () => {
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig: serialConfig,
|
||||
charset: host.charset,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
@@ -116,6 +122,7 @@ export const useSessionState = () => {
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
charset: host.charset,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(newSession.id);
|
||||
@@ -317,6 +324,7 @@ export const useSessionState = () => {
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig: serialConfig,
|
||||
charset: host.charset,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -330,6 +338,7 @@ export const useSessionState = () => {
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
charset: host.charset,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -414,11 +423,17 @@ export const useSessionState = () => {
|
||||
// direction: 'horizontal' = split top/bottom, 'vertical' = split left/right
|
||||
const splitSession = useCallback((
|
||||
sessionId: string,
|
||||
direction: SplitDirection
|
||||
direction: SplitDirection,
|
||||
options?: {
|
||||
localShellType?: TerminalSession['shellType'];
|
||||
},
|
||||
) => {
|
||||
setSessions(prevSessions => {
|
||||
const session = prevSessions.find(s => s.id === sessionId);
|
||||
if (!session) return prevSessions;
|
||||
const nextShellType = session.protocol === 'local'
|
||||
? options?.localShellType
|
||||
: session.shellType;
|
||||
|
||||
// If session is already in a workspace, split within that workspace
|
||||
if (session.workspaceId) {
|
||||
@@ -434,8 +449,10 @@ export const useSessionState = () => {
|
||||
protocol: session.protocol,
|
||||
port: session.port,
|
||||
moshEnabled: session.moshEnabled,
|
||||
shellType: nextShellType,
|
||||
charset: session.charset,
|
||||
};
|
||||
|
||||
|
||||
// Add pane to existing workspace
|
||||
const hint: SplitHint = {
|
||||
direction,
|
||||
@@ -464,13 +481,15 @@ export const useSessionState = () => {
|
||||
protocol: session.protocol,
|
||||
port: session.port,
|
||||
moshEnabled: session.moshEnabled,
|
||||
shellType: nextShellType,
|
||||
charset: session.charset,
|
||||
};
|
||||
|
||||
|
||||
const hint: SplitHint = {
|
||||
direction,
|
||||
position: direction === 'horizontal' ? 'bottom' : 'right',
|
||||
};
|
||||
|
||||
|
||||
const newWorkspace = createWorkspaceEntity(sessionId, newSession.id, hint);
|
||||
setWorkspaces(prev => [...prev, newWorkspace]);
|
||||
setActiveTabId(newWorkspace.id);
|
||||
@@ -551,6 +570,7 @@ export const useSessionState = () => {
|
||||
hostname: host.hostname,
|
||||
username: host.username,
|
||||
status: 'connecting' as const,
|
||||
charset: host.charset,
|
||||
// workspaceId will be set after workspace is created
|
||||
}));
|
||||
|
||||
@@ -615,10 +635,15 @@ export const useSessionState = () => {
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Copy a session - creates a new session with the same host connection
|
||||
const copySession = useCallback((sessionId: string) => {
|
||||
const copySession = useCallback((sessionId: string, options?: {
|
||||
localShellType?: TerminalSession['shellType'];
|
||||
}) => {
|
||||
setSessions(prevSessions => {
|
||||
const session = prevSessions.find(s => s.id === sessionId);
|
||||
if (!session) return prevSessions;
|
||||
const nextShellType = session.protocol === 'local'
|
||||
? options?.localShellType
|
||||
: session.shellType;
|
||||
|
||||
// Create a new session with the same connection info
|
||||
const newSession: TerminalSession = {
|
||||
@@ -631,6 +656,8 @@ export const useSessionState = () => {
|
||||
protocol: session.protocol,
|
||||
port: session.port,
|
||||
moshEnabled: session.moshEnabled,
|
||||
shellType: nextShellType,
|
||||
charset: session.charset,
|
||||
serialConfig: session.serialConfig,
|
||||
};
|
||||
|
||||
@@ -664,9 +691,11 @@ export const useSessionState = () => {
|
||||
...workspaces.map(w => w.id),
|
||||
...logViews.map(lv => lv.id),
|
||||
];
|
||||
const allTabIdSet = new Set(allTabIds);
|
||||
// Filter tabOrder to only include existing tabs, then add any new tabs at the end
|
||||
const orderedIds = tabOrder.filter(id => allTabIds.includes(id));
|
||||
const newIds = allTabIds.filter(id => !orderedIds.includes(id));
|
||||
const orderedIds = tabOrder.filter(id => allTabIdSet.has(id));
|
||||
const orderedIdSet = new Set(orderedIds);
|
||||
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
|
||||
return [...orderedIds, ...newIds];
|
||||
}, [orphanSessions, workspaces, logViews, tabOrder]);
|
||||
|
||||
@@ -680,10 +709,12 @@ export const useSessionState = () => {
|
||||
...workspaces.map(w => w.id),
|
||||
...logViews.map(lv => lv.id),
|
||||
];
|
||||
const allTabIdSet = new Set(allTabIds);
|
||||
|
||||
// Build current effective order: existing order + new tabs at end
|
||||
const orderedIds = prevTabOrder.filter(id => allTabIds.includes(id));
|
||||
const newIds = allTabIds.filter(id => !orderedIds.includes(id));
|
||||
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
|
||||
const orderedIdSet = new Set(orderedIds);
|
||||
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
|
||||
const currentOrder = [...orderedIds, ...newIds];
|
||||
|
||||
const draggedIndex = currentOrder.indexOf(draggedId);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_EDITOR_WORD_WRAP,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_IMMERSIVE_MODE,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
@@ -37,7 +39,6 @@ import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { UI_FONTS, DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
|
||||
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
|
||||
import { useAvailableFonts } from './fontStore';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
@@ -63,6 +64,7 @@ const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
|
||||
const DEFAULT_SFTP_AUTO_SYNC = false;
|
||||
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
|
||||
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
|
||||
const DEFAULT_SFTP_AUTO_OPEN_SIDEBAR = false;
|
||||
|
||||
// Editor defaults
|
||||
const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
@@ -119,8 +121,11 @@ const applyThemeTokens = (
|
||||
accentOverride: string,
|
||||
) => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(resolvedTheme);
|
||||
// If immersive mode is active (style tag present), it owns the dark/light class — don't override
|
||||
if (!document.getElementById('netcatty-immersive-override')) {
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(resolvedTheme);
|
||||
}
|
||||
root.style.setProperty('--background', tokens.background);
|
||||
root.style.setProperty('--foreground', tokens.foreground);
|
||||
root.style.setProperty('--card', tokens.card);
|
||||
@@ -153,7 +158,6 @@ const applyThemeTokens = (
|
||||
};
|
||||
|
||||
export const useSettingsState = () => {
|
||||
const availableFonts = useAvailableFonts();
|
||||
const uiFontsLoaded = useUIFontsLoaded();
|
||||
const [theme, setTheme] = useState<'dark' | 'light' | 'system'>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_THEME);
|
||||
@@ -231,6 +235,10 @@ export const useSettingsState = () => {
|
||||
if (stored === 'false' || stored === 'disabled') return false;
|
||||
return DEFAULT_SFTP_USE_COMPRESSED_UPLOAD;
|
||||
});
|
||||
const [sftpAutoOpenSidebar, setSftpAutoOpenSidebar] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_OPEN_SIDEBAR;
|
||||
});
|
||||
|
||||
// Editor Settings
|
||||
const [editorWordWrap, setEditorWordWrapState] = useState<boolean>(() => {
|
||||
@@ -281,6 +289,10 @@ export const useSettingsState = () => {
|
||||
const localTerminalSettingsVersionRef = useRef(0);
|
||||
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
|
||||
|
||||
// Fix 1: Mount guard — skip redundant IPC broadcasts & localStorage writes on initial mount.
|
||||
// Set to true by the LAST useEffect declaration; all persist effects see false on first render.
|
||||
const persistMountedRef = useRef(false);
|
||||
|
||||
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
|
||||
setTerminalSettingsState((prev) => {
|
||||
const candidate = typeof nextValue === 'function'
|
||||
@@ -316,6 +328,21 @@ export const useSettingsState = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [immersiveMode, setImmersiveModeState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_IMMERSIVE_MODE);
|
||||
if (stored === null || stored === '') {
|
||||
// Persist default so collectSyncableSettings() can include it
|
||||
localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, 'true');
|
||||
return true;
|
||||
}
|
||||
return stored === 'true';
|
||||
});
|
||||
const setImmersiveMode = useCallback((enabled: boolean) => {
|
||||
setImmersiveModeState(enabled);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, String(enabled));
|
||||
notifySettingsChanged(STORAGE_KEY_IMMERSIVE_MODE, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const syncAppearanceFromStorage = useCallback(() => {
|
||||
const storedTheme = readStoredString(STORAGE_KEY_THEME);
|
||||
const nextTheme = storedTheme && isValidTheme(storedTheme) ? storedTheme : theme;
|
||||
@@ -328,6 +355,17 @@ export const useSettingsState = () => {
|
||||
const storedAccent = readStoredString(STORAGE_KEY_COLOR);
|
||||
const nextAccent = storedAccent && isValidHslToken(storedAccent) ? storedAccent.trim() : customAccent;
|
||||
|
||||
// Fix 2: Skip expensive DOM operations if nothing actually changed
|
||||
if (
|
||||
nextTheme === theme &&
|
||||
nextLightId === lightUiThemeId &&
|
||||
nextDarkId === darkUiThemeId &&
|
||||
nextAccentMode === accentMode &&
|
||||
nextAccent === customAccent
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTheme(nextTheme);
|
||||
setLightUiThemeId(nextLightId);
|
||||
setDarkUiThemeId(nextDarkId);
|
||||
@@ -393,10 +431,20 @@ export const useSettingsState = () => {
|
||||
if (storedHidden === 'true' || storedHidden === 'false') setSftpShowHiddenFiles(storedHidden === 'true');
|
||||
const storedCompress = readStoredString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
|
||||
if (storedCompress === 'true' || storedCompress === 'false') setSftpUseCompressedUpload(storedCompress === 'true');
|
||||
const storedAutoOpenSidebar = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
|
||||
|
||||
// Immersive mode
|
||||
const storedImmersive = readStoredString(STORAGE_KEY_IMMERSIVE_MODE);
|
||||
if (storedImmersive === 'true' || storedImmersive === 'false') {
|
||||
const val = storedImmersive === 'true';
|
||||
setImmersiveModeState(val);
|
||||
notifySettingsChanged(STORAGE_KEY_IMMERSIVE_MODE, val);
|
||||
}
|
||||
|
||||
// Custom terminal themes
|
||||
customThemeStore.loadFromStorage();
|
||||
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
|
||||
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings, notifySettingsChanged]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
@@ -406,12 +454,11 @@ export const useSettingsState = () => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, accentMode);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_COLOR, customAccent);
|
||||
// Notify other windows
|
||||
// Fix 1: Skip IPC broadcast on initial mount (values already match localStorage)
|
||||
if (!persistMountedRef.current) return;
|
||||
// Fix 3: Send a single IPC instead of 5 — the receiver calls syncAppearanceFromStorage()
|
||||
// which re-reads ALL appearance values from localStorage.
|
||||
notifySettingsChanged(STORAGE_KEY_THEME, theme);
|
||||
notifySettingsChanged(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
|
||||
notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
|
||||
notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode);
|
||||
notifySettingsChanged(STORAGE_KEY_COLOR, customAccent);
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, notifySettingsChanged]);
|
||||
|
||||
// Listen for OS color scheme changes to keep systemPreference in sync
|
||||
@@ -429,7 +476,10 @@ export const useSettingsState = () => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
|
||||
document.documentElement.lang = uiLanguage;
|
||||
netcattyBridge.get()?.setLanguage?.(uiLanguage);
|
||||
notifySettingsChanged(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
|
||||
// Fix 1: Skip IPC broadcast on initial mount
|
||||
if (persistMountedRef.current) {
|
||||
notifySettingsChanged(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
|
||||
}
|
||||
}, [uiLanguage, notifySettingsChanged]);
|
||||
|
||||
// Apply and persist UI font family
|
||||
@@ -438,7 +488,10 @@ export const useSettingsState = () => {
|
||||
const font = uiFontStore.getFontById(uiFontFamilyId);
|
||||
document.documentElement.style.setProperty('--font-sans', font.family);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
|
||||
notifySettingsChanged(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
|
||||
// Fix 1: Skip IPC broadcast on initial mount
|
||||
if (persistMountedRef.current) {
|
||||
notifySettingsChanged(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
|
||||
}
|
||||
}, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]);
|
||||
|
||||
// Listen for settings changes from other windows via IPC
|
||||
@@ -529,6 +582,12 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
|
||||
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
|
||||
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_IMMERSIVE_MODE && typeof value === 'boolean') {
|
||||
setImmersiveModeState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
try {
|
||||
@@ -556,53 +615,76 @@ export const useSettingsState = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fix 4: Keep a ref snapshot of current settings so the storage event handler
|
||||
// can compare without capturing 25+ state variables in its closure / dep array.
|
||||
// This avoids constant listener detach/reattach on every state change.
|
||||
const settingsSnapshotRef = useRef({
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, immersiveMode,
|
||||
});
|
||||
settingsSnapshotRef.current = {
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, immersiveMode,
|
||||
};
|
||||
|
||||
// Listen for storage changes from other windows (cross-window sync)
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
const s = settingsSnapshotRef.current;
|
||||
if (e.key === STORAGE_KEY_THEME && e.newValue) {
|
||||
if (isValidTheme(e.newValue) && e.newValue !== theme) {
|
||||
if (isValidTheme(e.newValue) && e.newValue !== s.theme) {
|
||||
setTheme(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_THEME_LIGHT && e.newValue) {
|
||||
if (isValidUiThemeId('light', e.newValue) && e.newValue !== lightUiThemeId) {
|
||||
if (isValidUiThemeId('light', e.newValue) && e.newValue !== s.lightUiThemeId) {
|
||||
setLightUiThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_THEME_DARK && e.newValue) {
|
||||
if (isValidUiThemeId('dark', e.newValue) && e.newValue !== darkUiThemeId) {
|
||||
if (isValidUiThemeId('dark', e.newValue) && e.newValue !== s.darkUiThemeId) {
|
||||
setDarkUiThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_ACCENT_MODE && e.newValue) {
|
||||
if ((e.newValue === 'theme' || e.newValue === 'custom') && e.newValue !== accentMode) {
|
||||
if ((e.newValue === 'theme' || e.newValue === 'custom') && e.newValue !== s.accentMode) {
|
||||
setAccentMode(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_COLOR && e.newValue) {
|
||||
if (isValidHslToken(e.newValue) && e.newValue !== customAccent) {
|
||||
if (isValidHslToken(e.newValue) && e.newValue !== s.customAccent) {
|
||||
setCustomAccent(e.newValue.trim());
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_CUSTOM_CSS && e.newValue !== null) {
|
||||
if (e.newValue !== customCSS) {
|
||||
if (e.newValue !== s.customCSS) {
|
||||
setCustomCSS(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_FONT_FAMILY && e.newValue) {
|
||||
if (isValidUiFontId(e.newValue) && e.newValue !== uiFontFamilyId) {
|
||||
if (isValidUiFontId(e.newValue) && e.newValue !== s.uiFontFamilyId) {
|
||||
setUiFontFamilyId(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_HOTKEY_SCHEME && e.newValue) {
|
||||
const newScheme = e.newValue as HotkeyScheme;
|
||||
if (newScheme !== hotkeyScheme) {
|
||||
if (newScheme !== s.hotkeyScheme) {
|
||||
setHotkeyScheme(newScheme);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_LANGUAGE && e.newValue) {
|
||||
const next = resolveSupportedLocale(e.newValue);
|
||||
if (next !== uiLanguage) {
|
||||
if (next !== s.uiLanguage) {
|
||||
setUiLanguage(next as UILanguage);
|
||||
}
|
||||
}
|
||||
@@ -625,64 +707,64 @@ export const useSettingsState = () => {
|
||||
}
|
||||
// Sync terminal theme from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_THEME && e.newValue) {
|
||||
if (e.newValue !== terminalThemeId) {
|
||||
if (e.newValue !== s.terminalThemeId) {
|
||||
setTerminalThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync terminal font family from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_FONT_FAMILY && e.newValue) {
|
||||
if (e.newValue !== terminalFontFamilyId) {
|
||||
if (e.newValue !== s.terminalFontFamilyId) {
|
||||
setTerminalFontFamilyId(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync terminal font size from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_FONT_SIZE && e.newValue) {
|
||||
const newSize = parseInt(e.newValue, 10);
|
||||
if (!isNaN(newSize) && newSize !== terminalFontSize) {
|
||||
if (!isNaN(newSize) && newSize !== s.terminalFontSize) {
|
||||
setTerminalFontSize(newSize);
|
||||
}
|
||||
}
|
||||
// Sync SFTP double-click behavior from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR && e.newValue) {
|
||||
if ((e.newValue === 'open' || e.newValue === 'transfer') && e.newValue !== sftpDoubleClickBehavior) {
|
||||
if ((e.newValue === 'open' || e.newValue === 'transfer') && e.newValue !== s.sftpDoubleClickBehavior) {
|
||||
setSftpDoubleClickBehavior(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP auto-sync setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_AUTO_SYNC && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sftpAutoSync) {
|
||||
if (newValue !== s.sftpAutoSync) {
|
||||
setSftpAutoSync(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP show hidden files setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sftpShowHiddenFiles) {
|
||||
if (newValue !== s.sftpShowHiddenFiles) {
|
||||
setSftpShowHiddenFiles(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_EDITOR_WORD_WRAP && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== editorWordWrap) {
|
||||
if (newValue !== s.editorWordWrap) {
|
||||
setEditorWordWrapState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sessionLogsEnabled) {
|
||||
if (newValue !== s.sessionLogsEnabled) {
|
||||
setSessionLogsEnabled(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_DIR && e.newValue !== null) {
|
||||
if (e.newValue !== sessionLogsDir) {
|
||||
if (e.newValue !== s.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
|
||||
e.newValue !== s.sessionLogsFormat
|
||||
) {
|
||||
setSessionLogsFormat(e.newValue);
|
||||
}
|
||||
@@ -690,47 +772,65 @@ export const useSettingsState = () => {
|
||||
// Sync SFTP compressed upload setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
|
||||
if (newValue !== sftpUseCompressedUpload) {
|
||||
if (newValue !== s.sftpUseCompressedUpload) {
|
||||
setSftpUseCompressedUpload(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP auto-open sidebar setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.sftpAutoOpenSidebar) {
|
||||
setSftpAutoOpenSidebar(newValue);
|
||||
}
|
||||
}
|
||||
// Sync global hotkey enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== globalHotkeyEnabled) {
|
||||
if (newValue !== s.globalHotkeyEnabled) {
|
||||
setGlobalHotkeyEnabled(newValue);
|
||||
}
|
||||
}
|
||||
// Sync auto-update enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_AUTO_UPDATE_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== autoUpdateEnabled) {
|
||||
if (newValue !== s.autoUpdateEnabled) {
|
||||
setAutoUpdateEnabled(newValue);
|
||||
}
|
||||
}
|
||||
// Sync immersive mode from other windows
|
||||
if (e.key === STORAGE_KEY_IMMERSIVE_MODE && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.immersiveMode) {
|
||||
setImmersiveModeState(newValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, globalHotkeyEnabled, autoUpdateEnabled, mergeIncomingTerminalSettings]);
|
||||
}, [mergeIncomingTerminalSettings]); // Fix 4: stable deps only — state comparisons use settingsSnapshotRef
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
}, [terminalThemeId, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
|
||||
}, [terminalFontFamilyId, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_TERM_FONT_SIZE, terminalFontSize);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_FONT_SIZE, terminalFontSize);
|
||||
}, [terminalFontSize, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
|
||||
if (!persistMountedRef.current) return;
|
||||
const currentSignature = serializeTerminalSettings(terminalSettings);
|
||||
const hasPendingUnbroadcastLocalChanges =
|
||||
localTerminalSettingsVersionRef.current !== broadcastedLocalTerminalSettingsVersionRef.current;
|
||||
@@ -745,11 +845,13 @@ export const useSettingsState = () => {
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
|
||||
}, [hotkeyScheme, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.write(STORAGE_KEY_CUSTOM_KEY_BINDINGS, customKeyBindings);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_CUSTOM_KEY_BINDINGS, customKeyBindings);
|
||||
}, [customKeyBindings, notifySettingsChanged]);
|
||||
|
||||
@@ -760,10 +862,7 @@ export const useSettingsState = () => {
|
||||
|
||||
// Apply and persist custom CSS
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
|
||||
notifySettingsChanged(STORAGE_KEY_CUSTOM_CSS, customCSS);
|
||||
|
||||
// Apply custom CSS to document
|
||||
// Always apply CSS to document (needed on mount)
|
||||
let styleEl = document.getElementById('netcatty-custom-css') as HTMLStyleElement | null;
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
@@ -771,53 +870,69 @@ export const useSettingsState = () => {
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleEl.textContent = customCSS;
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_CUSTOM_CSS, customCSS);
|
||||
}, [customCSS, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP double-click behavior
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
|
||||
}, [sftpDoubleClickBehavior, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP auto-sync setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync);
|
||||
}, [sftpAutoSync, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP show hidden files setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles);
|
||||
}, [sftpShowHiddenFiles, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP compressed upload setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload);
|
||||
}, [sftpUseCompressedUpload, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP auto-open sidebar setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar);
|
||||
}, [sftpAutoOpenSidebar, notifySettingsChanged]);
|
||||
|
||||
// Persist Session Logs settings
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled);
|
||||
}, [sessionLogsEnabled, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
|
||||
}, [sessionLogsDir, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
|
||||
if (!persistMountedRef.current) return;
|
||||
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
|
||||
// Register/unregister the global hotkey in main process (needed on mount)
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.registerGlobalHotkey) {
|
||||
if (toggleWindowHotkey && globalHotkeyEnabled) {
|
||||
@@ -841,25 +956,32 @@ export const useSettingsState = () => {
|
||||
});
|
||||
}
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
}, [toggleWindowHotkey, globalHotkeyEnabled, notifySettingsChanged]);
|
||||
|
||||
// Persist global hotkey enabled setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled);
|
||||
}, [globalHotkeyEnabled, 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
|
||||
// Update main process tray behavior (needed on mount)
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.setCloseToTray) {
|
||||
bridge.setCloseToTray(closeToTray).catch((err) => {
|
||||
console.warn('[SystemTray] Failed to set close-to-tray:', err);
|
||||
});
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
}, [closeToTray, notifySettingsChanged]);
|
||||
|
||||
// Hydrate auto-update state from the main-process preference file on mount.
|
||||
@@ -880,16 +1002,11 @@ export const useSettingsState = () => {
|
||||
}, []);
|
||||
|
||||
// Persist auto-update enabled setting.
|
||||
// Skip IPC on initial mount to avoid overwriting the main-process preference
|
||||
// file when localStorage has been cleared (where the default is true).
|
||||
const autoUpdateMountedRef = useRef(false);
|
||||
// Initial mount still writes localStorage, but skips cross-window/main-process IPC.
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled);
|
||||
if (!autoUpdateMountedRef.current) {
|
||||
autoUpdateMountedRef.current = true;
|
||||
return; // Skip IPC on initial mount
|
||||
}
|
||||
// Notify main process on user-initiated changes
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.setAutoUpdate?.(autoUpdateEnabled).catch((err: unknown) => {
|
||||
@@ -897,6 +1014,13 @@ export const useSettingsState = () => {
|
||||
});
|
||||
}, [autoUpdateEnabled, notifySettingsChanged]);
|
||||
|
||||
// Fix 1: Mark all persist effects as mounted.
|
||||
// This MUST be declared AFTER all persist useEffects so that React runs it last
|
||||
// during the initial mount cycle (effects fire in declaration order).
|
||||
useEffect(() => {
|
||||
persistMountedRef.current = true;
|
||||
}, []);
|
||||
|
||||
// Get merged key bindings (defaults + custom overrides)
|
||||
const keyBindings = useMemo((): KeyBinding[] => {
|
||||
return DEFAULT_KEY_BINDINGS.map(binding => {
|
||||
@@ -959,11 +1083,6 @@ export const useSettingsState = () => {
|
||||
[terminalThemeId, customThemes]
|
||||
);
|
||||
|
||||
const currentTerminalFont = useMemo(
|
||||
() => availableFonts.find(f => f.id === terminalFontFamilyId) || availableFonts[0],
|
||||
[terminalFontFamilyId, availableFonts]
|
||||
);
|
||||
|
||||
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
|
||||
key: K,
|
||||
value: TerminalSettings[K]
|
||||
@@ -971,6 +1090,12 @@ export const useSettingsState = () => {
|
||||
setTerminalSettings(prev => ({ ...prev, [key]: value }));
|
||||
}, [setTerminalSettings]);
|
||||
|
||||
/** Re-apply the current UI theme tokens (used to restore after immersive mode override). */
|
||||
const reapplyCurrentTheme = useCallback(() => {
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
|
||||
|
||||
return {
|
||||
theme,
|
||||
setTheme,
|
||||
@@ -994,7 +1119,6 @@ export const useSettingsState = () => {
|
||||
currentTerminalTheme,
|
||||
terminalFontFamilyId,
|
||||
setTerminalFontFamilyId,
|
||||
currentTerminalFont,
|
||||
terminalFontSize,
|
||||
setTerminalFontSize,
|
||||
terminalSettings,
|
||||
@@ -1019,6 +1143,8 @@ export const useSettingsState = () => {
|
||||
setSftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
setSftpUseCompressedUpload,
|
||||
sftpAutoOpenSidebar,
|
||||
setSftpAutoOpenSidebar,
|
||||
// Editor Settings
|
||||
editorWordWrap,
|
||||
setEditorWordWrap: useCallback((enabled: boolean) => {
|
||||
@@ -1026,7 +1152,6 @@ export const useSettingsState = () => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(enabled));
|
||||
notifySettingsChanged(STORAGE_KEY_EDITOR_WORD_WRAP, enabled);
|
||||
}, [notifySettingsChanged]),
|
||||
availableFonts,
|
||||
// Session Logs
|
||||
sessionLogsEnabled,
|
||||
setSessionLogsEnabled,
|
||||
@@ -1045,6 +1170,9 @@ export const useSettingsState = () => {
|
||||
globalHotkeyEnabled,
|
||||
setGlobalHotkeyEnabled,
|
||||
rehydrateAllFromStorage,
|
||||
reapplyCurrentTheme,
|
||||
immersiveMode,
|
||||
setImmersiveMode,
|
||||
// Opaque version that changes when any synced setting changes, used by useAutoSync.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
settingsVersion: useMemo(() => Math.random(), [
|
||||
@@ -1052,8 +1180,8 @@ export const useSettingsState = () => {
|
||||
uiFontFamilyId, uiLanguage, customCSS,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload,
|
||||
customThemes,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar,
|
||||
customThemes, immersiveMode,
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -197,6 +197,12 @@ export const useSftpBackend = () => {
|
||||
return bridge.showSaveDialog(defaultPath, filters);
|
||||
}, []);
|
||||
|
||||
const selectDirectory = async (title?: string, defaultPath?: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.selectDirectory) return null;
|
||||
return bridge.selectDirectory(title, defaultPath);
|
||||
};
|
||||
|
||||
const downloadSftpToTempAndOpen = useCallback(async (
|
||||
sftpId: string,
|
||||
remotePath: string,
|
||||
@@ -210,9 +216,7 @@ export const useSftpBackend = () => {
|
||||
}
|
||||
|
||||
// Download the file to temp
|
||||
console.log("[SFTPBackend] Downloading file to temp", { sftpId, remotePath, fileName });
|
||||
const tempPath = await bridge.downloadSftpToTemp(sftpId, remotePath, fileName, options?.encoding);
|
||||
console.log("[SFTPBackend] File downloaded to temp", { tempPath });
|
||||
|
||||
// Register temp file for cleanup when SFTP session closes (regardless of auto-sync setting)
|
||||
if (bridge.registerTempFile) {
|
||||
@@ -224,25 +228,18 @@ export const useSftpBackend = () => {
|
||||
}
|
||||
|
||||
// Open with the selected application
|
||||
console.log("[SFTPBackend] Opening with application", { tempPath, appPath });
|
||||
await bridge.openWithApplication(tempPath, appPath);
|
||||
console.log("[SFTPBackend] Application launched");
|
||||
|
||||
// Start file watching if enabled
|
||||
let watchId: string | undefined;
|
||||
console.log("[SFTPBackend] Auto-sync enabled check", { enableWatch: options?.enableWatch, hasStartFileWatch: !!bridge.startFileWatch });
|
||||
if (options?.enableWatch && bridge.startFileWatch) {
|
||||
try {
|
||||
console.log("[SFTPBackend] Starting file watch", { tempPath, remotePath, sftpId });
|
||||
const result = await bridge.startFileWatch(tempPath, remotePath, sftpId, options?.encoding);
|
||||
watchId = result.watchId;
|
||||
console.log("[SFTPBackend] File watch started successfully", { watchId, tempPath, remotePath });
|
||||
} catch (err) {
|
||||
console.warn("[SFTPBackend] Failed to start file watch:", err);
|
||||
// Don't fail the operation if watching fails
|
||||
}
|
||||
} else {
|
||||
console.log("[SFTPBackend] File watching not enabled or not available");
|
||||
}
|
||||
|
||||
return { localTempPath: tempPath, watchId };
|
||||
@@ -278,6 +275,7 @@ export const useSftpBackend = () => {
|
||||
onTransferProgress,
|
||||
selectApplication,
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
downloadSftpToTempAndOpen,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -25,7 +25,6 @@ let snapshotRef: { associations: FileAssociationsMap } = { associations: {} };
|
||||
|
||||
function loadFromStorage(): FileAssociationsMap {
|
||||
const stored = localStorageAdapter.read<FileAssociationsMap>(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS);
|
||||
console.log('[SftpFileAssociations] Loading from storage:', stored);
|
||||
if (stored) {
|
||||
const migrated: FileAssociationsMap = {};
|
||||
for (const [ext, value] of Object.entries(stored)) {
|
||||
@@ -35,7 +34,6 @@ function loadFromStorage(): FileAssociationsMap {
|
||||
migrated[ext] = value as FileAssociationEntry;
|
||||
}
|
||||
}
|
||||
console.log('[SftpFileAssociations] Migrated associations:', migrated);
|
||||
return migrated;
|
||||
}
|
||||
return {};
|
||||
@@ -45,19 +43,13 @@ function loadFromStorage(): FileAssociationsMap {
|
||||
snapshotRef = { associations: loadFromStorage() };
|
||||
|
||||
function saveToStorage(associations: FileAssociationsMap) {
|
||||
console.log('[SftpFileAssociations] saveToStorage called with:', associations);
|
||||
localStorageAdapter.write(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS, associations);
|
||||
// Verify it was saved
|
||||
const verify = localStorageAdapter.read(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS);
|
||||
console.log('[SftpFileAssociations] Verification read from storage:', verify);
|
||||
}
|
||||
|
||||
function updateAssociations(newAssociations: FileAssociationsMap) {
|
||||
console.log('[SftpFileAssociations] Updating associations:', newAssociations);
|
||||
// Create new reference so useSyncExternalStore detects change
|
||||
snapshotRef = { associations: newAssociations };
|
||||
saveToStorage(newAssociations);
|
||||
console.log('[SftpFileAssociations] Notifying', subscribers.size, 'subscribers');
|
||||
subscribers.forEach(callback => callback());
|
||||
}
|
||||
|
||||
@@ -101,8 +93,6 @@ export function useSftpFileAssociations() {
|
||||
openerType: FileOpenerType,
|
||||
systemApp?: SystemAppInfo
|
||||
) => {
|
||||
console.log('[SftpFileAssociations] setOpenerForExtension called with:', { extension, openerType, systemApp });
|
||||
console.log('[SftpFileAssociations] Current associations before update:', snapshotRef.associations);
|
||||
updateAssociations({
|
||||
...snapshotRef.associations,
|
||||
[extension.toLowerCase()]: { openerType, systemApp },
|
||||
@@ -122,13 +112,11 @@ export function useSftpFileAssociations() {
|
||||
* Get all associations as an array
|
||||
*/
|
||||
const getAllAssociations = useCallback((): FileAssociation[] => {
|
||||
const result = Object.entries(associations).map(([extension, entry]: [string, FileAssociationEntry]) => ({
|
||||
return Object.entries(associations).map(([extension, entry]: [string, FileAssociationEntry]) => ({
|
||||
extension,
|
||||
openerType: entry.openerType,
|
||||
systemApp: entry.systemApp,
|
||||
}));
|
||||
console.log('[SftpFileAssociations] getAllAssociations called, returning', result.length, 'items:', result);
|
||||
return result;
|
||||
}, [associations]);
|
||||
|
||||
/**
|
||||
|
||||
29
application/state/useStoredNumber.ts
Normal file
29
application/state/useStoredNumber.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
/**
|
||||
* Hook for reading a number from localStorage with lazy persistence.
|
||||
* Unlike useStoredString/useStoredBoolean, this hook does NOT auto-persist
|
||||
* on every state change — call `persist()` explicitly when ready (e.g. on
|
||||
* mouseup after a drag). This avoids flooding localStorage during
|
||||
* high-frequency updates like resize drags.
|
||||
*/
|
||||
export const useStoredNumber = (
|
||||
storageKey: string,
|
||||
fallback: number,
|
||||
clamp?: { min: number; max: number },
|
||||
) => {
|
||||
const [value, setValue] = useState<number>(() => {
|
||||
const stored = localStorageAdapter.readNumber(storageKey);
|
||||
if (stored === null) return fallback;
|
||||
if (clamp) return Math.max(clamp.min, Math.min(clamp.max, stored));
|
||||
return stored;
|
||||
});
|
||||
|
||||
const persist = useCallback(
|
||||
(v: number) => localStorageAdapter.writeNumber(storageKey, v),
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
return [value, setValue, persist] as const;
|
||||
};
|
||||
@@ -96,7 +96,7 @@ export const useTerminalBackend = () => {
|
||||
return bridge.onSessionExit(sessionId, cb);
|
||||
}, []);
|
||||
|
||||
const onChainProgress = useCallback((cb: (hop: number, total: number, label: string, status: string) => void) => {
|
||||
const onChainProgress = useCallback((cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onChainProgress?.(cb);
|
||||
}, []);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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, STORAGE_KEY_UPDATE_LATEST_RELEASE, STORAGE_KEY_AUTO_UPDATE_ENABLED } from '../../infrastructure/config/storageKeys';
|
||||
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE, STORAGE_KEY_AUTO_UPDATE_ENABLED, STORAGE_KEY_DEBUG_UPDATE_DEMO } from '../../infrastructure/config/storageKeys';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
// Check for updates at most once per hour
|
||||
@@ -13,15 +13,10 @@ const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
||||
// 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';
|
||||
const IS_UPDATE_DEMO_MODE = localStorageAdapter.readString(STORAGE_KEY_DEBUG_UPDATE_DEMO) === '1';
|
||||
|
||||
// Debug logging for update checks
|
||||
const debugLog = (...args: unknown[]) => {
|
||||
if (IS_UPDATE_DEMO_MODE || (typeof window !== 'undefined' && window.localStorage?.getItem('debug.updateCheck') === '1')) {
|
||||
console.log('[UpdateCheck]', ...args);
|
||||
}
|
||||
};
|
||||
// Debug logging for update checks (no-op in production)
|
||||
const debugLog = (..._args: unknown[]) => {};
|
||||
|
||||
export type AutoDownloadStatus = 'idle' | 'downloading' | 'ready' | 'error';
|
||||
|
||||
@@ -48,6 +43,8 @@ export interface UseUpdateCheckResult {
|
||||
dismissUpdate: () => void;
|
||||
openReleasePage: () => void;
|
||||
installUpdate: () => void;
|
||||
startDownload: () => void;
|
||||
isUpdateDemoMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -518,6 +515,46 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
|
||||
netcattyBridge.get()?.installUpdate?.();
|
||||
}, []);
|
||||
|
||||
const startDownload = useCallback(async () => {
|
||||
if (autoDownloadStatusRef.current === 'downloading' || autoDownloadStatusRef.current === 'ready') return;
|
||||
const bridge = netcattyBridge.get();
|
||||
try {
|
||||
const checkResult = await bridge?.checkForUpdate?.();
|
||||
if (!checkResult || checkResult.checking === true || checkResult.ready === true || checkResult.downloading === true) return;
|
||||
if (checkResult.supported === false) {
|
||||
openReleasePage();
|
||||
return;
|
||||
}
|
||||
if (checkResult.available === false) {
|
||||
openReleasePage();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
autoDownloadStatus: 'downloading',
|
||||
downloadPercent: 0,
|
||||
downloadError: null,
|
||||
}));
|
||||
void bridge?.downloadUpdate?.().then((res) => {
|
||||
if (res && !res.success) {
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
autoDownloadStatus: 'error',
|
||||
downloadError: res.error || 'Download failed',
|
||||
}));
|
||||
}
|
||||
}).catch(() => {
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
autoDownloadStatus: 'error',
|
||||
downloadError: 'Download failed',
|
||||
}));
|
||||
});
|
||||
}, [openReleasePage]);
|
||||
|
||||
// Startup check with delay - runs once on mount
|
||||
useEffect(() => {
|
||||
debugLog('Startup check effect mounted, IS_UPDATE_DEMO_MODE:', IS_UPDATE_DEMO_MODE);
|
||||
@@ -657,5 +694,7 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
|
||||
dismissUpdate,
|
||||
openReleasePage,
|
||||
installUpdate,
|
||||
startDownload,
|
||||
isUpdateDemoMode: IS_UPDATE_DEMO_MODE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import type {
|
||||
PortForwardingRule,
|
||||
Snippet,
|
||||
SSHKey,
|
||||
} from './models';
|
||||
import type { SyncPayload } from './sync';
|
||||
} from '../domain/models';
|
||||
import type { SyncPayload } from '../domain/sync';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
STORAGE_KEY_THEME,
|
||||
@@ -36,7 +36,9 @@ import {
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_CUSTOM_THEMES,
|
||||
STORAGE_KEY_IMMERSIVE_MODE,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -55,7 +57,7 @@ export interface SyncableVaultData {
|
||||
}
|
||||
|
||||
/** Callbacks used by `applySyncPayload` to import data into local state. */
|
||||
export interface SyncPayloadImporters {
|
||||
interface SyncPayloadImporters {
|
||||
/** Import vault data (hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts). */
|
||||
importVaultData: (jsonString: string) => void;
|
||||
/** Import port-forwarding rules (lives outside the vault hook). */
|
||||
@@ -73,9 +75,12 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'scrollback', 'drawBoldInBrightColors', 'fontLigatures', 'fontWeight', 'fontWeightBold',
|
||||
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
|
||||
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'smoothScrolling',
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'disableBracketedPaste', 'osc52Clipboard',
|
||||
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
|
||||
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -153,6 +158,12 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (hidden === 'true' || hidden === 'false') settings.sftpShowHiddenFiles = hidden === 'true';
|
||||
const compress = localStorageAdapter.readString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
|
||||
if (compress === 'true' || compress === 'false') settings.sftpUseCompressedUpload = compress === 'true';
|
||||
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
|
||||
|
||||
// Immersive mode
|
||||
const immersive = localStorageAdapter.readString(STORAGE_KEY_IMMERSIVE_MODE);
|
||||
if (immersive === 'true' || immersive === 'false') settings.immersiveMode = immersive === 'true';
|
||||
|
||||
return Object.keys(settings).length > 0 ? settings : undefined;
|
||||
}
|
||||
@@ -161,7 +172,7 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
* Apply synced settings to localStorage. Merges terminal settings
|
||||
* to preserve platform-specific fields.
|
||||
*/
|
||||
export function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): void {
|
||||
function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): void {
|
||||
// Theme & Appearance
|
||||
if (settings.theme != null) localStorageAdapter.writeString(STORAGE_KEY_THEME, settings.theme);
|
||||
if (settings.lightUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, settings.lightUiThemeId);
|
||||
@@ -211,6 +222,10 @@ export function applySyncableSettings(settings: NonNullable<SyncPayload['setting
|
||||
if (settings.sftpAutoSync != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, String(settings.sftpAutoSync));
|
||||
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
|
||||
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
|
||||
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
|
||||
|
||||
// Immersive mode
|
||||
if (settings.immersiveMode != null) localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, String(settings.immersiveMode));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -6,7 +6,6 @@
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
|
||||
@@ -20,7 +19,7 @@ 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 { useFileUpload } from '../application/state/useFileUpload';
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
AISession,
|
||||
@@ -40,7 +39,7 @@ 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 { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
|
||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
|
||||
@@ -57,6 +56,7 @@ interface AIChatSidePanelProps {
|
||||
deleteSession: (sessionId: string, scopeKey?: string) => void;
|
||||
updateSessionTitle: (sessionId: string, title: string) => void;
|
||||
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
|
||||
retargetSessionScope: (sessionId: string, scope: AISessionScope) => void;
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateLastMessage: (
|
||||
sessionId: string,
|
||||
@@ -102,6 +102,9 @@ interface AIChatSidePanelProps {
|
||||
label: string;
|
||||
os?: string;
|
||||
username?: string;
|
||||
protocol?: string;
|
||||
shellType?: string;
|
||||
deviceType?: string;
|
||||
connected: boolean;
|
||||
}>;
|
||||
resolveExecutorContext?: (scope: {
|
||||
@@ -151,6 +154,27 @@ function buildAcpHistoryMessages(messages: ChatMessage[]): Array<{ role: 'user'
|
||||
});
|
||||
}
|
||||
|
||||
function getSessionScopeMatchRank(
|
||||
session: AISession,
|
||||
scopeType: 'terminal' | 'workspace',
|
||||
scopeTargetId?: string,
|
||||
scopeHostIds?: string[],
|
||||
activeTerminalTargetIds?: Set<string>,
|
||||
): number {
|
||||
if (session.scope.type !== scopeType) return 0;
|
||||
if (session.scope.targetId === scopeTargetId) return 2;
|
||||
|
||||
if (scopeType !== 'terminal' || !scopeHostIds?.length || !session.scope.hostIds?.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (session.scope.targetId && activeTerminalTargetIds?.has(session.scope.targetId)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return session.scope.hostIds.some((hostId) => scopeHostIds.includes(hostId)) ? 1 : 0;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
@@ -163,6 +187,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
deleteSession,
|
||||
updateSessionTitle,
|
||||
updateSessionExternalSessionId,
|
||||
retargetSessionScope,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
@@ -202,7 +227,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [currentAgentId, setCurrentAgentId] = useState(defaultAgentId);
|
||||
|
||||
const { images, addImages, removeImage, clearImages } = useImageUpload();
|
||||
const { files, addFiles, removeFile, clearFiles } = useFileUpload();
|
||||
const { openSettingsWindow } = useWindowControls();
|
||||
const terminalSessionsRef = useRef(terminalSessions);
|
||||
terminalSessionsRef.current = terminalSessions;
|
||||
@@ -214,7 +239,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
streamingSessionIds,
|
||||
setStreamingForScope,
|
||||
abortControllersRef,
|
||||
processCattyStream,
|
||||
sendToCattyAgent,
|
||||
sendToExternalAgent,
|
||||
reportStreamError,
|
||||
@@ -225,42 +249,122 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
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 activeSessionIdForScope = activeSessionIdMap[scopeKey] ?? null;
|
||||
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);
|
||||
const activeTerminalTargetIds = useMemo(() => {
|
||||
const targetIds = new Set<string>();
|
||||
for (const [sessionScopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
if (!sessionScopeKey.startsWith('terminal:') || !sessionId) continue;
|
||||
const targetId = sessionScopeKey.slice('terminal:'.length);
|
||||
if (!targetId || targetId === scopeTargetId) continue;
|
||||
targetIds.add(targetId);
|
||||
}
|
||||
return targetIds;
|
||||
}, [activeSessionIdMap, scopeTargetId]);
|
||||
|
||||
const historySessions = useMemo(
|
||||
() =>
|
||||
sessions
|
||||
.map((session) => ({
|
||||
session,
|
||||
matchRank: getSessionScopeMatchRank(session, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds),
|
||||
}))
|
||||
.filter(({ matchRank }) => matchRank > 0)
|
||||
.sort((a, b) => b.matchRank - a.matchRank || b.session.updatedAt - a.session.updatedAt)
|
||||
.map(({ session }) => session),
|
||||
[sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds],
|
||||
);
|
||||
|
||||
const activeSession = useMemo(() => {
|
||||
if (activeSessionIdForScope) {
|
||||
const session = sessions.find((s) => s.id === activeSessionIdForScope);
|
||||
if (session && getSessionScopeMatchRank(session, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds) > 0) {
|
||||
return session;
|
||||
}
|
||||
}
|
||||
}, [scopeKey, activeSessionId, sessions]);
|
||||
return historySessions[0] ?? null;
|
||||
}, [sessions, activeSessionIdForScope, historySessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);
|
||||
|
||||
const activeSessionId = activeSession?.id ?? activeSessionIdForScope;
|
||||
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
|
||||
|
||||
const shouldRetargetActiveSession = useMemo(() => {
|
||||
if (!activeSession || scopeType !== 'terminal' || !scopeTargetId || !scopeHostIds?.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (activeSession.scope.type !== scopeType || activeSession.scope.targetId === scopeTargetId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't retarget sessions that are actively owned by another terminal
|
||||
if (activeSession.scope.targetId && activeTerminalTargetIds.has(activeSession.scope.targetId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return activeSession.scope.hostIds?.some((hostId) => scopeHostIds.includes(hostId)) ?? false;
|
||||
}, [activeSession, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSession) return;
|
||||
|
||||
if (shouldRetargetActiveSession && isVisible) {
|
||||
// Full cleanup of any in-flight work — the session came from a disconnected
|
||||
// terminal, so any active response, pending approvals, or exec is dead.
|
||||
if (streamingSessionIds.has(activeSession.id)) {
|
||||
const controller = abortControllersRef.current.get(activeSession.id);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
abortControllersRef.current.delete(activeSession.id);
|
||||
}
|
||||
setStreamingForScope(activeSession.id, false);
|
||||
clearAllPendingApprovals(activeSession.id);
|
||||
const bridge = getNetcattyBridge();
|
||||
bridge?.aiCattyCancelExec?.(activeSession.id);
|
||||
bridge?.aiAcpCancel?.('', activeSession.id);
|
||||
}
|
||||
retargetSessionScope(activeSession.id, {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
hostIds: scopeHostIds,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVisible && activeSessionIdForScope !== activeSession.id) {
|
||||
setActiveSessionId(activeSession.id);
|
||||
}
|
||||
}, [
|
||||
activeSession,
|
||||
activeSessionIdForScope,
|
||||
retargetSessionScope,
|
||||
isVisible,
|
||||
scopeHostIds,
|
||||
scopeTargetId,
|
||||
scopeType,
|
||||
setActiveSessionId,
|
||||
setStreamingForScope,
|
||||
shouldRetargetActiveSession,
|
||||
streamingSessionIds,
|
||||
abortControllersRef,
|
||||
]);
|
||||
|
||||
// Restore agent selector from active session when scope changes
|
||||
useEffect(() => {
|
||||
if (activeSession) {
|
||||
setCurrentAgentId(activeSession.agentId);
|
||||
}
|
||||
}, [scopeKey, activeSession]);
|
||||
|
||||
// Proactively sync terminal session metadata to main process whenever scope or sessions change
|
||||
useEffect(() => {
|
||||
const bridge = getNetcattyBridge();
|
||||
if (bridge?.aiMcpUpdateSessions && terminalSessions.length > 0) {
|
||||
if (bridge?.aiMcpUpdateSessions) {
|
||||
void bridge.aiMcpUpdateSessions(terminalSessions, activeSessionId ?? undefined);
|
||||
}
|
||||
}, [terminalSessions, scopeKey, activeSessionId]);
|
||||
@@ -308,12 +412,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
[enableAgent, setExternalAgents],
|
||||
);
|
||||
|
||||
// Active session (scoped)
|
||||
const activeSession = useMemo(
|
||||
() => sessions.find((s) => s.id === activeSessionId) ?? null,
|
||||
[sessions, activeSessionId],
|
||||
);
|
||||
|
||||
const messages = activeSession?.messages ?? [];
|
||||
|
||||
// ── Export hook ──
|
||||
@@ -359,15 +457,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
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
|
||||
// -------------------------------------------------------------------
|
||||
@@ -407,8 +496,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
/** 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;
|
||||
const filesRef = useRef(files);
|
||||
filesRef.current = files;
|
||||
|
||||
/** Auto-title a session from the first user message if untitled. */
|
||||
const autoTitleSession = useCallback((sessionId: string, text: string) => {
|
||||
@@ -434,12 +523,34 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
/** Ensure a session exists for the current scope and return its ID. */
|
||||
const ensureSession = useCallback((): string => {
|
||||
if (activeSessionId) return activeSessionId;
|
||||
if (activeSession && sessionsRef.current.some((session) => session.id === activeSession.id)) {
|
||||
if (shouldRetargetActiveSession) {
|
||||
retargetSessionScope(activeSession.id, {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
hostIds: scopeHostIds,
|
||||
});
|
||||
} else if (activeSessionIdForScope !== activeSession.id) {
|
||||
setActiveSessionId(activeSession.id);
|
||||
}
|
||||
return activeSession.id;
|
||||
}
|
||||
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]);
|
||||
}, [
|
||||
activeSession,
|
||||
activeSessionIdForScope,
|
||||
createSession,
|
||||
currentAgentId,
|
||||
retargetSessionScope,
|
||||
scopeHostIds,
|
||||
scopeTargetId,
|
||||
scopeType,
|
||||
setActiveSessionId,
|
||||
shouldRetargetActiveSession,
|
||||
]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Main send handler (thin orchestrator)
|
||||
@@ -465,16 +576,16 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const sessionId = ensureSession();
|
||||
|
||||
// Capture images before clearing
|
||||
const attachedImages = imagesRef.current.map(img => ({ base64Data: img.base64Data, mediaType: img.mediaType, filename: img.filename }));
|
||||
const attachments = filesRef.current.map(f => ({ base64Data: f.base64Data, mediaType: f.mediaType, filename: f.filename, filePath: f.filePath }));
|
||||
|
||||
// Add user message
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
...(attachedImages.length > 0 ? { images: attachedImages } : {}),
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setInputValue('');
|
||||
clearImages();
|
||||
clearFiles();
|
||||
setStreamingForScope(sessionId, true);
|
||||
|
||||
// Create assistant message placeholder with a tracked ID
|
||||
@@ -497,7 +608,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachedImages, {
|
||||
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachments, {
|
||||
existingSessionId: currentSession?.externalSessionId,
|
||||
updateExternalSessionId: updateSessionExternalSessionId,
|
||||
historyMessages: buildAcpHistoryMessages(currentSession?.messages ?? []),
|
||||
@@ -530,18 +641,17 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
terminalSessions,
|
||||
webSearchConfig,
|
||||
getExecutorContext: () => buildExecutorContextForScope(toolScope),
|
||||
setPendingApproval,
|
||||
autoTitleSession,
|
||||
});
|
||||
}, attachments.length > 0 ? attachments : undefined);
|
||||
}
|
||||
}, [
|
||||
isStreaming, activeProvider, scopeKey, currentAgentId,
|
||||
activeModelId, externalAgents,
|
||||
ensureSession, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
setStreamingForScope, setInputValue, clearImages,
|
||||
setStreamingForScope, setInputValue, clearFiles,
|
||||
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
|
||||
abortControllersRef, terminalSessions, providers, selectedAgentModel, updateSessionExternalSessionId,
|
||||
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope, setPendingApproval,
|
||||
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope,
|
||||
]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
@@ -556,11 +666,13 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
statusText: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'cancelled' : 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]);
|
||||
// Clear pending approvals for this session (so tool execute functions don't hang)
|
||||
clearAllPendingApprovals(activeSessionId);
|
||||
// Cancel in-flight command executions (Catty Agent + ACP Agent)
|
||||
const bridge = getNetcattyBridge();
|
||||
bridge?.aiCattyCancelExec?.(activeSessionId);
|
||||
bridge?.aiAcpCancel?.('', activeSessionId);
|
||||
}, [activeSessionId, setStreamingForScope, updateLastMessage, abortControllersRef]);
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
(sessionId: string) => {
|
||||
@@ -653,16 +765,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
onApprove={(messageId) => void handleApprovalResponse(messageId, true, {
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
webSearchConfig,
|
||||
})}
|
||||
onReject={(messageId) => void handleApprovalResponse(messageId, false, {
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
webSearchConfig,
|
||||
})}
|
||||
activeSessionId={activeSessionId}
|
||||
/>
|
||||
|
||||
{/* Recent sessions (Zed-style, shown when no messages) */}
|
||||
@@ -707,9 +810,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
modelPresets={agentModelPresets}
|
||||
selectedModelId={selectedAgentModel}
|
||||
onModelSelect={handleAgentModelSelect}
|
||||
images={images}
|
||||
onAddImages={addImages}
|
||||
onRemoveImage={removeImage}
|
||||
files={files}
|
||||
onAddFiles={addFiles}
|
||||
onRemoveFile={removeFile}
|
||||
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
|
||||
permissionMode={globalPermissionMode}
|
||||
onPermissionModeChange={setGlobalPermissionMode}
|
||||
@@ -767,9 +870,12 @@ const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
const timeStr = formatRelativeTime(time, t);
|
||||
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={session.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(session.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') 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',
|
||||
@@ -790,7 +896,7 @@ const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -411,7 +411,7 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => { console.log('[ProviderCard] Connect clicked'); onConnect(); }}
|
||||
onClick={() => { onConnect(); }}
|
||||
className="gap-1"
|
||||
disabled={disabled || isConnecting}
|
||||
>
|
||||
@@ -611,7 +611,7 @@ interface SyncDashboardProps {
|
||||
onClearLocalData?: () => void;
|
||||
}
|
||||
|
||||
export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
onBuildPayload,
|
||||
onApplyPayload,
|
||||
onClearLocalData,
|
||||
@@ -689,15 +689,6 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Debug: log provider states
|
||||
console.log('[SyncDashboard] Provider states:', {
|
||||
github: sync.providers.github.status,
|
||||
google: sync.providers.google.status,
|
||||
onedrive: sync.providers.onedrive.status,
|
||||
webdav: sync.providers.webdav.status,
|
||||
s3: sync.providers.s3.status,
|
||||
});
|
||||
|
||||
// GitHub Device Flow state
|
||||
const [showGitHubModal, setShowGitHubModal] = useState(false);
|
||||
const [gitHubUserCode, setGitHubUserCode] = useState('');
|
||||
@@ -789,12 +780,9 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
|
||||
// Connect GitHub (disconnect others first - single provider only)
|
||||
const handleConnectGitHub = async () => {
|
||||
console.log('[CloudSync] handleConnectGitHub called');
|
||||
try {
|
||||
await disconnectOtherProviders('github');
|
||||
console.log('[CloudSync] Calling sync.connectGitHub()...');
|
||||
const deviceFlow = await sync.connectGitHub();
|
||||
console.log('[CloudSync] Device flow received:', deviceFlow.userCode);
|
||||
setGitHubUserCode(deviceFlow.userCode);
|
||||
setGitHubVerificationUri(deviceFlow.verificationUri);
|
||||
setShowGitHubModal(true);
|
||||
@@ -812,6 +800,9 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
toast.success(t('cloudSync.connect.github.success'));
|
||||
} catch (error) {
|
||||
setIsPollingGitHub(false);
|
||||
setShowGitHubModal(false);
|
||||
// Reset provider status so button is clickable again (without tearing down existing connections)
|
||||
sync.resetProviderStatus('github');
|
||||
const message = getNetworkErrorMessage(error, t('common.unknownError'));
|
||||
toast.error(message, t('cloudSync.connect.github.failedTitle'));
|
||||
}
|
||||
@@ -825,10 +816,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// Note: Auth flow is handled automatically by oauthBridge
|
||||
toast.info(t('cloudSync.connect.browserContinue'));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('cloudSync.connect.google.failedTitle'),
|
||||
);
|
||||
// Reset provider status so button is clickable again (without tearing down existing connections)
|
||||
sync.resetProviderStatus('google');
|
||||
const msg = error instanceof Error ? error.message : t('common.unknownError');
|
||||
// Don't show toast for user-initiated cancellation (popup closed)
|
||||
if (!msg.includes('cancelled')) {
|
||||
toast.error(msg, t('cloudSync.connect.google.failedTitle'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -840,10 +834,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// Note: Auth flow is handled automatically by oauthBridge
|
||||
toast.info(t('cloudSync.connect.browserContinue'));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('cloudSync.connect.onedrive.failedTitle'),
|
||||
);
|
||||
// Reset provider status so button is clickable again (without tearing down existing connections)
|
||||
sync.resetProviderStatus('onedrive');
|
||||
const msg = error instanceof Error ? error.message : t('common.unknownError');
|
||||
// Don't show toast for user-initiated cancellation (popup closed)
|
||||
if (!msg.includes('cancelled')) {
|
||||
toast.error(msg, t('cloudSync.connect.onedrive.failedTitle'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1262,6 +1259,9 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
onClose={() => {
|
||||
setShowGitHubModal(false);
|
||||
setIsPollingGitHub(false);
|
||||
// Reset provider status so button is clickable again.
|
||||
// The background polling will continue until expiry but is harmless.
|
||||
sync.resetProviderStatus('github');
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Server, Usb } from "lucide-react";
|
||||
import React, { memo } from "react";
|
||||
import { normalizeDistroId } from "../domain/host";
|
||||
import { getEffectiveHostDistro } from "../domain/host";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host } from "../types";
|
||||
|
||||
@@ -58,8 +58,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
className,
|
||||
size = "md",
|
||||
}) => {
|
||||
const distro =
|
||||
normalizeDistroId(host.distro) || (host.distro || "").toLowerCase();
|
||||
const distro = getEffectiveHostDistro(host);
|
||||
const logo = DISTRO_LOGOS[distro];
|
||||
const [errored, setErrored] = React.useState(false);
|
||||
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
|
||||
@@ -106,7 +105,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
>
|
||||
<img
|
||||
src={logo}
|
||||
alt={host.distro || host.os}
|
||||
alt={distro || host.os}
|
||||
className={cn("object-contain invert brightness-0", iconSize)}
|
||||
onError={() => setErrored(true)}
|
||||
/>
|
||||
|
||||
@@ -45,7 +45,6 @@ export const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
|
||||
try {
|
||||
const result = await onSelectSystemApp();
|
||||
if (result) {
|
||||
console.log('[FileOpenerDialog] Calling onSelect with rememberChoice:', rememberChoice, 'result:', result);
|
||||
onSelect('system-app', rememberChoice, result);
|
||||
onClose();
|
||||
}
|
||||
|
||||
@@ -20,18 +20,31 @@ import {
|
||||
Tag,
|
||||
TerminalSquare,
|
||||
User,
|
||||
FileKey,
|
||||
FolderOpen,
|
||||
Trash2,
|
||||
Variable,
|
||||
Wifi,
|
||||
Router,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useApplicationBackend } from "../application/state/useApplicationBackend";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { getEffectiveHostDistro, LINUX_DISTRO_OPTIONS } from "../domain/host";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import {
|
||||
clearHostFontSizeOverride,
|
||||
clearHostThemeOverride,
|
||||
hasHostFontSizeOverride,
|
||||
hasHostThemeOverride,
|
||||
resolveHostTerminalFontSize,
|
||||
resolveHostTerminalThemeId,
|
||||
} from "../domain/terminalAppearance";
|
||||
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";
|
||||
import { DISTRO_COLORS, DISTRO_LOGOS } from "./DistroAvatar";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import ThemeSelectPanel from "./ThemeSelectPanel";
|
||||
import {
|
||||
@@ -59,7 +72,7 @@ import {
|
||||
ProxyPanel,
|
||||
} from "./host-details";
|
||||
|
||||
type CredentialType = "sshid" | "key" | "certificate" | null;
|
||||
type CredentialType = "sshid" | "key" | "certificate" | "localKeyFile" | null;
|
||||
type SubPanel =
|
||||
| "none"
|
||||
| "create-group"
|
||||
@@ -69,6 +82,8 @@ type SubPanel =
|
||||
| "theme-select"
|
||||
| "telnet-theme-select";
|
||||
|
||||
const LINUX_DISTRO_OPTION_IDS = [...LINUX_DISTRO_OPTIONS];
|
||||
|
||||
interface HostDetailsPanelProps {
|
||||
initialData?: Host | null;
|
||||
availableKeys: SSHKey[];
|
||||
@@ -78,6 +93,8 @@ interface HostDetailsPanelProps {
|
||||
allTags?: string[]; // All available tags for autocomplete
|
||||
allHosts?: Host[]; // All hosts for chain selection
|
||||
defaultGroup?: string | null; // Default group for new hosts (from current navigation)
|
||||
terminalThemeId: string;
|
||||
terminalFontSize: number;
|
||||
onSave: (host: Host) => void;
|
||||
onCancel: () => void;
|
||||
onCreateGroup?: (groupPath: string) => void; // Callback to create a new group
|
||||
@@ -93,6 +110,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
allTags = [],
|
||||
allHosts = [],
|
||||
defaultGroup,
|
||||
terminalThemeId,
|
||||
terminalFontSize,
|
||||
onSave,
|
||||
onCancel,
|
||||
onCreateGroup,
|
||||
@@ -100,7 +119,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
const { terminalThemeId, terminalFontSize } = useSettingsState();
|
||||
const [form, setForm] = useState<Host>(
|
||||
() =>
|
||||
initialData ||
|
||||
@@ -115,8 +133,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
os: "linux",
|
||||
authMethod: "password",
|
||||
charset: "UTF-8",
|
||||
theme: terminalThemeId,
|
||||
fontSize: terminalFontSize,
|
||||
distroMode: "auto",
|
||||
createdAt: Date.now(),
|
||||
group: defaultGroup || undefined, // Pre-fill with current navigation group
|
||||
} as Host),
|
||||
@@ -136,6 +153,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
// Password visibility state
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// Local key file path input state
|
||||
const [newKeyFilePath, setNewKeyFilePath] = useState("");
|
||||
|
||||
// New group creation state
|
||||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [newGroupParent, setNewGroupParent] = useState("");
|
||||
@@ -179,6 +199,56 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const effectiveThemeId = useMemo(
|
||||
() => resolveHostTerminalThemeId(form, terminalThemeId),
|
||||
[form, terminalThemeId],
|
||||
);
|
||||
const effectiveFontSize = useMemo(
|
||||
() => resolveHostTerminalFontSize(form, terminalFontSize),
|
||||
[form, terminalFontSize],
|
||||
);
|
||||
const hasEffectiveThemeOverride = useMemo(
|
||||
() => hasHostThemeOverride(form),
|
||||
[form],
|
||||
);
|
||||
const hasEffectiveFontSizeOverride = useMemo(
|
||||
() => hasHostFontSizeOverride(form),
|
||||
[form],
|
||||
);
|
||||
const effectiveTelnetThemeId =
|
||||
form.protocols?.find((p) => p.protocol === "telnet")?.theme || effectiveThemeId;
|
||||
const distroOptions = useMemo(
|
||||
() =>
|
||||
LINUX_DISTRO_OPTION_IDS.map((value) => ({
|
||||
value,
|
||||
label: t(`hostDetails.distro.option.${value}`),
|
||||
icon: DISTRO_LOGOS[value],
|
||||
bgClass: DISTRO_COLORS[value] || DISTRO_COLORS.default,
|
||||
})),
|
||||
[t],
|
||||
);
|
||||
|
||||
const getDistroOptionLabel = useCallback(
|
||||
(value?: string) =>
|
||||
distroOptions.find((option) => option.value === value)?.label ||
|
||||
value ||
|
||||
t("hostDetails.distro.pending"),
|
||||
[distroOptions, t],
|
||||
);
|
||||
|
||||
const effectiveFormDistro = getEffectiveHostDistro(form);
|
||||
|
||||
const handleDistroModeChange = useCallback((mode: "auto" | "manual") => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
distroMode: mode,
|
||||
manualDistro:
|
||||
mode === "manual"
|
||||
? prev.manualDistro || getEffectiveHostDistro(prev) || "linux"
|
||||
: prev.manualDistro,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const updateProxyConfig = useCallback(
|
||||
(field: keyof ProxyConfig, value: string | number) => {
|
||||
setForm((prev) => ({
|
||||
@@ -298,6 +368,27 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
password: form.savePassword === false ? undefined : form.password,
|
||||
managedSourceId: finalManagedSourceId,
|
||||
};
|
||||
const preserveLegacyTheme = initialData?.theme != null && cleaned.themeOverride !== false;
|
||||
const preserveLegacyFontFamily = initialData?.fontFamily != null && cleaned.fontFamilyOverride !== false;
|
||||
const preserveLegacyFontSize = initialData?.fontSize != null && cleaned.fontSizeOverride !== false;
|
||||
|
||||
if (cleaned.themeOverride === false) {
|
||||
delete cleaned.theme;
|
||||
} else if (preserveLegacyTheme && cleaned.theme == null) {
|
||||
cleaned.theme = initialData?.theme;
|
||||
}
|
||||
|
||||
if (cleaned.fontFamilyOverride === false) {
|
||||
delete cleaned.fontFamily;
|
||||
} else if (preserveLegacyFontFamily && cleaned.fontFamily == null) {
|
||||
cleaned.fontFamily = initialData?.fontFamily;
|
||||
}
|
||||
|
||||
if (cleaned.fontSizeOverride === false) {
|
||||
delete cleaned.fontSize;
|
||||
} else if (preserveLegacyFontSize && cleaned.fontSize == null) {
|
||||
cleaned.fontSize = initialData?.fontSize;
|
||||
}
|
||||
onSave(cleaned);
|
||||
};
|
||||
|
||||
@@ -387,6 +478,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
authMethod: identity.authMethod,
|
||||
password: undefined,
|
||||
identityFileId: undefined,
|
||||
identityFilePaths: undefined,
|
||||
}));
|
||||
setSelectedCredentialType(null);
|
||||
setCredentialPopoverOpen(false);
|
||||
@@ -478,9 +570,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
return (
|
||||
<ThemeSelectPanel
|
||||
open={true}
|
||||
selectedThemeId={form.theme || "flexoki-dark"}
|
||||
selectedThemeId={effectiveThemeId}
|
||||
onSelect={(themeId) => {
|
||||
update("theme", themeId);
|
||||
setForm((prev) => ({ ...prev, theme: themeId, themeOverride: true }));
|
||||
setActiveSubPanel("none");
|
||||
}}
|
||||
onClose={onCancel}
|
||||
@@ -495,11 +587,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
return (
|
||||
<ThemeSelectPanel
|
||||
open={true}
|
||||
selectedThemeId={
|
||||
form.protocols?.find((p) => p.protocol === "telnet")?.theme ||
|
||||
form.theme ||
|
||||
"flexoki-dark"
|
||||
}
|
||||
selectedThemeId={effectiveTelnetThemeId}
|
||||
onSelect={(themeId) => {
|
||||
// Update telnet protocol theme
|
||||
const telnetConfig = form.protocols?.find(
|
||||
@@ -891,6 +979,31 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local key file paths display */}
|
||||
{!selectedIdentity && !form.identityFileId && form.identityFilePaths && form.identityFilePaths.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{form.identityFilePaths.map((keyPath, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
|
||||
<FileKey size={14} className="text-primary shrink-0" />
|
||||
<span className="text-xs flex-1 truncate font-mono" title={keyPath}>
|
||||
{keyPath}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => {
|
||||
const paths = form.identityFilePaths?.filter((_, i) => i !== idx) || [];
|
||||
update("identityFilePaths", paths.length > 0 ? paths : undefined);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected credential display */}
|
||||
{!selectedIdentity && form.identityFileId && (
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
|
||||
@@ -968,6 +1081,20 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
{t("hostDetails.credential.certificate")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
|
||||
onClick={() => {
|
||||
setSelectedCredentialType("localKeyFile");
|
||||
setCredentialPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<FileKey size={16} className="text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{t("hostDetails.credential.localKeyFile")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -989,6 +1116,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onValueChange={(val) => {
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "key");
|
||||
update("identityFilePaths", undefined);
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.keys.search")}
|
||||
@@ -1024,6 +1152,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onValueChange={(val) => {
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "certificate");
|
||||
update("identityFilePaths", undefined);
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.certs.search")}
|
||||
@@ -1043,6 +1172,67 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local key file path input - appears after selecting "Local Key File" type */}
|
||||
{!selectedIdentity &&
|
||||
selectedCredentialType === "localKeyFile" &&
|
||||
!form.identityFileId && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder={t("hostDetails.credential.localKeyFilePlaceholder")}
|
||||
value={newKeyFilePath}
|
||||
onChange={(e) => setNewKeyFilePath(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newKeyFilePath.trim()) {
|
||||
e.preventDefault();
|
||||
const paths = [...(form.identityFilePaths || []), newKeyFilePath.trim()];
|
||||
update("identityFilePaths", paths);
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", "key");
|
||||
setNewKeyFilePath("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
title={t("hostDetails.credential.browseKeyFile")}
|
||||
onClick={async () => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.selectFile) return;
|
||||
const filePath = await bridge.selectFile(
|
||||
"Select SSH Private Key",
|
||||
undefined,
|
||||
[{ name: "All Files", extensions: ["*"] }]
|
||||
);
|
||||
if (filePath) {
|
||||
const paths = [...(form.identityFilePaths || []), filePath];
|
||||
update("identityFilePaths", paths);
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", "key");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => {
|
||||
setSelectedCredentialType(null);
|
||||
setNewKeyFilePath("");
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1103,6 +1293,113 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{form.os === "linux" && (
|
||||
<div className="space-y-2 rounded-lg border border-border/70 bg-secondary/30 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Globe size={14} className="mt-0.5 text-muted-foreground" />
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs font-semibold">{t("hostDetails.distro.title")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.distro.desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.mode")}</span>
|
||||
<Select
|
||||
value={form.distroMode || "auto"}
|
||||
onValueChange={(val) => handleDistroModeChange(val as "auto" | "manual")}
|
||||
>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.mode")}>
|
||||
<span className="truncate whitespace-nowrap pr-2 text-left">
|
||||
{form.distroMode === "manual"
|
||||
? t("hostDetails.distro.mode.manual")
|
||||
: t("hostDetails.distro.mode.auto")}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("hostDetails.distro.mode.auto")}</SelectItem>
|
||||
<SelectItem value="manual">{t("hostDetails.distro.mode.manual")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{form.distroMode === "manual" ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.manualLabel")}</span>
|
||||
<Select
|
||||
value={form.manualDistro}
|
||||
onValueChange={(val) => update("manualDistro", val)}
|
||||
>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.manualLabel")}>
|
||||
{(() => {
|
||||
const selectedOption = distroOptions.find((option) => option.value === form.manualDistro);
|
||||
return selectedOption ? (
|
||||
<div className="flex min-w-0 items-center gap-2 pr-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
|
||||
selectedOption.bgClass,
|
||||
)}
|
||||
>
|
||||
{selectedOption.icon ? (
|
||||
<img
|
||||
src={selectedOption.icon}
|
||||
alt={selectedOption.label}
|
||||
className="h-3 w-3 object-contain invert brightness-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-white/70" />
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate whitespace-nowrap">{selectedOption.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder={t("hostDetails.distro.unknown")} />
|
||||
);
|
||||
})()}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-[14rem]">
|
||||
{distroOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
|
||||
option.bgClass,
|
||||
)}
|
||||
>
|
||||
{option.icon ? (
|
||||
<img
|
||||
src={option.icon}
|
||||
alt={option.label}
|
||||
className="h-3 w-3 object-contain invert brightness-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-white/70" />
|
||||
)}
|
||||
</div>
|
||||
<span className="whitespace-nowrap">{option.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.detectedLabel")}</span>
|
||||
<div className="flex h-8 items-center rounded-md border border-border/60 bg-background/50 px-3 text-sm">
|
||||
{effectiveFormDistro
|
||||
? getDistroOptionLabel(effectiveFormDistro)
|
||||
: t("hostDetails.distro.unknown")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SSH Theme Selection */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1113,15 +1410,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:
|
||||
customThemeStore.getThemeById(form.theme || "flexoki-dark")?.colors.background || "#100F0F",
|
||||
customThemeStore.getThemeById(effectiveThemeId)?.colors.background || "#100F0F",
|
||||
color:
|
||||
customThemeStore.getThemeById(form.theme || "flexoki-dark")?.colors.foreground || "#CECDC3",
|
||||
customThemeStore.getThemeById(effectiveThemeId)?.colors.foreground || "#CECDC3",
|
||||
}}
|
||||
>
|
||||
<div className="p-0.5">
|
||||
<div
|
||||
style={{
|
||||
color: customThemeStore.getThemeById(form.theme || "flexoki-dark")?.colors.green,
|
||||
color: customThemeStore.getThemeById(effectiveThemeId)?.colors.green,
|
||||
}}
|
||||
>
|
||||
$
|
||||
@@ -1129,9 +1426,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm flex-1">
|
||||
{customThemeStore.getThemeById(form.theme || "flexoki-dark")?.name || "Flexoki Dark"}
|
||||
{customThemeStore.getThemeById(effectiveThemeId)?.name || "Flexoki Dark"}
|
||||
</span>
|
||||
</button>
|
||||
{hasEffectiveThemeOverride && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-primary"
|
||||
onClick={() => setForm((prev) => clearHostThemeOverride(prev))}
|
||||
>
|
||||
{t("common.useGlobal")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Font Size */}
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1140,11 +1447,15 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if ((form.fontSize || 14) > MIN_FONT_SIZE) {
|
||||
update("fontSize", (form.fontSize || 14) - 1);
|
||||
if (effectiveFontSize > MIN_FONT_SIZE) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontSize: effectiveFontSize - 1,
|
||||
fontSizeOverride: true,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
disabled={(form.fontSize || 14) <= MIN_FONT_SIZE}
|
||||
disabled={effectiveFontSize <= MIN_FONT_SIZE}
|
||||
className="px-2 h-8"
|
||||
>
|
||||
-
|
||||
@@ -1153,25 +1464,43 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
type="number"
|
||||
min={MIN_FONT_SIZE}
|
||||
max={MAX_FONT_SIZE}
|
||||
value={form.fontSize || 14}
|
||||
value={effectiveFontSize}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
if (val >= MIN_FONT_SIZE && val <= MAX_FONT_SIZE) {
|
||||
update("fontSize", val);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontSize: val,
|
||||
fontSizeOverride: true,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
className="w-16 text-center h-8"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">pt</span>
|
||||
{hasEffectiveFontSizeOverride && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-8 text-primary"
|
||||
onClick={() => setForm((prev) => clearHostFontSizeOverride(prev))}
|
||||
>
|
||||
{t("common.useGlobal")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if ((form.fontSize || 14) < MAX_FONT_SIZE) {
|
||||
update("fontSize", (form.fontSize || 14) + 1);
|
||||
if (effectiveFontSize < MAX_FONT_SIZE) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
fontSize: effectiveFontSize + 1,
|
||||
fontSizeOverride: true,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
disabled={(form.fontSize || 14) >= MAX_FONT_SIZE}
|
||||
disabled={effectiveFontSize >= MAX_FONT_SIZE}
|
||||
className="px-2 h-8"
|
||||
>
|
||||
+
|
||||
@@ -1187,7 +1516,15 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<ToggleRow
|
||||
label="Mosh"
|
||||
enabled={!!form.moshEnabled}
|
||||
onToggle={() => update("moshEnabled", !form.moshEnabled)}
|
||||
onToggle={() => {
|
||||
const enabling = !form.moshEnabled;
|
||||
if (enabling && form.deviceType === 'network') {
|
||||
// Network device mode is incompatible with Mosh — clear it
|
||||
setForm(prev => ({ ...prev, moshEnabled: true, deviceType: undefined }));
|
||||
} else {
|
||||
update("moshEnabled", enabling);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -1220,6 +1557,32 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Network Device Mode — only for SSH hosts without Mosh (serial already uses raw mode) */}
|
||||
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && (
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Router size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.deviceType")}</p>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.deviceType")}
|
||||
enabled={form.deviceType === 'network'}
|
||||
onToggle={() => update("deviceType", form.deviceType === 'network' ? undefined : 'network')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground break-words">
|
||||
{t("hostDetails.deviceType.desc")}
|
||||
</p>
|
||||
{form.deviceType === 'network' && (
|
||||
<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.deviceType.warning")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Legacy Algorithms */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1494,21 +1857,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:
|
||||
customThemeStore.getThemeById(
|
||||
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
|
||||
)?.colors.background || "#100F0F",
|
||||
customThemeStore.getThemeById(effectiveTelnetThemeId)?.colors.background || "#100F0F",
|
||||
color:
|
||||
customThemeStore.getThemeById(
|
||||
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
|
||||
)?.colors.foreground || "#CECDC3",
|
||||
customThemeStore.getThemeById(effectiveTelnetThemeId)?.colors.foreground || "#CECDC3",
|
||||
}}
|
||||
>
|
||||
<div className="p-0.5">
|
||||
<div
|
||||
style={{
|
||||
color: customThemeStore.getThemeById(
|
||||
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
|
||||
)?.colors.green,
|
||||
color: customThemeStore.getThemeById(effectiveTelnetThemeId)?.colors.green,
|
||||
}}
|
||||
>
|
||||
$
|
||||
@@ -1516,9 +1873,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm flex-1">
|
||||
{customThemeStore.getThemeById(
|
||||
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
|
||||
)?.name || "Flexoki Dark"}
|
||||
{customThemeStore.getThemeById(effectiveTelnetThemeId)?.name || "Flexoki Dark"}
|
||||
</span>
|
||||
</button>
|
||||
</Card>
|
||||
|
||||
@@ -254,25 +254,25 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
const RENDER_LIMIT = 100; // Limit rendered items for performance
|
||||
|
||||
// Define handleScanSystem before useEffect that depends on it
|
||||
const handleScanSystem = useCallback(async () => {
|
||||
const handleScanSystem = useCallback(async (silent = false) => {
|
||||
setIsScanning(true);
|
||||
try {
|
||||
const content = await readKnownHosts();
|
||||
if (content === undefined) {
|
||||
toast.error(
|
||||
if (!silent) toast.error(
|
||||
t("knownHosts.toast.scanUnavailable"),
|
||||
t("vault.nav.knownHosts"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!content) {
|
||||
toast.info(t("knownHosts.toast.scanNoFile"), t("vault.nav.knownHosts"));
|
||||
if (!silent) toast.info(t("knownHosts.toast.scanNoFile"), t("vault.nav.knownHosts"));
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseKnownHostsFile(content);
|
||||
if (parsed.length === 0) {
|
||||
toast.info(
|
||||
if (!silent) toast.info(
|
||||
t("knownHosts.toast.scanNoEntries"),
|
||||
t("vault.nav.knownHosts"),
|
||||
);
|
||||
@@ -288,16 +288,16 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
|
||||
if (newHosts.length > 0) {
|
||||
onImportFromFile(newHosts);
|
||||
toast.success(
|
||||
if (!silent) toast.success(
|
||||
t("knownHosts.toast.scanImported", { count: newHosts.length }),
|
||||
t("vault.nav.knownHosts"),
|
||||
);
|
||||
} else {
|
||||
toast.info(t("knownHosts.toast.scanNoNew"), t("vault.nav.knownHosts"));
|
||||
if (!silent) toast.info(t("knownHosts.toast.scanNoNew"), t("vault.nav.knownHosts"));
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("Failed to scan system known_hosts:", err);
|
||||
toast.error(
|
||||
if (!silent) toast.error(
|
||||
err instanceof Error ? err.message : t("knownHosts.toast.scanFailed"),
|
||||
t("vault.nav.knownHosts"),
|
||||
);
|
||||
@@ -307,13 +307,12 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
}
|
||||
}, [knownHosts, onRefresh, onImportFromFile, readKnownHosts, t]);
|
||||
|
||||
// Auto-scan on first mount
|
||||
// Auto-scan on first mount (silent — don't show toasts for missing known_hosts)
|
||||
useEffect(() => {
|
||||
if (!hasScannedRef.current) {
|
||||
hasScannedRef.current = true;
|
||||
// Delay scan slightly to not block initial render
|
||||
const timer = setTimeout(() => {
|
||||
handleScanSystem();
|
||||
handleScanSystem(true);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
@@ -515,7 +514,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 px-3 text-xs"
|
||||
onClick={handleScanSystem}
|
||||
onClick={() => handleScanSystem()}
|
||||
disabled={isScanning}
|
||||
>
|
||||
<RefreshCw
|
||||
@@ -572,7 +571,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleScanSystem}
|
||||
onClick={() => handleScanSystem()}
|
||||
disabled={isScanning}
|
||||
>
|
||||
<RefreshCw
|
||||
|
||||
@@ -130,7 +130,9 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
const result = await startTunnel(
|
||||
rule,
|
||||
_host,
|
||||
keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase })),
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
(status, error) => {
|
||||
// Show toast on error (only once)
|
||||
if (status === "error" && error && !errorShown) {
|
||||
@@ -159,7 +161,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
});
|
||||
}
|
||||
},
|
||||
[hosts, keys, setRuleStatus, startTunnel, t],
|
||||
[hosts, identities, keys, setRuleStatus, startTunnel, t],
|
||||
);
|
||||
|
||||
// Stop a port forwarding tunnel
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import type { QuickConnectTarget } from "../domain/quickConnect";
|
||||
import { formatHostPort } from "../domain/host";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, SSHKey } from "../types";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -531,11 +532,11 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
case "protocol":
|
||||
return target.hostname;
|
||||
case "username":
|
||||
return `${protocol.toUpperCase()} ${target.hostname}:${port}`;
|
||||
return `${protocol.toUpperCase()} ${formatHostPort(target.hostname, port)}`;
|
||||
case "knownhost":
|
||||
return `${protocol.toUpperCase()} ${effectiveUsername}@${target.hostname}:${port}`;
|
||||
return `${protocol.toUpperCase()} ${effectiveUsername}@${formatHostPort(target.hostname, port)}`;
|
||||
case "auth":
|
||||
return `${protocol.toUpperCase()} ${target.hostname}:${port}`;
|
||||
return `${protocol.toUpperCase()} ${formatHostPort(target.hostname, port)}`;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
Folder,
|
||||
LayoutGrid,
|
||||
Search,
|
||||
Shield,
|
||||
FolderLock,
|
||||
Terminal,
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
@@ -98,13 +98,17 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
|
||||
// Reset state when opening
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSelectedIndex(0);
|
||||
// Auto focus the input after a short delay
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 50);
|
||||
}
|
||||
if (!isOpen) return;
|
||||
|
||||
const focusTimer = window.setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 50);
|
||||
|
||||
setSelectedIndex(0);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(focusTimer);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle clicks outside the container
|
||||
@@ -287,7 +291,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
const isSelected = idx === selectedIndex;
|
||||
const icon =
|
||||
tabId === "vault" ? (
|
||||
<Shield size={16} />
|
||||
<FolderLock size={16} />
|
||||
) : (
|
||||
<Folder size={16} />
|
||||
);
|
||||
|
||||
@@ -1,817 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useSftpBackend } from "../application/state/useSftpBackend";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { useSftpModalTransfers } from "./sftp-modal/hooks/useSftpModalTransfers";
|
||||
import { Host, RemoteFile, SftpFilenameEncoding } from "../types";
|
||||
import { filterHiddenFiles } from "./sftp";
|
||||
import { DropEntry } from "../lib/sftpFileUtils";
|
||||
import FileOpenerDialog from "./FileOpenerDialog";
|
||||
import TextEditorModal from "./TextEditorModal";
|
||||
import { SftpModalFileList } from "./sftp-modal/SftpModalFileList";
|
||||
import { SftpModalDialogs } from "./sftp-modal/SftpModalDialogs";
|
||||
import { SftpModalFooter } from "./sftp-modal/SftpModalFooter";
|
||||
import { SftpModalHeader } from "./sftp-modal/SftpModalHeader";
|
||||
import { SftpModalUploadTasks } from "./sftp-modal/SftpModalUploadTasks";
|
||||
import { formatBytes, formatDate } from "./sftp-modal/utils";
|
||||
import { useSftpModalSorting } from "./sftp-modal/hooks/useSftpModalSorting";
|
||||
import { useSftpModalVirtualList } from "./sftp-modal/hooks/useSftpModalVirtualList";
|
||||
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";
|
||||
|
||||
interface SFTPModalProps {
|
||||
host: Host;
|
||||
credentials: {
|
||||
username?: string;
|
||||
hostname: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
passphrase?: string;
|
||||
publicKey?: string;
|
||||
keyId?: string;
|
||||
keySource?: 'generated' | 'imported';
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
sftpSudo?: boolean;
|
||||
legacyAlgorithms?: boolean;
|
||||
};
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Initial path to open in SFTP. If not accessible, falls back to home directory. */
|
||||
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> = ({
|
||||
host,
|
||||
credentials,
|
||||
open,
|
||||
onClose,
|
||||
initialPath,
|
||||
initialEntriesToUpload,
|
||||
onUpdateHost,
|
||||
}) => {
|
||||
const {
|
||||
openSftp,
|
||||
closeSftp: closeSftpBackend,
|
||||
listSftp,
|
||||
readSftp,
|
||||
writeSftpBinaryWithProgress,
|
||||
writeSftpBinary,
|
||||
writeSftp,
|
||||
deleteSftp,
|
||||
mkdirSftp,
|
||||
renameSftp,
|
||||
chmodSftp,
|
||||
statSftp,
|
||||
listLocalDir,
|
||||
readLocalFile,
|
||||
writeLocalFile,
|
||||
deleteLocalFile,
|
||||
mkdirLocal,
|
||||
getHomeDir,
|
||||
selectApplication,
|
||||
downloadSftpToTempAndOpen,
|
||||
cancelSftpUpload,
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
showSaveDialog,
|
||||
} = useSftpBackend();
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
setSftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
} = useSettingsState();
|
||||
const isLocalSession = host.protocol === "local";
|
||||
const [filenameEncoding, setFilenameEncoding] = useState<SftpFilenameEncoding>(
|
||||
host.sftpEncoding ?? "auto"
|
||||
);
|
||||
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
const navigatingRef = useRef(false);
|
||||
const clearSelection = useCallback(() => setSelectedFiles(new Set()), []);
|
||||
|
||||
// Update filenameEncoding when host changes
|
||||
useEffect(() => {
|
||||
setFilenameEncoding(host.sftpEncoding ?? "auto");
|
||||
}, [host.id, host.sftpEncoding]);
|
||||
|
||||
const listSftpWithEncoding = useCallback(
|
||||
(sftpId: string, path: string) => listSftp(sftpId, path, filenameEncoding),
|
||||
[listSftp, filenameEncoding],
|
||||
);
|
||||
|
||||
const readSftpWithEncoding = useCallback(
|
||||
(sftpId: string, path: string) => readSftp(sftpId, path, filenameEncoding),
|
||||
[readSftp, filenameEncoding],
|
||||
);
|
||||
|
||||
const writeSftpWithEncoding = useCallback(
|
||||
(sftpId: string, path: string, data: string) =>
|
||||
writeSftp(sftpId, path, data, filenameEncoding),
|
||||
[writeSftp, filenameEncoding],
|
||||
);
|
||||
|
||||
const writeSftpBinaryWithEncoding = useCallback(
|
||||
(sftpId: string, path: string, data: ArrayBuffer) =>
|
||||
writeSftpBinary(sftpId, path, data, filenameEncoding),
|
||||
[writeSftpBinary, filenameEncoding],
|
||||
);
|
||||
|
||||
const writeSftpBinaryWithProgressWithEncoding = useCallback(
|
||||
(
|
||||
sftpId: string,
|
||||
path: string,
|
||||
data: ArrayBuffer,
|
||||
transferId: string,
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void,
|
||||
) =>
|
||||
writeSftpBinaryWithProgress(
|
||||
sftpId,
|
||||
path,
|
||||
data,
|
||||
transferId,
|
||||
filenameEncoding,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onError,
|
||||
),
|
||||
[writeSftpBinaryWithProgress, filenameEncoding],
|
||||
);
|
||||
|
||||
const deleteSftpWithEncoding = useCallback(
|
||||
(sftpId: string, path: string) => deleteSftp(sftpId, path, filenameEncoding),
|
||||
[deleteSftp, filenameEncoding],
|
||||
);
|
||||
|
||||
const mkdirSftpWithEncoding = useCallback(
|
||||
(sftpId: string, path: string) => mkdirSftp(sftpId, path, filenameEncoding),
|
||||
[mkdirSftp, filenameEncoding],
|
||||
);
|
||||
|
||||
const renameSftpWithEncoding = useCallback(
|
||||
(sftpId: string, oldPath: string, newPath: string) =>
|
||||
renameSftp(sftpId, oldPath, newPath, filenameEncoding),
|
||||
[renameSftp, filenameEncoding],
|
||||
);
|
||||
|
||||
const chmodSftpWithEncoding = useCallback(
|
||||
(sftpId: string, path: string, mode: string) =>
|
||||
chmodSftp(sftpId, path, mode, filenameEncoding),
|
||||
[chmodSftp, filenameEncoding],
|
||||
);
|
||||
|
||||
const statSftpWithEncoding = useCallback(
|
||||
(sftpId: string, path: string) => statSftp(sftpId, path, filenameEncoding),
|
||||
[statSftp, filenameEncoding],
|
||||
);
|
||||
|
||||
const downloadSftpToTempAndOpenWithEncoding = useCallback(
|
||||
(
|
||||
sftpId: string,
|
||||
remotePath: string,
|
||||
fileName: string,
|
||||
appPath: string,
|
||||
options?: { enableWatch?: boolean },
|
||||
) =>
|
||||
downloadSftpToTempAndOpen(sftpId, remotePath, fileName, appPath, {
|
||||
...options,
|
||||
encoding: filenameEncoding,
|
||||
}),
|
||||
[downloadSftpToTempAndOpen, filenameEncoding],
|
||||
);
|
||||
|
||||
const {
|
||||
currentPath,
|
||||
setCurrentPath,
|
||||
currentPathRef,
|
||||
files,
|
||||
loading,
|
||||
setLoading,
|
||||
reconnecting,
|
||||
sessionVersion,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
closeSftpSession,
|
||||
localHomeRef,
|
||||
} = useSftpModalSession({
|
||||
open,
|
||||
host,
|
||||
credentials,
|
||||
initialPath,
|
||||
isLocalSession,
|
||||
t,
|
||||
openSftp,
|
||||
closeSftp: closeSftpBackend,
|
||||
listSftp: listSftpWithEncoding,
|
||||
listLocalDir,
|
||||
getHomeDir,
|
||||
onClearSelection: clearSelection,
|
||||
});
|
||||
|
||||
// Track previous encoding to detect changes
|
||||
const prevEncodingRef = useRef(filenameEncoding);
|
||||
|
||||
// Force reload only when filenameEncoding changes (not on every path change)
|
||||
useEffect(() => {
|
||||
if (!open || isLocalSession) return;
|
||||
// Only force reload if encoding actually changed
|
||||
if (prevEncodingRef.current !== filenameEncoding) {
|
||||
prevEncodingRef.current = filenameEncoding;
|
||||
loadFiles(currentPath, { force: true });
|
||||
}
|
||||
}, [currentPath, filenameEncoding, isLocalSession, loadFiles, open]);
|
||||
|
||||
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
|
||||
|
||||
const { sortField, sortOrder, columnWidths, handleSort, handleResizeStart } =
|
||||
useSftpModalSorting();
|
||||
|
||||
const joinPathForSession = useCallback(
|
||||
(base: string, name: string) => joinPath(base, name, isLocalSession),
|
||||
[isLocalSession],
|
||||
);
|
||||
const isRootPathForSession = useCallback(
|
||||
(path: string) => isRootPath(path, isLocalSession),
|
||||
[isLocalSession],
|
||||
);
|
||||
const getParentPathForSession = useCallback(
|
||||
(path: string) => getParentPath(path, isLocalSession),
|
||||
[isLocalSession],
|
||||
);
|
||||
|
||||
const handleNavigate = useCallback((path: string) => {
|
||||
// Prevent double navigation (e.g., from double-click race condition)
|
||||
if (navigatingRef.current) return;
|
||||
navigatingRef.current = true;
|
||||
setCurrentPath(path);
|
||||
// Reset lock after a short delay
|
||||
setTimeout(() => {
|
||||
navigatingRef.current = false;
|
||||
}, 300);
|
||||
}, [navigatingRef, setCurrentPath]);
|
||||
|
||||
const handleUp = () => {
|
||||
if (isRootPathForSession(currentPath)) return;
|
||||
setCurrentPath(getParentPathForSession(currentPath));
|
||||
};
|
||||
|
||||
const {
|
||||
isEditingPath,
|
||||
editingPathValue,
|
||||
setEditingPathValue,
|
||||
pathInputRef,
|
||||
handlePathDoubleClick,
|
||||
handlePathSubmit,
|
||||
handlePathKeyDown,
|
||||
breadcrumbs,
|
||||
visibleBreadcrumbs,
|
||||
hiddenBreadcrumbs,
|
||||
needsBreadcrumbTruncation,
|
||||
breadcrumbPathAtForIndex,
|
||||
rootLabel,
|
||||
rootPath,
|
||||
} = useSftpModalPath({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
localHomePath: localHomeRef.current,
|
||||
onNavigate: handleNavigate,
|
||||
});
|
||||
|
||||
const {
|
||||
handleDelete,
|
||||
handleCreateFolder,
|
||||
handleCreateFile,
|
||||
showCreateDialog,
|
||||
setShowCreateDialog,
|
||||
createType,
|
||||
createName,
|
||||
setCreateName,
|
||||
isCreating,
|
||||
handleCreateSubmit,
|
||||
showRenameDialog,
|
||||
setShowRenameDialog,
|
||||
renameTarget,
|
||||
renameName,
|
||||
setRenameName,
|
||||
isRenaming,
|
||||
openRenameDialog,
|
||||
handleRename,
|
||||
showPermissionsDialog,
|
||||
setShowPermissionsDialog,
|
||||
permissionsTarget,
|
||||
permissions,
|
||||
isChangingPermissions,
|
||||
openPermissionsDialog,
|
||||
togglePermission,
|
||||
getOctalPermissions,
|
||||
getSymbolicPermissions,
|
||||
handleSavePermissions,
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
openFileOpenerDialog,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
showTextEditor,
|
||||
setShowTextEditor,
|
||||
textEditorTarget,
|
||||
setTextEditorTarget,
|
||||
textEditorContent,
|
||||
setTextEditorContent,
|
||||
loadingTextContent,
|
||||
handleEditFile,
|
||||
handleSaveTextFile,
|
||||
handleOpenFile,
|
||||
} = useSftpModalFileActions({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath: joinPathForSession,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
readLocalFile,
|
||||
readSftp: readSftpWithEncoding,
|
||||
writeLocalFile,
|
||||
writeSftp: writeSftpWithEncoding,
|
||||
writeSftpBinary: writeSftpBinaryWithEncoding,
|
||||
deleteLocalFile,
|
||||
deleteSftp: deleteSftpWithEncoding,
|
||||
mkdirLocal,
|
||||
mkdirSftp: mkdirSftpWithEncoding,
|
||||
renameSftp: renameSftpWithEncoding,
|
||||
chmodSftp: chmodSftpWithEncoding,
|
||||
statSftp: statSftpWithEncoding,
|
||||
t,
|
||||
sftpAutoSync,
|
||||
getOpenerForFile,
|
||||
setOpenerForExtension,
|
||||
downloadSftpToTempAndOpen: downloadSftpToTempAndOpenWithEncoding,
|
||||
selectApplication,
|
||||
});
|
||||
|
||||
const {
|
||||
uploading,
|
||||
uploadTasks,
|
||||
dragActive,
|
||||
handleDownload,
|
||||
handleUploadEntries,
|
||||
handleFileSelect,
|
||||
handleFolderSelect,
|
||||
handleDrag,
|
||||
handleDrop,
|
||||
cancelUpload,
|
||||
cancelTask,
|
||||
dismissTask,
|
||||
} = useSftpModalTransfers({
|
||||
currentPath,
|
||||
currentPathRef,
|
||||
isLocalSession,
|
||||
joinPath: joinPathForSession,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
readLocalFile,
|
||||
readSftp: readSftpWithEncoding,
|
||||
writeLocalFile,
|
||||
writeSftpBinaryWithProgress: writeSftpBinaryWithProgressWithEncoding,
|
||||
writeSftpBinary: writeSftpBinaryWithEncoding,
|
||||
writeSftp: writeSftpWithEncoding,
|
||||
mkdirLocal,
|
||||
mkdirSftp: mkdirSftpWithEncoding,
|
||||
cancelSftpUpload,
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
showSaveDialog,
|
||||
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();
|
||||
};
|
||||
|
||||
// Handle initial entries to upload (from drag-and-drop to terminal)
|
||||
const initialUploadTriggeredRef = useRef(false);
|
||||
const prevLoadingRef = useRef(loading);
|
||||
const prevEntriesRef = useRef<DropEntry[] | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
// Detect when loading transitions from true to false (initial load complete)
|
||||
const wasLoading = prevLoadingRef.current;
|
||||
prevLoadingRef.current = loading;
|
||||
const justFinishedLoading = wasLoading && !loading;
|
||||
|
||||
// Reset the flag when initialEntriesToUpload is cleared
|
||||
if (!initialEntriesToUpload || initialEntriesToUpload.length === 0) {
|
||||
initialUploadTriggeredRef.current = false;
|
||||
prevEntriesRef.current = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset the flag when new entries arrive (different reference = new drop)
|
||||
if (initialEntriesToUpload !== prevEntriesRef.current) {
|
||||
initialUploadTriggeredRef.current = false;
|
||||
prevEntriesRef.current = initialEntriesToUpload;
|
||||
}
|
||||
|
||||
// Prevent duplicate uploads
|
||||
if (initialUploadTriggeredRef.current) return;
|
||||
|
||||
// Wait for SFTP connection to be established
|
||||
// Trigger when: modal is open AND loading just finished (works for empty directories too)
|
||||
if (!open || loading) return;
|
||||
if (!justFinishedLoading) return;
|
||||
|
||||
initialUploadTriggeredRef.current = true;
|
||||
|
||||
// Trigger upload with full DropEntry data (preserves directory structure)
|
||||
void handleUploadEntries(initialEntriesToUpload);
|
||||
}, [handleUploadEntries, initialEntriesToUpload, loading, open]);
|
||||
|
||||
// Display files with parent entry (like SftpView)
|
||||
const displayFiles = useMemo(() => {
|
||||
// Filter hidden files using utility function
|
||||
const visibleFiles = filterHiddenFiles(files, sftpShowHiddenFiles);
|
||||
|
||||
// Check if we're at root
|
||||
const atRoot = isRootPathForSession(currentPath);
|
||||
if (atRoot) return visibleFiles;
|
||||
|
||||
// Add ".." parent directory entry at the top (only if not at root)
|
||||
const parentEntry: RemoteFile = {
|
||||
name: "..",
|
||||
type: "directory",
|
||||
size: "--",
|
||||
lastModified: undefined,
|
||||
};
|
||||
return [parentEntry, ...visibleFiles.filter((f) => f.name !== "..")];
|
||||
}, [files, currentPath, isRootPathForSession, sftpShowHiddenFiles]);
|
||||
|
||||
// Sorted files
|
||||
const sortedFiles = useMemo(() => {
|
||||
if (!displayFiles.length) return displayFiles;
|
||||
|
||||
// Keep ".." at the top, sort the rest
|
||||
const parentEntry = displayFiles.find((f) => f.name === "..");
|
||||
const otherFiles = displayFiles.filter((f) => f.name !== "..");
|
||||
|
||||
const sorted = [...otherFiles].sort((a, b) => {
|
||||
// Directories and symlinks pointing to directories come first
|
||||
const aIsDir = a.type === "directory" || (a.type === "symlink" && a.linkTarget === "directory");
|
||||
const bIsDir = b.type === "directory" || (b.type === "symlink" && b.linkTarget === "directory");
|
||||
if (aIsDir && !bIsDir) return -1;
|
||||
if (!aIsDir && bIsDir) return 1;
|
||||
|
||||
let cmp = 0;
|
||||
switch (sortField) {
|
||||
case "name":
|
||||
cmp = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case "size": {
|
||||
const sizeA =
|
||||
typeof a.size === "number"
|
||||
? a.size
|
||||
: parseInt(String(a.size), 10) || 0;
|
||||
const sizeB =
|
||||
typeof b.size === "number"
|
||||
? b.size
|
||||
: parseInt(String(b.size), 10) || 0;
|
||||
cmp = sizeA - sizeB;
|
||||
break;
|
||||
}
|
||||
case "modified": {
|
||||
const dateA = new Date(a.lastModified || 0).getTime();
|
||||
const dateB = new Date(b.lastModified || 0).getTime();
|
||||
cmp = dateA - dateB;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return sortOrder === "asc" ? cmp : -cmp;
|
||||
});
|
||||
|
||||
return parentEntry ? [parentEntry, ...sorted] : sorted;
|
||||
}, [displayFiles, sortField, sortOrder]);
|
||||
const hasFiles = files.length > 0;
|
||||
const hasDisplayFiles = sortedFiles.length > 0;
|
||||
const {
|
||||
fileListRef,
|
||||
handleFileListScroll,
|
||||
shouldVirtualize,
|
||||
totalHeight,
|
||||
visibleRows,
|
||||
} = useSftpModalVirtualList({ open, sortedFiles });
|
||||
|
||||
|
||||
const { handleFileClick, handleFileDoubleClick } = useSftpModalSelection({
|
||||
files,
|
||||
setSelectedFiles,
|
||||
currentPath,
|
||||
joinPath: joinPathForSession,
|
||||
onNavigate: handleNavigate,
|
||||
onOpenFile: handleOpenFile,
|
||||
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);
|
||||
if (!confirm(t("sftp.deleteConfirm.title", { count: fileNames.length }))) return;
|
||||
|
||||
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",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadSelected = async () => {
|
||||
if (selectedFiles.size === 0) return;
|
||||
for (const fileName of selectedFiles) {
|
||||
const file = files.find((f) => f.name === fileName);
|
||||
if (file && file.type === "file") {
|
||||
await handleDownload(file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<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}
|
||||
showEncoding={!isLocalSession}
|
||||
filenameEncoding={filenameEncoding}
|
||||
onFilenameEncodingChange={setFilenameEncoding}
|
||||
currentPath={currentPath}
|
||||
isEditingPath={isEditingPath}
|
||||
editingPathValue={editingPathValue}
|
||||
setEditingPathValue={setEditingPathValue}
|
||||
handlePathSubmit={handlePathSubmit}
|
||||
handlePathKeyDown={handlePathKeyDown}
|
||||
handlePathDoubleClick={handlePathDoubleClick}
|
||||
isAtRoot={isRootPathForSession(currentPath)}
|
||||
rootLabel={rootLabel}
|
||||
isRefreshing={loading || reconnecting}
|
||||
onUp={handleUp}
|
||||
onHome={() =>
|
||||
setCurrentPath((isLocalSession && localHomeRef.current) || rootPath)
|
||||
}
|
||||
onRefresh={() => loadFiles(currentPath, { force: true })}
|
||||
visibleBreadcrumbs={visibleBreadcrumbs}
|
||||
hiddenBreadcrumbs={hiddenBreadcrumbs}
|
||||
needsBreadcrumbTruncation={needsBreadcrumbTruncation}
|
||||
breadcrumbs={breadcrumbs}
|
||||
onBreadcrumbSelect={(index) => setCurrentPath(breadcrumbPathAtForIndex(index))}
|
||||
onRootSelect={() => setCurrentPath(rootPath)}
|
||||
inputRef={inputRef}
|
||||
folderInputRef={folderInputRef}
|
||||
pathInputRef={pathInputRef}
|
||||
uploading={uploading}
|
||||
onTriggerUpload={() => inputRef.current?.click()}
|
||||
onTriggerFolderUpload={() => folderInputRef.current?.click()}
|
||||
onCreateFolder={handleCreateFolder}
|
||||
onCreateFile={handleCreateFile}
|
||||
onFileSelect={handleFileSelect}
|
||||
onFolderSelect={handleFolderSelect}
|
||||
showHiddenFiles={sftpShowHiddenFiles}
|
||||
onToggleShowHiddenFiles={() =>
|
||||
setSftpShowHiddenFiles(!sftpShowHiddenFiles)
|
||||
}
|
||||
onUpdateHost={onUpdateHost}
|
||||
onNavigateToBookmark={(path) => setCurrentPath(path)}
|
||||
/>
|
||||
|
||||
<SftpModalFileList
|
||||
t={t}
|
||||
currentPath={currentPath}
|
||||
isLocalSession={isLocalSession}
|
||||
hasFiles={hasFiles}
|
||||
hasDisplayFiles={hasDisplayFiles}
|
||||
selectedFiles={selectedFiles}
|
||||
dragActive={dragActive}
|
||||
loading={loading}
|
||||
loadingTextContent={loadingTextContent}
|
||||
reconnecting={reconnecting}
|
||||
columnWidths={columnWidths}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
shouldVirtualize={shouldVirtualize}
|
||||
totalHeight={totalHeight}
|
||||
visibleRows={visibleRows}
|
||||
fileListRef={fileListRef}
|
||||
inputRef={inputRef}
|
||||
folderInputRef={folderInputRef}
|
||||
handleSort={handleSort}
|
||||
handleResizeStart={handleResizeStart}
|
||||
handleFileListScroll={handleFileListScroll}
|
||||
handleDrag={handleDrag}
|
||||
handleDrop={handleDrop}
|
||||
handleFileClick={handleFileClick}
|
||||
handleFileDoubleClick={handleFileDoubleClick}
|
||||
handleDownload={handleDownload}
|
||||
handleDelete={handleDelete}
|
||||
handleOpenFile={handleOpenFile}
|
||||
openFileOpenerDialog={openFileOpenerDialog}
|
||||
handleEditFile={handleEditFile}
|
||||
openRenameDialog={openRenameDialog}
|
||||
openPermissionsDialog={openPermissionsDialog}
|
||||
handleNavigate={handleNavigate}
|
||||
handleCreateFolder={handleCreateFolder}
|
||||
handleCreateFile={handleCreateFile}
|
||||
handleDownloadSelected={handleDownloadSelected}
|
||||
handleDeleteSelected={handleDeleteSelected}
|
||||
loadFiles={loadFiles}
|
||||
formatBytes={formatBytes}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
|
||||
<SftpModalUploadTasks tasks={uploadTasks} t={t} onCancel={cancelUpload} onCancelTask={cancelTask} onDismiss={dismissTask} />
|
||||
|
||||
<SftpModalFooter
|
||||
t={t}
|
||||
files={files}
|
||||
selectedFiles={selectedFiles}
|
||||
loading={loading}
|
||||
uploading={uploading}
|
||||
onDownloadSelected={handleDownloadSelected}
|
||||
onDeleteSelected={handleDeleteSelected}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SftpModalDialogs
|
||||
t={t}
|
||||
showRenameDialog={showRenameDialog}
|
||||
setShowRenameDialog={setShowRenameDialog}
|
||||
renameTarget={renameTarget}
|
||||
renameName={renameName}
|
||||
setRenameName={setRenameName}
|
||||
handleRename={handleRename}
|
||||
isRenaming={isRenaming}
|
||||
showPermissionsDialog={showPermissionsDialog}
|
||||
setShowPermissionsDialog={setShowPermissionsDialog}
|
||||
permissionsTarget={permissionsTarget}
|
||||
permissions={permissions}
|
||||
togglePermission={togglePermission}
|
||||
getOctalPermissions={getOctalPermissions}
|
||||
getSymbolicPermissions={getSymbolicPermissions}
|
||||
handleSavePermissions={handleSavePermissions}
|
||||
isChangingPermissions={isChangingPermissions}
|
||||
showCreateDialog={showCreateDialog}
|
||||
setShowCreateDialog={setShowCreateDialog}
|
||||
createType={createType}
|
||||
createName={createName}
|
||||
setCreateName={setCreateName}
|
||||
isCreating={isCreating}
|
||||
handleCreateSubmit={handleCreateSubmit}
|
||||
/>
|
||||
|
||||
{/* File Opener Dialog */}
|
||||
<FileOpenerDialog
|
||||
open={showFileOpenerDialog}
|
||||
onClose={() => {
|
||||
setShowFileOpenerDialog(false);
|
||||
setFileOpenerTarget(null);
|
||||
}}
|
||||
fileName={fileOpenerTarget?.name || ""}
|
||||
onSelect={handleFileOpenerSelect}
|
||||
onSelectSystemApp={handleSelectSystemApp}
|
||||
/>
|
||||
|
||||
{/* Text Editor Modal */}
|
||||
<TextEditorModal
|
||||
open={showTextEditor}
|
||||
onClose={() => {
|
||||
setShowTextEditor(false);
|
||||
setTextEditorTarget(null);
|
||||
setTextEditorContent("");
|
||||
}}
|
||||
fileName={textEditorTarget?.name || ""}
|
||||
initialContent={textEditorContent}
|
||||
onSave={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SFTPModal;
|
||||
@@ -35,7 +35,7 @@ interface SerialPort {
|
||||
interface SerialConnectModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConnect: (config: SerialConfig) => void;
|
||||
onConnect: (config: SerialConfig, options?: { charset?: string }) => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
const [flowControl, setFlowControl] = useState<SerialFlowControl>('none');
|
||||
const [localEcho, setLocalEcho] = useState(false);
|
||||
const [lineMode, setLineMode] = useState(false);
|
||||
const [charset, setCharset] = useState('UTF-8');
|
||||
|
||||
// Save configuration state
|
||||
const [saveConfig, setSaveConfig] = useState(false);
|
||||
@@ -131,12 +132,13 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
tags: ['serial'],
|
||||
protocol: 'serial',
|
||||
createdAt: Date.now(),
|
||||
charset,
|
||||
serialConfig: config, // Store full serial configuration for connection
|
||||
};
|
||||
onSaveHost(host);
|
||||
}
|
||||
|
||||
onConnect(config);
|
||||
onConnect(config, { charset });
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -164,7 +166,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogContent className="max-w-md max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Usb size={18} />
|
||||
@@ -175,7 +177,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-4 py-2 overflow-y-auto flex-1 min-h-0">
|
||||
{/* Serial Port Selection */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -368,6 +370,20 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charset */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="serial-charset" className="text-sm font-medium">
|
||||
{t('serial.field.charset')}
|
||||
</Label>
|
||||
<Input
|
||||
id="serial-charset"
|
||||
placeholder={t("hostDetails.charset.placeholder")}
|
||||
value={charset}
|
||||
onChange={(e) => setCharset(e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
@@ -66,6 +66,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
|
||||
const [flowControl, setFlowControl] = useState<SerialFlowControl>(initialData.serialConfig?.flowControl || 'none');
|
||||
const [localEcho, setLocalEcho] = useState(initialData.serialConfig?.localEcho || false);
|
||||
const [lineMode, setLineMode] = useState(initialData.serialConfig?.lineMode || false);
|
||||
const [charset, setCharset] = useState(initialData.charset || 'UTF-8');
|
||||
const [tags, setTags] = useState<string[]>(initialData.tags || []);
|
||||
const [group, setGroup] = useState(initialData.group || '');
|
||||
|
||||
@@ -107,6 +108,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
|
||||
port: baudRate,
|
||||
tags,
|
||||
group,
|
||||
charset,
|
||||
serialConfig: config,
|
||||
};
|
||||
|
||||
@@ -392,6 +394,20 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charset */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="serial-charset" className="text-sm font-medium">
|
||||
{t('serial.field.charset')}
|
||||
</Label>
|
||||
<Input
|
||||
id="serial-charset"
|
||||
placeholder={t("hostDetails.charset.placeholder")}
|
||||
value={charset}
|
||||
onChange={(e) => setCharset(e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
@@ -68,9 +68,11 @@ interface SettingsApplicationTabProps {
|
||||
checkNow: UseUpdateCheckResult['checkNow'];
|
||||
openReleasePage: UseUpdateCheckResult['openReleasePage'];
|
||||
installUpdate: UseUpdateCheckResult['installUpdate'];
|
||||
startDownload: UseUpdateCheckResult['startDownload'];
|
||||
isUpdateDemoMode: boolean;
|
||||
}
|
||||
|
||||
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate }: SettingsApplicationTabProps) {
|
||||
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate, startDownload, isUpdateDemoMode }: SettingsApplicationTabProps) {
|
||||
const { t } = useI18n();
|
||||
const { openExternal, getApplicationInfo } = useApplicationBackend();
|
||||
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
|
||||
@@ -94,10 +96,6 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
||||
};
|
||||
}, [getApplicationInfo]);
|
||||
|
||||
// Check if demo mode is enabled for development testing
|
||||
const isUpdateDemoMode = typeof window !== 'undefined' &&
|
||||
window.localStorage?.getItem('debug.updateDemo') === '1';
|
||||
|
||||
const handleCheckForUpdates = async () => {
|
||||
// In demo mode, allow checking even for dev builds
|
||||
if (!isUpdateDemoMode && (!appInfo.version || appInfo.version === '0.0.0')) {
|
||||
@@ -150,7 +148,7 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
||||
{/* Update badge - reflects auto-download state */}
|
||||
{updateState.latestRelease && (updateState.hasUpdate || updateState.autoDownloadStatus === 'downloading' || updateState.autoDownloadStatus === 'ready') && (
|
||||
<button
|
||||
onClick={() => updateState.autoDownloadStatus === 'ready' ? installUpdate() : void openReleasePage()}
|
||||
onClick={() => updateState.autoDownloadStatus === 'ready' ? installUpdate() : updateState.autoDownloadStatus === 'downloading' ? undefined : startDownload()}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
|
||||
updateState.autoDownloadStatus === 'ready'
|
||||
@@ -177,7 +175,7 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
||||
variant="secondary"
|
||||
className="gap-2"
|
||||
onClick={() => void handleCheckForUpdates()}
|
||||
disabled={updateState.isChecking}
|
||||
disabled={updateState.isChecking || updateState.manualCheckStatus === 'checking' || updateState.autoDownloadStatus === 'downloading' || updateState.autoDownloadStatus === 'ready'}
|
||||
>
|
||||
{updateState.isChecking ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
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 { useAvailableFonts } from "../application/state/fontStore";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import { useVaultState } from "../application/state/useVaultState";
|
||||
import { useWindowControls } from "../application/state/useWindowControls";
|
||||
@@ -19,7 +20,6 @@ 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);
|
||||
|
||||
@@ -45,12 +45,63 @@ class AITabErrorBoundary extends React.Component<
|
||||
}
|
||||
}
|
||||
|
||||
type SettingsState = ReturnType<typeof useSettingsState> & {
|
||||
availableFonts: TerminalFont[];
|
||||
};
|
||||
type SettingsState = ReturnType<typeof useSettingsState>;
|
||||
|
||||
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
|
||||
|
||||
const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ settings }) => {
|
||||
const availableFonts = useAvailableFonts();
|
||||
|
||||
return (
|
||||
<SettingsTerminalTab
|
||||
terminalThemeId={settings.terminalThemeId}
|
||||
setTerminalThemeId={settings.setTerminalThemeId}
|
||||
terminalFontFamilyId={settings.terminalFontFamilyId}
|
||||
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
|
||||
terminalFontSize={settings.terminalFontSize}
|
||||
setTerminalFontSize={settings.setTerminalFontSize}
|
||||
terminalSettings={settings.terminalSettings}
|
||||
updateTerminalSetting={settings.updateTerminalSetting}
|
||||
availableFonts={availableFonts}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsAITabContainer: React.FC = () => {
|
||||
const aiState = useAIState();
|
||||
|
||||
return (
|
||||
<AITabErrorBoundary>
|
||||
<React.Suspense fallback={<div className="flex-1 px-6 py-5 text-sm text-muted-foreground">Loading AI settings...</div>}>
|
||||
<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}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
setWebSearchConfig={aiState.setWebSearchConfig}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</AITabErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = ({ onSettingsApplied }) => {
|
||||
const {
|
||||
hosts,
|
||||
@@ -98,10 +149,13 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
|
||||
const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }) => {
|
||||
const { t } = useI18n();
|
||||
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
|
||||
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
|
||||
const aiState = useAIState();
|
||||
const { updateState, checkNow, installUpdate, openReleasePage, startDownload, isUpdateDemoMode } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
|
||||
const [activeTab, setActiveTab] = useState("application");
|
||||
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
|
||||
const isImmersive = settings.immersiveMode;
|
||||
const toggleImmersive = useCallback(() => {
|
||||
settings.setImmersiveMode(!isImmersive);
|
||||
}, [settings, isImmersive]);
|
||||
|
||||
useEffect(() => {
|
||||
notifyRendererReady();
|
||||
@@ -206,6 +260,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
checkNow={checkNow}
|
||||
openReleasePage={openReleasePage}
|
||||
installUpdate={installUpdate}
|
||||
startDownload={startDownload}
|
||||
isUpdateDemoMode={isUpdateDemoMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -227,21 +283,13 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setUiLanguage={settings.setUiLanguage}
|
||||
customCSS={settings.customCSS}
|
||||
setCustomCSS={settings.setCustomCSS}
|
||||
isImmersive={isImmersive}
|
||||
onToggleImmersive={toggleImmersive}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("terminal") && (
|
||||
<SettingsTerminalTab
|
||||
terminalThemeId={settings.terminalThemeId}
|
||||
setTerminalThemeId={settings.setTerminalThemeId}
|
||||
terminalFontFamilyId={settings.terminalFontFamilyId}
|
||||
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
|
||||
terminalFontSize={settings.terminalFontSize}
|
||||
setTerminalFontSize={settings.setTerminalFontSize}
|
||||
terminalSettings={settings.terminalSettings}
|
||||
updateTerminalSetting={settings.updateTerminalSetting}
|
||||
availableFonts={settings.availableFonts}
|
||||
/>
|
||||
<SettingsTerminalTabContainer settings={settings} />
|
||||
)}
|
||||
|
||||
{mountedTabs.has("shortcuts") && (
|
||||
@@ -261,34 +309,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
)}
|
||||
|
||||
{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}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
setWebSearchConfig={aiState.setWebSearchConfig}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</AITabErrorBoundary>
|
||||
<SettingsAITabContainer />
|
||||
)}
|
||||
|
||||
{mountedTabs.has("sync") && (
|
||||
@@ -318,6 +339,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
checkNow={checkNow}
|
||||
installUpdate={installUpdate}
|
||||
openReleasePage={openReleasePage}
|
||||
startDownload={startDownload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { formatHostPort } from "../domain/host";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
import { useSftpBackend } from "../application/state/useSftpBackend";
|
||||
@@ -101,7 +102,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
|
||||
const { showSaveDialog, startStreamTransfer } = useSftpBackend();
|
||||
const {
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
startStreamTransfer,
|
||||
listSftp,
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
} = useSftpBackend();
|
||||
|
||||
const sftpRef = useRef(sftp);
|
||||
sftpRef.current = sftp;
|
||||
@@ -153,7 +161,11 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
listSftp,
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
||||
});
|
||||
@@ -183,17 +195,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
// 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]);
|
||||
// NOTE: We intentionally do NOT reset lastAppliedInitialLocationKeyRef on
|
||||
// visibility changes. When the user switches terminal tabs, the panel
|
||||
// toggles isVisible but should preserve its navigation state (the user may
|
||||
// have navigated away from initialLocation). When the panel is truly
|
||||
// closed, the component unmounts and all refs are naturally reset.
|
||||
|
||||
// Navigate SFTP to the terminal's current working directory
|
||||
const handleGoToTerminalCwd = useCallback(async () => {
|
||||
@@ -206,14 +213,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
// 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
|
||||
// Block host-following while any connection-sensitive interactive UI is
|
||||
// active: text editor, permissions dialog, file-opener dialog, or
|
||||
// auto-synced external file watches.
|
||||
const hasActiveWork = hasActiveTransfers || showTextEditor || !!permissionsState || showFileOpenerDialog
|
||||
// Note: transfers are NOT included here — they run on their own sftpId
|
||||
// independent of the active tab, and forceNewTab preserves old connections.
|
||||
const hasActiveWork = showTextEditor || !!permissionsState || showFileOpenerDialog
|
||||
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -298,28 +303,24 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
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.
|
||||
// Create a new tab when there's already an active connection, so the
|
||||
// previous tab is preserved for instant switching on focus change.
|
||||
// This covers both different hosts AND same host with different
|
||||
// session-time overrides (port/protocol), preventing the old SFTP
|
||||
// session from being closed while it may have in-flight transfers.
|
||||
const currentConn = s.leftPane.connection;
|
||||
const needsNewTab = !!(currentConn && currentConn.status === "connected" && currentConn.hostId !== activeHost.id);
|
||||
const needsNewTab = !!(currentConn && currentConn.status === "connected");
|
||||
|
||||
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);
|
||||
s.connect("left", activeHost, {
|
||||
...(needsNewTab ? { forceNewTab: true } : undefined),
|
||||
onTabCreated: (tabId) => {
|
||||
tabConnectionKeyMapRef.current.set(tabId, connectionKey);
|
||||
},
|
||||
});
|
||||
}, [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,
|
||||
@@ -425,10 +426,19 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
]);
|
||||
|
||||
const MAX_VISIBLE_TRANSFERS = 5;
|
||||
const visibleTransfers = useMemo(
|
||||
() => [...sftp.transfers].reverse().slice(0, MAX_VISIBLE_TRANSFERS),
|
||||
[sftp.transfers],
|
||||
);
|
||||
const visibleTransfers = useMemo(() => {
|
||||
const connection = sftp.leftPane.connection;
|
||||
if (!connection) return [];
|
||||
// Filter transfers to those relevant to the active connection's host,
|
||||
// so workspace focus switches don't show transfers from other hosts.
|
||||
const filtered = sftp.transfers.filter((t) => {
|
||||
if (connection.isLocal) {
|
||||
return t.sourceConnectionId === connection.id || t.targetConnectionId === connection.id;
|
||||
}
|
||||
return t.targetHostId === connection.hostId || t.sourceConnectionId === connection.id || t.targetConnectionId === connection.id;
|
||||
});
|
||||
return [...filtered].reverse().slice(0, MAX_VISIBLE_TRANSFERS);
|
||||
}, [sftp.transfers, sftp.leftPane.connection]);
|
||||
|
||||
const handleRevealTransferTarget = useCallback(
|
||||
async (task: TransferTask) => {
|
||||
@@ -509,7 +519,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
/>
|
||||
<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}`}
|
||||
title={`${displayHost.label} · ${(displayHost.username || "root")}@${formatHostPort(displayHost.hostname, displayHost.port || 22)}`}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{displayHost.label}
|
||||
|
||||
@@ -19,10 +19,11 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useIsSftpActive } from "../application/state/activeTabStore";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
import { useSftpBackend } from "../application/state/useSftpBackend";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { HotkeyScheme, KeyBinding } from "../domain/models";
|
||||
import { logger } from "../lib/logger";
|
||||
import { useRenderTracker } from "../lib/useRenderTracker";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
|
||||
import { Host, Identity, SSHKey } from "../types";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
import { toast } from "./ui/toast";
|
||||
@@ -49,21 +50,35 @@ interface SftpViewProps {
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
sftpDoubleClickBehavior: "open" | "transfer";
|
||||
sftpAutoSync: boolean;
|
||||
sftpShowHiddenFiles: boolean;
|
||||
sftpUseCompressedUpload: boolean;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
editorWordWrap: boolean;
|
||||
setEditorWordWrap: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updateHosts }) => {
|
||||
const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
updateHosts,
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isActive = useIsSftpActive();
|
||||
const {
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
} = useSettingsState();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useInstantThemeSwitch(rootRef);
|
||||
|
||||
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
|
||||
const fileWatchHandlers = useMemo(() => ({
|
||||
@@ -86,8 +101,15 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
|
||||
|
||||
// Get stream transfer functions for optimized downloads
|
||||
const { showSaveDialog, startStreamTransfer } = useSftpBackend();
|
||||
// Get backend helpers for file downloads and local filesystem writes.
|
||||
const {
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
startStreamTransfer,
|
||||
listSftp,
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
} = useSftpBackend();
|
||||
|
||||
// Store sftp in a ref so callbacks can access the latest instance
|
||||
// without needing to re-create when sftp changes
|
||||
@@ -176,7 +198,11 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
listSftp,
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
||||
});
|
||||
@@ -235,6 +261,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
rightCallbacks={rightCallbacks}
|
||||
>
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={cn(
|
||||
"absolute inset-0 min-h-0 flex flex-col",
|
||||
isActive ? "z-20" : "",
|
||||
@@ -397,7 +424,17 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
};
|
||||
|
||||
const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
|
||||
prev.hosts === next.hosts && prev.keys === next.keys && prev.identities === next.identities;
|
||||
prev.hosts === next.hosts &&
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
|
||||
prev.sftpAutoSync === next.sftpAutoSync &&
|
||||
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
|
||||
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
|
||||
prev.hotkeyScheme === next.hotkeyScheme &&
|
||||
prev.keyBindings === next.keyBindings &&
|
||||
prev.editorWordWrap === next.editorWordWrap &&
|
||||
prev.setEditorWordWrap === next.setEditorWordWrap;
|
||||
|
||||
export const SftpView = memo(SftpViewInner, sftpViewAreEqual);
|
||||
SftpView.displayName = "SftpView";
|
||||
|
||||
@@ -439,8 +439,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
const name = newPackageName.trim();
|
||||
if (!name) return;
|
||||
|
||||
// Allow leading slash and validate the rest - allow hyphens anywhere in package names
|
||||
if (!/^\/?([\w-]+(\/[\w-]+)*)\/?$/.test(name)) {
|
||||
// Allow leading slash and validate the rest - allow hyphens and Unicode letters/numbers
|
||||
if (!/^\/?([\w\p{L}\p{N}-]+(\/[\w\p{L}\p{N}-]+)*)\/?$/u.test(name)) {
|
||||
// Could add toast notification here for invalid characters
|
||||
return;
|
||||
}
|
||||
@@ -550,9 +550,9 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate: same rules as createPackage - only allow letters, numbers, hyphens, underscores
|
||||
// Validate: same rules as createPackage - allow Unicode letters, numbers, hyphens, underscores
|
||||
// Since we're renaming a single segment (no slashes allowed), use the segment-level pattern
|
||||
if (!/^[\w-]+$/.test(newName)) {
|
||||
if (!/^[\w\p{L}\p{N}-]+$/u.test(newName)) {
|
||||
setRenameError(t('snippets.renameDialog.error.invalidChars'));
|
||||
return;
|
||||
}
|
||||
@@ -1203,7 +1203,6 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
value={newPackageName}
|
||||
onChange={(e) => setNewPackageName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && createPackage()}
|
||||
pattern="^/?([\w-]+(/[\w-]+)*)?/?$"
|
||||
title="Package names can contain letters, numbers, hyphens, underscores, and forward slashes. Can optionally start with /"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">{t('snippets.packageDialog.hint')}</p>
|
||||
|
||||
@@ -4,11 +4,11 @@ import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
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, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
// flushSync removed - no longer needed
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn } from "../lib/utils";
|
||||
import { cn, normalizeLineEndings, wrapBracketedPaste } from "../lib/utils";
|
||||
import {
|
||||
Host,
|
||||
Identity,
|
||||
@@ -25,6 +25,9 @@ import {
|
||||
shouldEnableNativeUserInputAutoScroll,
|
||||
shouldScrollOnTerminalInput,
|
||||
} from "../domain/terminalScroll";
|
||||
import {
|
||||
resolveHostTerminalThemeId,
|
||||
} from "../domain/terminalAppearance";
|
||||
import { resolveHostAuth } from "../domain/sshAuth";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
|
||||
@@ -49,6 +52,7 @@ import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextAc
|
||||
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
|
||||
import { useServerStats } from "./terminal/hooks/useServerStats";
|
||||
import { extractDropEntries, getPathForFile, DropEntry } from "../lib/sftpFileUtils";
|
||||
import { useTerminalAutocomplete, AutocompletePopup } from "./terminal/autocomplete";
|
||||
|
||||
/**
|
||||
* Extract unique root paths from drop entries for local terminal path insertion.
|
||||
@@ -105,7 +109,8 @@ interface TerminalProps {
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
snippets: Snippet[];
|
||||
allHosts?: Host[];
|
||||
chainHosts?: Host[];
|
||||
themePreviewId?: string;
|
||||
knownHosts?: KnownHost[];
|
||||
isVisible: boolean;
|
||||
inWorkspace?: boolean;
|
||||
@@ -152,6 +157,12 @@ interface TerminalProps {
|
||||
onToggleComposeBar?: () => void;
|
||||
isWorkspaceComposeBarOpen?: boolean;
|
||||
onBroadcastInput?: (data: string, sourceSessionId: string) => void;
|
||||
onSnippetExecutorChange?: (
|
||||
sessionId: string,
|
||||
executor: ((command: string, noAutoRun?: boolean) => void) | null,
|
||||
) => void;
|
||||
// Session log configuration for real-time streaming
|
||||
sessionLog?: { enabled: boolean; directory: string; format: string };
|
||||
}
|
||||
|
||||
// Helper function to format network speed (bytes/sec) to human-readable format
|
||||
@@ -172,7 +183,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
keys,
|
||||
identities,
|
||||
snippets,
|
||||
allHosts = [],
|
||||
chainHosts = [],
|
||||
themePreviewId,
|
||||
knownHosts: _knownHosts = [],
|
||||
isVisible,
|
||||
inWorkspace,
|
||||
@@ -209,6 +221,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onToggleComposeBar,
|
||||
isWorkspaceComposeBarOpen,
|
||||
onBroadcastInput,
|
||||
onSnippetExecutorChange,
|
||||
sessionLog,
|
||||
}) => {
|
||||
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
|
||||
const CONNECTION_TIMEOUT = 120000;
|
||||
@@ -220,11 +234,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const serializeAddonRef = useRef<SerializeAddon | null>(null);
|
||||
const searchAddonRef = useRef<SearchAddon | null>(null);
|
||||
const xtermRuntimeRef = useRef<XTermRuntime | null>(null);
|
||||
const knownCwdRef = useRef<string | undefined>(undefined);
|
||||
const disposeDataRef = useRef<(() => void) | null>(null);
|
||||
const disposeExitRef = useRef<(() => void) | null>(null);
|
||||
const sessionRef = useRef<string | null>(null);
|
||||
const hasConnectedRef = useRef(false);
|
||||
const hasRunStartupCommandRef = useRef(false);
|
||||
const terminalDataCapturedRef = useRef(false);
|
||||
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
|
||||
const commandBufferRef = useRef<string>("");
|
||||
const [hasMouseTracking, setHasMouseTracking] = useState(false);
|
||||
const mouseTrackingRef = useRef(false);
|
||||
@@ -232,6 +249,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const terminalSettingsRef = useRef(terminalSettings);
|
||||
terminalSettingsRef.current = terminalSettings;
|
||||
onTerminalDataCaptureRef.current = onTerminalDataCapture;
|
||||
const isVisibleRef = useRef(isVisible);
|
||||
isVisibleRef.current = isVisible;
|
||||
const pendingOutputScrollRef = useRef(false);
|
||||
@@ -283,6 +301,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const snippetsRef = useRef(snippets);
|
||||
snippetsRef.current = snippets;
|
||||
|
||||
// Autocomplete handler refs (set after hook initialization)
|
||||
const autocompleteKeyEventRef = useRef<((e: KeyboardEvent) => boolean) | undefined>(undefined);
|
||||
const autocompleteInputRef = useRef<((data: string) => void) | undefined>(undefined);
|
||||
const autocompleteRepositionRef = useRef<(() => void) | undefined>(undefined);
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const { resizeSession, setSessionEncoding } = terminalBackend;
|
||||
|
||||
@@ -299,6 +322,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [showSFTP, setShowSFTP] = useState(false);
|
||||
const [progressValue, setProgressValue] = useState(15);
|
||||
const [hasSelection, setHasSelection] = useState(false);
|
||||
const [isDisconnectedDialogDismissed, setIsDisconnectedDialogDismissed] = useState(false);
|
||||
|
||||
const statusRef = useRef<TerminalSession["status"]>(status);
|
||||
statusRef.current = status;
|
||||
@@ -333,17 +357,147 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
handleCloseSearch,
|
||||
} = terminalSearch;
|
||||
|
||||
// Terminal autocomplete — onAcceptText writes directly to session (no CustomEvent)
|
||||
const autocompleteAcceptTextRef = useRef<((text: string) => void) | undefined>(undefined);
|
||||
autocompleteAcceptTextRef.current = (text: string) => {
|
||||
const id = sessionRef.current;
|
||||
if (id && text) {
|
||||
// Serial line mode: buffer text and handle local echo instead of direct send
|
||||
if (host.protocol === "serial" && serialConfig?.lineMode) {
|
||||
for (const ch of text) {
|
||||
if (ch === "\r") {
|
||||
const line = serialLineBufferRef.current + "\r";
|
||||
terminalBackend.writeToSession(id, line);
|
||||
serialLineBufferRef.current = "";
|
||||
if (serialConfig?.localEcho) termRef.current?.write("\r\n");
|
||||
} else if (ch === "\x15") {
|
||||
if (serialConfig?.localEcho && serialLineBufferRef.current.length > 0) {
|
||||
termRef.current?.write("\b \b".repeat(serialLineBufferRef.current.length));
|
||||
}
|
||||
serialLineBufferRef.current = "";
|
||||
} else if (ch === "\b" || ch === "\x7f") {
|
||||
if (serialLineBufferRef.current.length > 0) {
|
||||
serialLineBufferRef.current = serialLineBufferRef.current.slice(0, -1);
|
||||
if (serialConfig?.localEcho) termRef.current?.write("\b \b");
|
||||
}
|
||||
} else if (ch.charCodeAt(0) >= 32) {
|
||||
serialLineBufferRef.current += ch;
|
||||
if (serialConfig?.localEcho) termRef.current?.write(ch);
|
||||
}
|
||||
}
|
||||
// Still update commandBuffer and broadcast for serial line mode
|
||||
// (fall through to shared bookkeeping below — don't return early)
|
||||
} else if (host.protocol === "serial" && serialConfig?.localEcho) {
|
||||
// Serial character mode with local echo: echo accepted text locally
|
||||
terminalBackend.writeToSession(id, text);
|
||||
for (const ch of text) {
|
||||
if (ch === "\r") {
|
||||
termRef.current?.write("\r\n");
|
||||
} else if (ch.charCodeAt(0) >= 32) {
|
||||
termRef.current?.write(ch);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
terminalBackend.writeToSession(id, text);
|
||||
}
|
||||
|
||||
// Broadcast to other sessions if broadcast mode is enabled
|
||||
if (isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
|
||||
onBroadcastInputRef.current(text, sessionId);
|
||||
}
|
||||
|
||||
// Update command buffer for onCommandExecuted tracking
|
||||
for (const ch of text) {
|
||||
if (ch === "\r" || ch === "\n") {
|
||||
const cmd = commandBufferRef.current.trim();
|
||||
if (cmd && onCommandExecuted) onCommandExecuted(cmd, host.id, host.label, sessionId);
|
||||
commandBufferRef.current = "";
|
||||
} else if (ch === "\x15") {
|
||||
// Ctrl+U: clear line — reset command buffer (fuzzy match sends this)
|
||||
commandBufferRef.current = "";
|
||||
} else if (ch === "\b" || ch === "\x7f") {
|
||||
// Backspace: remove last character (Windows fuzzy replacement uses \b)
|
||||
commandBufferRef.current = commandBufferRef.current.slice(0, -1);
|
||||
} else if (ch.charCodeAt(0) >= 32) {
|
||||
commandBufferRef.current += ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const autocomplete = useTerminalAutocomplete({
|
||||
termRef,
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostOs: host.os || (host.protocol === "local"
|
||||
? (navigator.platform?.startsWith("Win") ? "windows" : navigator.platform?.startsWith("Mac") ? "macos" : "linux")
|
||||
: "linux"),
|
||||
settings: terminalSettings ? {
|
||||
enabled: terminalSettings.autocompleteEnabled ?? true,
|
||||
showGhostText: terminalSettings.autocompleteGhostText ?? true,
|
||||
showPopupMenu: terminalSettings.autocompletePopupMenu ?? true,
|
||||
debounceMs: terminalSettings.autocompleteDebounceMs ?? 100,
|
||||
minChars: terminalSettings.autocompleteMinChars ?? 1,
|
||||
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 8,
|
||||
} : undefined,
|
||||
onAcceptText: (text) => autocompleteAcceptTextRef.current?.(text),
|
||||
protocol: host.protocol,
|
||||
getCwd: () => knownCwdRef.current ?? xtermRuntimeRef.current?.currentCwd,
|
||||
});
|
||||
|
||||
// Wire up autocomplete handler refs so createXTermRuntime can use them
|
||||
autocompleteKeyEventRef.current = autocomplete.handleKeyEvent;
|
||||
autocompleteInputRef.current = autocomplete.handleInput;
|
||||
autocompleteRepositionRef.current = autocomplete.repositionPopup;
|
||||
const autocompleteClosePopup = autocomplete.closePopup;
|
||||
|
||||
useEffect(() => {
|
||||
knownCwdRef.current = undefined;
|
||||
}, [sessionId, host.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (host.protocol === "local" || host.protocol === "serial" || host.protocol === "telnet") {
|
||||
return;
|
||||
}
|
||||
if (status !== "connected" || !sessionRef.current || knownCwdRef.current) return;
|
||||
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
if (!sessionRef.current) return;
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
if (!cancelled && result.success && result.cwd) {
|
||||
knownCwdRef.current = result.cwd;
|
||||
}
|
||||
} catch {
|
||||
// Best effort only.
|
||||
}
|
||||
}, 150);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [host.protocol, status, terminalBackend]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
autocompleteClosePopup();
|
||||
}
|
||||
}, [isVisible, autocompleteClosePopup]);
|
||||
|
||||
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
|
||||
const isLocalConnection = host.protocol === "local";
|
||||
const isSerialConnection = host.protocol === "serial";
|
||||
|
||||
// Server stats (CPU, Memory, Disk) for Linux servers
|
||||
// Server stats (CPU, Memory, Disk) — only for Linux/macOS
|
||||
const { stats: serverStats } = useServerStats({
|
||||
sessionId,
|
||||
enabled: terminalSettings?.showServerStats ?? true,
|
||||
refreshInterval: terminalSettings?.serverStatsRefreshInterval ?? 5,
|
||||
isLinux: host.os === 'linux',
|
||||
isSupportedOs: host.os === 'linux' || host.os === 'macos',
|
||||
isConnected: status === 'connected',
|
||||
isVisible,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -397,26 +551,47 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
const hasFontSizeOverride = host.fontSizeOverride === true || (host.fontSizeOverride === undefined && host.fontSize != null);
|
||||
const hasFontFamilyOverride = host.fontFamilyOverride === true || (host.fontFamilyOverride === undefined && !!host.fontFamily);
|
||||
const effectiveFontSize = useMemo(
|
||||
() => (hasFontSizeOverride && host.fontSize != null ? host.fontSize : fontSize),
|
||||
[fontSize, hasFontSizeOverride, host.fontSize],
|
||||
);
|
||||
const resolvedFontFamily = useMemo(() => {
|
||||
const hostFontId = hasFontFamilyOverride && host.fontFamily
|
||||
? host.fontFamily
|
||||
: fontFamilyId;
|
||||
const resolvedFontId = hostFontId || "menlo";
|
||||
return (availableFonts.find((f) => f.id === resolvedFontId) || availableFonts[0]).family;
|
||||
}, [availableFonts, fontFamilyId, hasFontFamilyOverride, host.fontFamily]);
|
||||
|
||||
const effectiveTheme = useMemo(() => {
|
||||
if (host.theme) {
|
||||
const hostTheme = TERMINAL_THEMES.find((t) => t.id === host.theme)
|
||||
|| customThemes.find((t) => t.id === host.theme);
|
||||
const themeId = themePreviewId ?? resolveHostTerminalThemeId(
|
||||
{ theme: host.theme, themeOverride: host.themeOverride } as Pick<Host, 'theme' | 'themeOverride'>,
|
||||
terminalTheme.id,
|
||||
);
|
||||
if (themeId) {
|
||||
const hostTheme = TERMINAL_THEMES.find((t) => t.id === themeId)
|
||||
|| customThemes.find((t) => t.id === themeId);
|
||||
if (hostTheme) return hostTheme;
|
||||
}
|
||||
return terminalTheme;
|
||||
}, [host.theme, terminalTheme, customThemes]);
|
||||
}, [customThemes, host.theme, host.themeOverride, terminalTheme, themePreviewId]);
|
||||
|
||||
const resolvedChainHosts =
|
||||
(host.hostChain?.hostIds
|
||||
?.map((id) => allHosts.find((h) => h.id === id))
|
||||
.filter(Boolean) as Host[]) || [];
|
||||
chainHosts;
|
||||
|
||||
const updateStatus = (next: TerminalSession["status"]) => {
|
||||
setStatus(next);
|
||||
hasConnectedRef.current = next === "connected";
|
||||
onStatusChange?.(sessionId, next);
|
||||
};
|
||||
const handleTerminalDataCaptureOnce = useCallback((capturedSessionId: string, data: string) => {
|
||||
const captureHandler = onTerminalDataCaptureRef.current;
|
||||
if (!captureHandler || terminalDataCapturedRef.current) return;
|
||||
terminalDataCapturedRef.current = true;
|
||||
captureHandler(capturedSessionId, data);
|
||||
}, []);
|
||||
|
||||
const cleanupSession = () => {
|
||||
disposeDataRef.current?.();
|
||||
@@ -484,20 +659,23 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
},
|
||||
onSessionExit,
|
||||
onTerminalDataCapture,
|
||||
onTerminalDataCapture: handleTerminalDataCaptureOnce,
|
||||
onOsDetected,
|
||||
onCommandExecuted,
|
||||
sessionLog,
|
||||
});
|
||||
sessionStartersRef.current = sessionStarters;
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
terminalDataCapturedRef.current = false;
|
||||
setError(null);
|
||||
hasConnectedRef.current = false;
|
||||
pendingOutputScrollRef.current = false;
|
||||
setProgressLogs([]);
|
||||
setShowLogs(false);
|
||||
setIsCancelling(false);
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
|
||||
const boot = async () => {
|
||||
try {
|
||||
@@ -527,7 +705,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
serialLocalEcho: serialConfig?.localEcho,
|
||||
serialLineMode: serialConfig?.lineMode,
|
||||
serialLineBufferRef,
|
||||
onCwdChange: (cwd: string) => {
|
||||
knownCwdRef.current = cwd;
|
||||
},
|
||||
onOsc52ReadRequest: handleOsc52ReadRequest,
|
||||
// Autocomplete integration
|
||||
onAutocompleteKeyEvent: (e: KeyboardEvent) => autocompleteKeyEventRef.current?.(e) ?? true,
|
||||
onAutocompleteInput: (data: string) => autocompleteInputRef.current?.(data),
|
||||
});
|
||||
|
||||
xtermRuntimeRef.current = runtime;
|
||||
@@ -602,11 +786,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (onTerminalDataCapture && serializeAddonRef.current) {
|
||||
if (!terminalDataCapturedRef.current && serializeAddonRef.current) {
|
||||
try {
|
||||
const terminalData = serializeAddonRef.current.serialize();
|
||||
logger.info("[Terminal] Capturing data on unmount", { sessionId, dataLength: terminalData.length });
|
||||
onTerminalDataCapture(sessionId, terminalData);
|
||||
handleTerminalDataCaptureOnce(sessionId, terminalData);
|
||||
} catch (err) {
|
||||
logger.warn("Failed to serialize terminal data on unmount:", err);
|
||||
}
|
||||
@@ -614,7 +798,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
teardown();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Effect only runs on host.id/sessionId change, internal functions are stable
|
||||
}, [host.id, sessionId]);
|
||||
}, [handleTerminalDataCaptureOnce, host.id, sessionId]);
|
||||
|
||||
// Connection timeline and timeout visuals
|
||||
useEffect(() => {
|
||||
@@ -623,28 +807,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Local terminal and serial connections don't need timeout/progress UI
|
||||
if (isLocalConnection || isSerialConnection) return;
|
||||
|
||||
// Only show SSH-specific scripted logs for SSH connections
|
||||
const isSSH = host.protocol !== "telnet";
|
||||
|
||||
let stepTimer: ReturnType<typeof setInterval> | undefined;
|
||||
if (isSSH) {
|
||||
const scripted = [
|
||||
"Resolving host and keys...",
|
||||
"Negotiating ciphers...",
|
||||
"Exchanging keys...",
|
||||
"Authenticating user...",
|
||||
"Waiting for server greeting...",
|
||||
];
|
||||
let idx = 0;
|
||||
stepTimer = setInterval(() => {
|
||||
setProgressLogs((prev) => {
|
||||
if (idx >= scripted.length) return prev;
|
||||
const next = scripted[idx++];
|
||||
return prev.includes(next) ? prev : [...prev, next];
|
||||
});
|
||||
}, 900);
|
||||
}
|
||||
|
||||
setTimeLeft(CONNECTION_TIMEOUT / 1000);
|
||||
const countdown = setInterval(() => {
|
||||
setTimeLeft((prev) => (prev > 0 ? prev - 1 : 0));
|
||||
@@ -667,7 +829,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, 200);
|
||||
|
||||
return () => {
|
||||
if (stepTimer) clearInterval(stepTimer);
|
||||
clearInterval(countdown);
|
||||
clearTimeout(timeout);
|
||||
clearInterval(prog);
|
||||
@@ -675,6 +836,12 @@ 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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "connecting") {
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const safeFit = (options?: { force?: boolean; requireVisible?: boolean }) => {
|
||||
const fitAddon = fitAddonRef.current;
|
||||
if (!fitAddon) return;
|
||||
@@ -696,6 +863,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (!options?.force) {
|
||||
const lastSize = lastFittedSizeRef.current;
|
||||
if (lastSize && lastSize.width === width && lastSize.height === height) {
|
||||
autocompleteRepositionRef.current?.();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -704,6 +872,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
try {
|
||||
lastFittedSizeRef.current = { width, height };
|
||||
fitAddon.fit();
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => {
|
||||
autocompleteRepositionRef.current?.();
|
||||
});
|
||||
} else {
|
||||
autocompleteRepositionRef.current?.();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("Fit failed", err);
|
||||
}
|
||||
@@ -719,15 +894,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Sync xterm theme before browser paint so canvas + DOM CSS vars update in the same frame
|
||||
useLayoutEffect(() => {
|
||||
if (termRef.current) {
|
||||
const effectiveFontSize = host.fontSize || fontSize;
|
||||
termRef.current.options.fontSize = effectiveFontSize;
|
||||
|
||||
termRef.current.options.theme = {
|
||||
...effectiveTheme.colors,
|
||||
selectionBackground: effectiveTheme.colors.selection,
|
||||
};
|
||||
}
|
||||
}, [effectiveTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (termRef.current) {
|
||||
termRef.current.options.fontSize = effectiveFontSize;
|
||||
termRef.current.options.fontFamily = resolvedFontFamily;
|
||||
|
||||
if (terminalSettings) {
|
||||
termRef.current.options.cursorStyle = terminalSettings.cursorShape;
|
||||
@@ -769,6 +949,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalSettings.drawBoldInBrightColors;
|
||||
termRef.current.options.minimumContrastRatio =
|
||||
terminalSettings.minimumContrastRatio;
|
||||
termRef.current.options.smoothScrollDuration =
|
||||
terminalSettings.smoothScrolling
|
||||
? XTERM_PERFORMANCE_CONFIG.rendering.smoothScrollDuration
|
||||
: 0;
|
||||
termRef.current.options.scrollOnUserInput =
|
||||
shouldEnableNativeUserInputAutoScroll(terminalSettings);
|
||||
termRef.current.options.altClickMovesCursor = !terminalSettings.altAsMeta;
|
||||
@@ -776,27 +960,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
|
||||
}
|
||||
|
||||
setTimeout(() => safeFit({ force: true }), 50);
|
||||
if (isVisibleRef.current) {
|
||||
setTimeout(() => safeFit({ force: true, requireVisible: true }), 50);
|
||||
} else {
|
||||
lastFittedSizeRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [fontSize, effectiveTheme, terminalSettings, host.fontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (termRef.current) {
|
||||
const effectiveFontSize = host.fontSize || fontSize;
|
||||
termRef.current.options.fontSize = effectiveFontSize;
|
||||
|
||||
const hostFontId = host.fontFamily || fontFamilyId || "menlo";
|
||||
const fontObj = availableFonts.find((f) => f.id === hostFontId) || availableFonts[0];
|
||||
termRef.current.options.fontFamily = fontObj.family;
|
||||
|
||||
termRef.current.options.theme = {
|
||||
...effectiveTheme.colors,
|
||||
selectionBackground: effectiveTheme.colors.selection,
|
||||
};
|
||||
|
||||
setTimeout(() => safeFit({ force: true }), 50);
|
||||
}
|
||||
}, [host.fontSize, host.fontFamily, host.theme, fontFamilyId, fontSize, effectiveTheme, availableFonts]);
|
||||
}, [effectiveFontSize, resolvedFontFamily, terminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
@@ -844,7 +1014,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
if (terminalSettings && termRef.current) {
|
||||
const fontFamily = termRef.current.options?.fontFamily || "";
|
||||
const effectiveFontSize = host.fontSize || fontSize;
|
||||
if (typeof document !== "undefined" && document.fonts?.check) {
|
||||
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
|
||||
const resolvedBold = document.fonts.check(weightSpec)
|
||||
@@ -880,7 +1049,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [host.id, host.fontFamily, host.fontSize, fontFamilyId, fontSize, resizeSession, sessionId, terminalSettings]);
|
||||
}, [effectiveFontSize, resizeSession, terminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !containerRef.current || !fitAddonRef.current) return;
|
||||
@@ -1018,6 +1187,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const handler = () => {
|
||||
@@ -1035,7 +1206,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (resizeTimeout) clearTimeout(resizeTimeout);
|
||||
window.removeEventListener("resize", handler);
|
||||
};
|
||||
}, []);
|
||||
}, [isVisible]);
|
||||
|
||||
const disableBracketedPasteRef = useRef(terminalSettings?.disableBracketedPaste ?? false);
|
||||
disableBracketedPasteRef.current = terminalSettings?.disableBracketedPaste ?? false;
|
||||
@@ -1043,11 +1214,43 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const scrollOnPasteRef = useRef(terminalSettings?.scrollOnPaste ?? true);
|
||||
scrollOnPasteRef.current = terminalSettings?.scrollOnPaste ?? true;
|
||||
|
||||
const scrollToBottomAfterProgrammaticInput = (data: string) => {
|
||||
const scrollToBottomAfterProgrammaticInput = useCallback((data: string) => {
|
||||
if (termRef.current && shouldScrollOnTerminalInput(terminalSettingsRef.current, data)) {
|
||||
termRef.current.scrollToBottom();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const executeSnippetCommand = useCallback((command: string, noAutoRun?: boolean) => {
|
||||
const term = termRef.current;
|
||||
const id = sessionRef.current;
|
||||
if (!term || !id) return;
|
||||
|
||||
let data = normalizeLineEndings(command);
|
||||
const isMultiLine = data.includes('\n');
|
||||
// Wrap in bracketed paste BEFORE appending \r so the Enter is sent
|
||||
// outside the paste markers — otherwise shells treat it as pasted text
|
||||
// instead of a submit action.
|
||||
if (isMultiLine && term.modes.bracketedPasteMode && !disableBracketedPasteRef.current) {
|
||||
data = wrapBracketedPaste(data);
|
||||
}
|
||||
if (!noAutoRun) data = `${data}\r`;
|
||||
|
||||
terminalBackend.writeToSession(id, data);
|
||||
scrollToBottomAfterProgrammaticInput(data);
|
||||
term.focus();
|
||||
}, [scrollToBottomAfterProgrammaticInput, terminalBackend]);
|
||||
|
||||
// Only register the snippet executor once the terminal session is ready.
|
||||
// Before that, TerminalLayer falls back to raw writeToSession which is the
|
||||
// correct path for sessions that are still connecting.
|
||||
useEffect(() => {
|
||||
if (status !== "connected") {
|
||||
onSnippetExecutorChange?.(sessionId, null);
|
||||
return;
|
||||
}
|
||||
onSnippetExecutorChange?.(sessionId, executeSnippetCommand);
|
||||
return () => onSnippetExecutorChange?.(sessionId, null);
|
||||
}, [executeSnippetCommand, onSnippetExecutorChange, sessionId, status]);
|
||||
|
||||
const terminalContextActions = useTerminalContextActions({
|
||||
termRef,
|
||||
@@ -1106,6 +1309,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onCloseSession?.(sessionId);
|
||||
};
|
||||
|
||||
const handleDismissDisconnectedDialog = () => {
|
||||
setIsDisconnectedDialogDismissed(true);
|
||||
};
|
||||
|
||||
const handleCloseDisconnectedSession = () => {
|
||||
onCloseSession?.(sessionId);
|
||||
};
|
||||
|
||||
const handleHostKeyClose = () => {
|
||||
setNeedsHostKeyVerification(false);
|
||||
setPendingHostKeyInfo(null);
|
||||
@@ -1145,18 +1356,31 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (!termRef.current) return;
|
||||
cleanupSession();
|
||||
auth.resetForRetry();
|
||||
terminalDataCapturedRef.current = false;
|
||||
hasRunStartupCommandRef.current = false;
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
setStatus("connecting");
|
||||
setError(null);
|
||||
setProgressLogs(["Retrying secure channel..."]);
|
||||
setShowLogs(true);
|
||||
if (host.protocol === "local" || host.hostname === "localhost") {
|
||||
if (host.protocol === "serial") {
|
||||
sessionStarters.startSerial(termRef.current);
|
||||
} else if (host.protocol === "local" || host.hostname === "localhost") {
|
||||
sessionStarters.startLocal(termRef.current);
|
||||
} else if (host.protocol === "telnet") {
|
||||
sessionStarters.startTelnet(termRef.current);
|
||||
} else if (host.moshEnabled) {
|
||||
sessionStarters.startMosh(termRef.current);
|
||||
} else {
|
||||
sessionStarters.startSSH(termRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldShowConnectionDialog = status !== "connected"
|
||||
&& !needsHostKeyVerification
|
||||
&& !((isLocalConnection || isSerialConnection) && status === "connecting")
|
||||
&& !(status === "disconnected" && isDisconnectedDialogDismissed);
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -1266,6 +1490,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
: status === "connecting"
|
||||
? "bg-amber-400"
|
||||
: "bg-rose-500";
|
||||
const terminalPreviewVars = useMemo(() => ({
|
||||
['--terminal-ui-bg' as never]: `var(--terminal-preview-bg, ${effectiveTheme.colors.background})`,
|
||||
['--terminal-ui-fg' as never]: `var(--terminal-preview-fg, ${effectiveTheme.colors.foreground})`,
|
||||
['--terminal-ui-border' as never]: `var(--terminal-preview-border, color-mix(in srgb, ${effectiveTheme.colors.foreground} 8%, ${effectiveTheme.colors.background} 92%))`,
|
||||
['--terminal-ui-toolbar-btn' as never]: `var(--terminal-preview-toolbar-btn, color-mix(in srgb, ${effectiveTheme.colors.background} 88%, ${effectiveTheme.colors.foreground} 12%))`,
|
||||
['--terminal-ui-toolbar-btn-hover' as never]: `var(--terminal-preview-toolbar-btn-hover, color-mix(in srgb, ${effectiveTheme.colors.background} 78%, ${effectiveTheme.colors.foreground} 22%))`,
|
||||
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.background} 68%, ${effectiveTheme.colors.foreground} 32%))`,
|
||||
}), [effectiveTheme.colors.background, effectiveTheme.colors.foreground]);
|
||||
|
||||
return (
|
||||
<TerminalContextMenu
|
||||
@@ -1288,6 +1520,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
"relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]",
|
||||
isComposeBarOpen && !inWorkspace && "flex-col"
|
||||
)}
|
||||
style={terminalPreviewVars}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -1318,14 +1551,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0 border-b-[0.5px]"
|
||||
style={{
|
||||
backgroundColor: effectiveTheme.colors.background,
|
||||
color: effectiveTheme.colors.foreground,
|
||||
borderColor: `color-mix(in srgb, ${effectiveTheme.colors.foreground} 8%, ${effectiveTheme.colors.background} 92%)`,
|
||||
['--terminal-toolbar-fg' as never]: effectiveTheme.colors.foreground,
|
||||
['--terminal-toolbar-bg' as never]: effectiveTheme.colors.background,
|
||||
['--terminal-toolbar-btn' as never]: `color-mix(in srgb, ${effectiveTheme.colors.background} 88%, ${effectiveTheme.colors.foreground} 12%)`,
|
||||
['--terminal-toolbar-btn-hover' as never]: `color-mix(in srgb, ${effectiveTheme.colors.background} 78%, ${effectiveTheme.colors.foreground} 22%)`,
|
||||
['--terminal-toolbar-btn-active' as never]: `color-mix(in srgb, ${effectiveTheme.colors.background} 68%, ${effectiveTheme.colors.foreground} 32%)`,
|
||||
backgroundColor: 'var(--terminal-ui-bg)',
|
||||
color: 'var(--terminal-ui-fg)',
|
||||
borderColor: 'var(--terminal-ui-border)',
|
||||
['--terminal-toolbar-fg' as never]: 'var(--terminal-ui-fg)',
|
||||
['--terminal-toolbar-bg' as never]: 'var(--terminal-ui-bg)',
|
||||
['--terminal-toolbar-btn' as never]: 'var(--terminal-ui-toolbar-btn)',
|
||||
['--terminal-toolbar-btn-hover' as never]: 'var(--terminal-ui-toolbar-btn-hover)',
|
||||
['--terminal-toolbar-btn-active' as never]: 'var(--terminal-ui-toolbar-btn-active)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1 text-[11px] font-semibold">
|
||||
@@ -1337,8 +1570,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/* Server Stats Display - Linux only */}
|
||||
{host.os === 'linux' && terminalSettings?.showServerStats && status === 'connected' && serverStats.lastUpdated && (
|
||||
{/* Server Stats Display */}
|
||||
{terminalSettings?.showServerStats && status === 'connected' && serverStats.lastUpdated && (
|
||||
<div className="flex items-center gap-2.5 ml-2 text-[10px] opacity-80 flex-nowrap overflow-hidden min-w-0">
|
||||
{/* CPU with HoverCard for per-core details */}
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
@@ -1385,6 +1618,24 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : serverStats.cpu !== null ? (
|
||||
<div className="flex flex-col gap-1.5 min-w-[160px]">
|
||||
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
serverStats.cpu >= 90 ? "bg-red-500" : serverStats.cpu >= 70 ? "bg-amber-500" : "bg-emerald-500"
|
||||
)}
|
||||
style={{ width: `${serverStats.cpu}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn(
|
||||
"text-center text-[11px] font-medium",
|
||||
serverStats.cpu >= 90 ? "text-red-400" : serverStats.cpu >= 70 ? "text-amber-400" : "text-emerald-400"
|
||||
)}>
|
||||
{serverStats.cpu}% · {serverStats.cpuCores ?? '?'} cores
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
|
||||
)}
|
||||
@@ -1682,7 +1933,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
<div
|
||||
className="h-full flex-1 min-w-0 relative overflow-hidden pt-8"
|
||||
style={{ backgroundColor: effectiveTheme.colors.background }}
|
||||
style={{ backgroundColor: 'var(--terminal-ui-bg)' }}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -1690,10 +1941,33 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
style={{
|
||||
top: isSearchOpen ? "64px" : "30px",
|
||||
paddingLeft: 6,
|
||||
backgroundColor: effectiveTheme.colors.background,
|
||||
backgroundColor: 'var(--terminal-ui-bg)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Autocomplete popup — rendered via Portal to escape overflow:hidden */}
|
||||
{isVisible && autocomplete.state.popupVisible && autocomplete.state.suggestions.length > 0 &&
|
||||
ReactDOM.createPortal(
|
||||
<AutocompletePopup
|
||||
suggestions={autocomplete.state.suggestions}
|
||||
selectedIndex={autocomplete.state.selectedIndex}
|
||||
position={autocomplete.state.popupPosition}
|
||||
cursorLineTop={autocomplete.state.popupCursorLineTop}
|
||||
cursorLineBottom={autocomplete.state.popupCursorLineBottom}
|
||||
visible={autocomplete.state.popupVisible}
|
||||
expandUpward={autocomplete.state.expandUpward}
|
||||
themeColors={effectiveTheme.colors}
|
||||
onSelect={autocomplete.selectSuggestion}
|
||||
subDirPanels={autocomplete.state.subDirPanels}
|
||||
subDirFocusLevel={autocomplete.state.subDirFocusLevel}
|
||||
containerRef={containerRef}
|
||||
onRequestReposition={autocomplete.repositionPopup}
|
||||
searchBarOffset={isSearchOpen ? 64 : 30}
|
||||
/>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
{needsHostKeyVerification && pendingHostKeyInfo && (
|
||||
<div className="absolute inset-0 z-30 bg-background">
|
||||
<KnownHostConfirmDialog
|
||||
@@ -1730,9 +2004,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
)}
|
||||
|
||||
{/* Connection dialog: skip for local/serial during connecting phase, but show on error */}
|
||||
{status !== "connected" && !needsHostKeyVerification && !(
|
||||
(isLocalConnection || isSerialConnection) && status === "connecting"
|
||||
) && (
|
||||
{shouldShowConnectionDialog && (
|
||||
<TerminalConnectionDialog
|
||||
host={host}
|
||||
status={status}
|
||||
@@ -1743,6 +2015,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
showLogs={showLogs}
|
||||
_setShowLogs={setShowLogs}
|
||||
keys={keys}
|
||||
onDismissDisconnected={handleDismissDisconnectedDialog}
|
||||
authProps={{
|
||||
authMethod: auth.authMethod,
|
||||
setAuthMethod: auth.setAuthMethod,
|
||||
@@ -1768,7 +2041,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
timeLeft,
|
||||
isCancelling,
|
||||
progressLogs,
|
||||
onCancel: handleCancelConnect,
|
||||
onCancelConnect: handleCancelConnect,
|
||||
onCloseSession: handleCloseDisconnectedSession,
|
||||
onRetry: handleRetry,
|
||||
}}
|
||||
/>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -151,6 +151,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());
|
||||
const readClipboardTextRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
|
||||
|
||||
// Track theme from document.documentElement class (syncs with app theme)
|
||||
const [isDarkTheme, setIsDarkTheme] = useState(() =>
|
||||
@@ -254,6 +255,10 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
}
|
||||
}, [readClipboardTextFromBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
readClipboardTextRef.current = readClipboardText;
|
||||
}, [readClipboardText]);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
@@ -316,7 +321,30 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
});
|
||||
|
||||
// Fallback paste path for Electron environments where Monaco paste can fail.
|
||||
// Skip custom paste when focus is inside the find/replace widget so that
|
||||
// its input fields receive the pasted text via default browser behavior.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
|
||||
const active = document.activeElement;
|
||||
if (active?.closest('.find-widget')) {
|
||||
// Read clipboard and insert into the find/replace input field.
|
||||
void (async () => {
|
||||
try {
|
||||
const text = await readClipboardTextRef.current();
|
||||
if (!text) return;
|
||||
// Monaco find widget inputs are <textarea> elements inside .monaco-inputbox
|
||||
if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) {
|
||||
const start = active.selectionStart ?? active.value.length;
|
||||
const end = active.selectionEnd ?? active.value.length;
|
||||
active.focus();
|
||||
active.setSelectionRange(start, end);
|
||||
document.execCommand('insertText', false, text);
|
||||
}
|
||||
} catch {
|
||||
// Ignore – paste simply won't work
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
void handlePasteRef.current();
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Bell, Copy, FileText, Folder, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Shield, Sparkles, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
|
||||
import { Bell, Copy, FileText, Folder, FolderLock, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, 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 { buildWorkspaceActivityMap } from '../application/state/sessionActivity';
|
||||
import { useSessionActivityMap } from '../application/state/sessionActivityStore';
|
||||
import { LogView } from '../application/state/useSessionState';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { normalizeDistroId } from '../domain/host';
|
||||
import { getEffectiveHostDistro } from '../domain/host';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Host, TerminalSession, Workspace } from '../types';
|
||||
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
|
||||
@@ -36,6 +38,7 @@ interface TopTabsProps {
|
||||
onToggleTheme: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onSyncNow?: () => Promise<void>;
|
||||
isImmersiveActive?: boolean;
|
||||
onStartSessionDrag: (sessionId: string) => void;
|
||||
onEndSessionDrag: () => void;
|
||||
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
|
||||
@@ -54,7 +57,7 @@ const localOsId = (() => {
|
||||
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");
|
||||
const fallbackStyle = { color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' };
|
||||
|
||||
// Serial protocol → USB icon
|
||||
if (protocol === 'serial' || host?.protocol === 'serial') {
|
||||
@@ -81,7 +84,7 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={cn(boxBase, "bg-primary/15 text-primary")}>
|
||||
<div className={boxBase} style={{ backgroundColor: 'color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 15%, transparent)', color: 'var(--top-tabs-accent, hsl(var(--accent)))' }}>
|
||||
<TerminalSquare className={iconSize} />
|
||||
</div>
|
||||
);
|
||||
@@ -89,7 +92,7 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
|
||||
|
||||
// Try distro logo with brand background color
|
||||
if (host) {
|
||||
const distro = normalizeDistroId(host.distro) || (host.distro || '').toLowerCase();
|
||||
const distro = getEffectiveHostDistro(host);
|
||||
const logo = DISTRO_LOGOS[distro];
|
||||
if (logo) {
|
||||
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
|
||||
@@ -97,7 +100,7 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
|
||||
<div className={cn(boxBase, bg)}>
|
||||
<img
|
||||
src={logo}
|
||||
alt={host.distro || host.os}
|
||||
alt={distro || host.os}
|
||||
className={cn(iconSize, "object-contain invert brightness-0")}
|
||||
/>
|
||||
</div>
|
||||
@@ -108,22 +111,33 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
|
||||
// Fallback: generic server icon for remote, terminal for unknown
|
||||
if (host && host.protocol !== 'local') {
|
||||
return (
|
||||
<div className={cn(boxBase, "bg-primary/15 text-primary")}>
|
||||
<div className={boxBase} style={{ backgroundColor: 'color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 15%, transparent)', color: 'var(--top-tabs-accent, hsl(var(--accent)))' }}>
|
||||
<Server className={iconSize} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <TerminalSquare className={fallbackIcon} />;
|
||||
return <TerminalSquare className={iconSize} style={fallbackStyle} />;
|
||||
});
|
||||
SessionTabIcon.displayName = 'SessionTabIcon';
|
||||
|
||||
const sessionStatusDot = (status: TerminalSession['status']) => {
|
||||
const sessionStatusDot = (status: TerminalSession['status'], hasActivity: boolean) => {
|
||||
const tone = status === 'connected'
|
||||
? "bg-emerald-400"
|
||||
: status === 'connecting'
|
||||
? "bg-amber-400"
|
||||
: "bg-rose-500";
|
||||
return <span className={cn("inline-block h-2 w-2 rounded-full ring-2 ring-background/60", tone)} />;
|
||||
return (
|
||||
<span className="relative inline-flex h-2 w-2 shrink-0 items-center justify-center">
|
||||
<span
|
||||
className={cn(
|
||||
"relative inline-block h-2 w-2 rounded-full ring-2",
|
||||
tone,
|
||||
hasActivity && "session-activity-dot",
|
||||
)}
|
||||
style={{ boxShadow: '0 0 0 2px color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 60%, transparent)' }}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom window controls for Windows/Linux (frameless window)
|
||||
@@ -167,14 +181,16 @@ const WindowControls: React.FC = memo(() => {
|
||||
<div className="flex items-center app-drag h-full">
|
||||
<button
|
||||
onClick={handleMinimize}
|
||||
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"
|
||||
className="h-full w-10 flex items-center justify-center transition-all duration-150 app-no-drag"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
title="Minimize"
|
||||
>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMaximize}
|
||||
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"
|
||||
className="h-full w-10 flex items-center justify-center transition-all duration-150 app-no-drag"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
title={isMaximized ? "Restore" : "Maximize"}
|
||||
>
|
||||
{isMaximized ? (
|
||||
@@ -217,6 +233,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onToggleTheme,
|
||||
onOpenSettings,
|
||||
onSyncNow,
|
||||
isImmersiveActive,
|
||||
onStartSessionDrag,
|
||||
onEndSessionDrag,
|
||||
onReorderTabs,
|
||||
@@ -225,6 +242,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
// Subscribe to activeTabId from external store
|
||||
const { maximize, isFullscreen, onFullscreenChanged } = useWindowControls();
|
||||
const activeTabId = useActiveTabId();
|
||||
const sessionActivityMap = useSessionActivityMap();
|
||||
const isVaultActive = activeTabId === 'vault';
|
||||
const isSftpActive = activeTabId === 'sftp';
|
||||
const onSelectTab = activeTabStore.setActiveTabId;
|
||||
@@ -328,6 +346,10 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
return map;
|
||||
}, [hosts]);
|
||||
|
||||
const workspaceActivityMap = useMemo(() => {
|
||||
return buildWorkspaceActivityMap(sessions, sessionActivityMap);
|
||||
}, [sessionActivityMap, sessions]);
|
||||
|
||||
// Pre-compute session counts per workspace for O(1) access
|
||||
const workspacePaneCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
@@ -451,6 +473,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
|
||||
if (item.type === 'session') {
|
||||
const session = item.session;
|
||||
const hasActivity = !!sessionActivityMap[session.id];
|
||||
const isBeingDragged = draggingSessionId === session.id;
|
||||
const shiftStyle = tabShiftStyles[session.id] || {};
|
||||
const showDropIndicatorBefore = dropIndicator?.tabId === session.id && dropIndicator.position === 'before';
|
||||
@@ -470,30 +493,56 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onDrop={(e) => handleTabDrop(e, session.id)}
|
||||
className={cn(
|
||||
"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",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
|
||||
)}
|
||||
style={shiftStyle}
|
||||
style={{
|
||||
...shiftStyle,
|
||||
backgroundColor: activeTabId === session.id
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: activeTabId === session.id
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (activeTabId !== session.id) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (activeTabId !== session.id) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Active tab top accent line */}
|
||||
{activeTabId === session.id && (
|
||||
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
|
||||
/>
|
||||
)}
|
||||
{/* Drop indicator line - before */}
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
<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" />
|
||||
<div
|
||||
className="absolute -left-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
|
||||
/>
|
||||
)}
|
||||
{/* Drop indicator line - after */}
|
||||
{showDropIndicatorAfter && isDraggingForReorder && (
|
||||
<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="absolute -right-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<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 className="flex-shrink-0">{sessionStatusDot(session.status, hasActivity)}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => onCloseSession(session.id, e)}
|
||||
@@ -522,6 +571,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
if (item.type === 'workspace') {
|
||||
const workspace = item.workspace;
|
||||
const paneCount = item.paneCount;
|
||||
const hasActivity = !!workspaceActivityMap.get(workspace.id);
|
||||
const isActive = activeTabId === workspace.id;
|
||||
const isBeingDragged = draggingSessionId === workspace.id;
|
||||
const shiftStyle = tabShiftStyles[workspace.id] || {};
|
||||
@@ -542,32 +592,71 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onDrop={(e) => handleTabDrop(e, workspace.id)}
|
||||
className={cn(
|
||||
"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",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
|
||||
)}
|
||||
style={shiftStyle}
|
||||
style={{
|
||||
...shiftStyle,
|
||||
backgroundColor: isActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Active tab top accent line */}
|
||||
{isActive && (
|
||||
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
|
||||
/>
|
||||
)}
|
||||
{/* Drop indicator line - before */}
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
<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" />
|
||||
<div
|
||||
className="absolute -left-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
|
||||
/>
|
||||
)}
|
||||
{/* Drop indicator line - after */}
|
||||
{showDropIndicatorAfter && isDraggingForReorder && (
|
||||
<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="absolute -right-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<LayoutGrid size={14} className={cn("shrink-0", isActive ? "text-primary" : "text-muted-foreground")} />
|
||||
<LayoutGrid
|
||||
size={14}
|
||||
className="shrink-0"
|
||||
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
/>
|
||||
<span className="truncate">{workspace.title}</span>
|
||||
</div>
|
||||
<div className="text-[10px] px-1.5 py-0.5 rounded-full border border-border/70 bg-background/60 min-w-[22px] text-center">
|
||||
{paneCount}
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{hasActivity && sessionStatusDot('connected', true)}
|
||||
<div
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full min-w-[22px] text-center"
|
||||
style={{
|
||||
border: '1px solid color-mix(in srgb, var(--top-tabs-fg, hsl(var(--foreground))) 18%, transparent)',
|
||||
backgroundColor: 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 60%, transparent)',
|
||||
}}
|
||||
>
|
||||
{paneCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
@@ -595,18 +684,41 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onClick={() => onSelectTab(logView.id)}
|
||||
className={cn(
|
||||
"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={{
|
||||
backgroundColor: isActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Active tab top accent line */}
|
||||
{isActive && (
|
||||
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
|
||||
/>
|
||||
)}
|
||||
<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")} />
|
||||
<FileText
|
||||
size={14}
|
||||
className="shrink-0"
|
||||
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{t('tabs.logPrefix')} {isLocal ? t('tabs.logLocal') : logView.log.hostname}
|
||||
</span>
|
||||
@@ -640,8 +752,13 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
data-top-tabs-root
|
||||
className="relative w-full bg-secondary app-drag"
|
||||
style={dragRegionNoSelect}
|
||||
style={{
|
||||
...dragRegionNoSelect,
|
||||
backgroundColor: 'var(--top-tabs-bg, hsl(var(--secondary)))',
|
||||
color: 'var(--top-tabs-fg, hsl(var(--foreground)))',
|
||||
}}
|
||||
onDoubleClick={handleTitleBarDoubleClick}
|
||||
>
|
||||
{/* Always-on drag stripe so the window can be moved even when tabs fill the bar */}
|
||||
@@ -656,25 +773,62 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onClick={() => onSelectTab('vault')}
|
||||
className={cn(
|
||||
"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={{
|
||||
backgroundColor: isVaultActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isVaultActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isVaultActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isVaultActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Shield size={14} /> Vaults
|
||||
<FolderLock size={14} /> Vaults
|
||||
</div>
|
||||
<div
|
||||
onClick={() => onSelectTab('sftp')}
|
||||
className={cn(
|
||||
"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={{
|
||||
backgroundColor: isSftpActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isSftpActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSftpActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSftpActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSftpActive && <div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />}
|
||||
{isSftpActive && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
|
||||
/>
|
||||
)}
|
||||
<Folder size={14} /> SFTP
|
||||
</div>
|
||||
</div>
|
||||
@@ -696,7 +850,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{canScrollLeft && (
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-8 pointer-events-none z-10"
|
||||
style={{ background: 'linear-gradient(to right, hsl(var(--secondary) / 0.9), transparent)' }}
|
||||
style={{ background: 'linear-gradient(to right, var(--top-tabs-bg, hsl(var(--secondary))), transparent)' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -713,6 +867,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0 app-no-drag mb-0 rounded-none"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={onOpenQuickSwitcher}
|
||||
title="Open quick switcher"
|
||||
>
|
||||
@@ -727,7 +882,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{canScrollRight && (
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-8 pointer-events-none z-10"
|
||||
style={{ background: 'linear-gradient(to left, hsl(var(--secondary) / 0.9), transparent)' }}
|
||||
style={{ background: 'linear-gradient(to left, var(--top-tabs-bg, hsl(var(--secondary))), transparent)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -738,6 +893,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0 app-no-drag self-end rounded-none"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={onOpenQuickSwitcher}
|
||||
title="More tabs"
|
||||
>
|
||||
@@ -750,21 +906,24 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag"
|
||||
className="h-6 w-6 app-no-drag"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
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">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 app-no-drag" style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}>
|
||||
<Bell size={16} />
|
||||
</Button>
|
||||
<SyncStatusButton onOpenSettings={onOpenSettings} onSyncNow={onSyncNow} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag"
|
||||
className="h-6 w-6 app-no-drag"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={onToggleTheme}
|
||||
disabled={isImmersiveActive}
|
||||
title="Toggle theme"
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||
@@ -788,10 +947,12 @@ const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
|
||||
prev.orphanSessions === next.orphanSessions &&
|
||||
prev.workspaces === next.workspaces &&
|
||||
prev.orderedTabs === next.orderedTabs &&
|
||||
prev.logViews === next.logViews &&
|
||||
prev.draggingSessionId === next.draggingSessionId &&
|
||||
prev.isMacClient === next.isMacClient &&
|
||||
prev.onOpenSettings === next.onOpenSettings &&
|
||||
prev.onSyncNow === next.onSyncNow
|
||||
prev.onSyncNow === next.onSyncNow &&
|
||||
prev.isImmersiveActive === next.isImmersiveActive
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ const TrayPanelContent: React.FC = () => {
|
||||
onTrayPanelMenuData,
|
||||
} = useTrayPanelBackend();
|
||||
|
||||
const { hosts, keys } = useVaultState();
|
||||
const { hosts, keys, identities } = useVaultState();
|
||||
useSessionState();
|
||||
const { rules: portForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
|
||||
const activeTabId = useActiveTabId();
|
||||
@@ -151,11 +151,6 @@ const TrayPanelContent: React.FC = () => {
|
||||
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]);
|
||||
@@ -339,7 +334,7 @@ const TrayPanelContent: React.FC = () => {
|
||||
if (isActive) {
|
||||
void stopTunnel(rule.id);
|
||||
} else {
|
||||
void startTunnel(rule, host, keysForPf, (status, error) => {
|
||||
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
|
||||
import { ArrowDown } from 'lucide-react';
|
||||
@@ -9,7 +9,7 @@ export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn('relative flex-1 overflow-y-hidden', className)}
|
||||
initial="smooth"
|
||||
initial="instant"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
{...props}
|
||||
@@ -25,41 +25,6 @@ export const ConversationContent = ({ className, ...props }: ConversationContent
|
||||
/>
|
||||
);
|
||||
|
||||
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();
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ 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',
|
||||
'flex w-fit min-w-0 max-w-full flex-col gap-1.5 text-[13px] leading-relaxed',
|
||||
'group-[.is-user]:ml-auto group-[.is-user]:overflow-hidden 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-2.5 group-[.is-user]:py-2',
|
||||
'group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground/90',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
import { ArrowUp, Square, X } from 'lucide-react';
|
||||
import type {
|
||||
ComponentProps,
|
||||
ComponentPropsWithoutRef,
|
||||
ElementRef,
|
||||
FormEvent,
|
||||
HTMLAttributes,
|
||||
KeyboardEvent,
|
||||
@@ -17,13 +15,6 @@ import type {
|
||||
} 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,
|
||||
@@ -254,30 +245,3 @@ export const PromptInputSubmit = forwardRef<HTMLButtonElement, PromptInputSubmit
|
||||
);
|
||||
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;
|
||||
|
||||
@@ -1,7 +1,42 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ChevronDown, ChevronRight, CheckCircle2, Loader2, XCircle, Slash } from 'lucide-react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Check, ChevronDown, ChevronRight, CheckCircle2, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef, useState, type HTMLAttributes } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
|
||||
/**
|
||||
* Format tool result for display. Extracts stdout/stderr from structured
|
||||
* command results for terminal-like output.
|
||||
*/
|
||||
function formatToolResult(result: unknown): string {
|
||||
let parsed = result;
|
||||
|
||||
if (typeof parsed === 'string') {
|
||||
try {
|
||||
const obj = JSON.parse(parsed);
|
||||
if (obj && typeof obj === 'object') parsed = obj;
|
||||
} catch {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (typeof obj.stdout === 'string' || typeof obj.stderr === 'string') {
|
||||
const parts: string[] = [];
|
||||
if (typeof obj.stdout === 'string' && obj.stdout) parts.push(obj.stdout);
|
||||
if (typeof obj.stderr === 'string' && obj.stderr) parts.push(obj.stderr);
|
||||
if (typeof obj.exitCode === 'number' && obj.exitCode !== 0) {
|
||||
parts.push(`exit code: ${obj.exitCode}`);
|
||||
}
|
||||
if (parts.length > 0) return parts.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof parsed === 'string') return parsed;
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
}
|
||||
|
||||
export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
|
||||
name: string;
|
||||
@@ -10,23 +45,91 @@ export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
|
||||
isError?: boolean;
|
||||
isLoading?: boolean;
|
||||
isInterrupted?: boolean;
|
||||
/** Approval state for this tool call (from the approval gate). */
|
||||
approvalStatus?: 'pending' | 'approved' | 'denied';
|
||||
/** Called when user approves this tool call. */
|
||||
onApprove?: () => void;
|
||||
/** Called when user rejects this tool call. */
|
||||
onReject?: () => void;
|
||||
}
|
||||
|
||||
export const ToolCall = ({ name, args, result, isError, isLoading, isInterrupted, className, ...props }: ToolCallProps) => {
|
||||
export const ToolCall = ({
|
||||
name, args, result, isError, isLoading, isInterrupted,
|
||||
approvalStatus, onApprove, onReject,
|
||||
className, ...props
|
||||
}: ToolCallProps) => {
|
||||
const { t } = useI18n();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const approveBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const [responded, setResponded] = useState(false);
|
||||
|
||||
const statusIcon = isLoading ? (
|
||||
<Loader2 size={12} className="animate-spin text-blue-400/70" />
|
||||
const isPendingApproval = approvalStatus === 'pending' && !responded;
|
||||
|
||||
const handleApprove = useCallback(() => {
|
||||
if (!isPendingApproval) return;
|
||||
setResponded(true);
|
||||
onApprove?.();
|
||||
}, [isPendingApproval, onApprove]);
|
||||
|
||||
const handleReject = useCallback(() => {
|
||||
if (!isPendingApproval) return;
|
||||
setResponded(true);
|
||||
onReject?.();
|
||||
}, [isPendingApproval, onReject]);
|
||||
|
||||
// Keyboard: Enter = approve, Escape = reject (when pending)
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (!isPendingApproval) return;
|
||||
if (e.key === 'Enter') { e.preventDefault(); handleApprove(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); handleReject(); }
|
||||
}, [isPendingApproval, handleApprove, handleReject]);
|
||||
|
||||
// Auto-focus and auto-scroll when approval is pending
|
||||
useEffect(() => {
|
||||
if (isPendingApproval && cardRef.current) {
|
||||
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
// Small delay to let the UI render, then expand and focus
|
||||
setExpanded(true);
|
||||
setTimeout(() => approveBtnRef.current?.focus(), 100);
|
||||
}
|
||||
}, [isPendingApproval]);
|
||||
|
||||
// Reset responded state when approvalStatus changes (e.g. new approval)
|
||||
useEffect(() => {
|
||||
if (approvalStatus === 'pending') setResponded(false);
|
||||
}, [approvalStatus]);
|
||||
|
||||
// Border/bg color based on approval status
|
||||
const borderClass = approvalStatus === 'pending'
|
||||
? 'border-yellow-500/30 bg-yellow-500/[0.04]'
|
||||
: approvalStatus === 'approved'
|
||||
? 'border-green-500/20 bg-green-500/[0.03]'
|
||||
: approvalStatus === 'denied'
|
||||
? 'border-red-500/20 bg-red-500/[0.03]'
|
||||
: 'border-border/25 bg-muted/10';
|
||||
const statusIconClass = 'shrink-0';
|
||||
|
||||
const statusIcon = approvalStatus === 'pending' ? (
|
||||
<ShieldAlert size={12} className={cn('text-yellow-500/70', statusIconClass)} />
|
||||
) : isLoading ? (
|
||||
<Loader2 size={12} className={cn('animate-spin text-blue-400/70', statusIconClass)} />
|
||||
) : isInterrupted ? (
|
||||
<Slash size={12} className="text-muted-foreground/55" />
|
||||
<Slash size={12} className={cn('text-muted-foreground/55', statusIconClass)} />
|
||||
) : isError ? (
|
||||
<XCircle size={12} className="text-red-400/70" />
|
||||
<XCircle size={12} className={cn('text-red-400/70', statusIconClass)} />
|
||||
) : result !== undefined ? (
|
||||
<CheckCircle2 size={12} className="text-green-400/70" />
|
||||
<CheckCircle2 size={12} className={cn('text-green-400/70', statusIconClass)} />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-md border border-border/25 bg-muted/10 overflow-hidden text-[12px]', className)} {...props}>
|
||||
<div
|
||||
ref={cardRef}
|
||||
tabIndex={isPendingApproval ? 0 : undefined}
|
||||
onKeyDown={isPendingApproval ? handleKeyDown : undefined}
|
||||
className={cn('rounded-md border overflow-hidden text-[12px] outline-none', borderClass, className)}
|
||||
{...props}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
@@ -36,28 +139,78 @@ export const ToolCall = ({ name, args, result, isError, isLoading, isInterrupted
|
||||
? <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>
|
||||
{name === 'terminal_execute' && args?.command ? (
|
||||
<span className="font-mono text-muted-foreground/70 truncate" title={String(args.command)}>
|
||||
<span className="text-muted-foreground/40">$ </span>{String(args.command)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
{/* Approval badge for resolved approvals */}
|
||||
{approvalStatus === 'approved' && (
|
||||
<Badge className="text-[10px] px-1.5 py-0 bg-green-600/20 text-green-400 border-green-600/30">
|
||||
{t('ai.chat.toolApproved')}
|
||||
</Badge>
|
||||
)}
|
||||
{approvalStatus === 'denied' && (
|
||||
<Badge className="text-[10px] px-1.5 py-0 bg-red-600/20 text-red-400 border-red-600/30">
|
||||
{t('ai.chat.toolDenied')}
|
||||
</Badge>
|
||||
)}
|
||||
{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">
|
||||
<pre className="max-h-64 overflow-auto text-[11px] font-mono text-muted-foreground/50 whitespace-pre [overflow-wrap:normal]">
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline approval buttons */}
|
||||
{isPendingApproval && (
|
||||
<div className="px-3 py-2 border-t border-border/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<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"
|
||||
className="h-6 px-2 text-[11px] border-red-500/20 text-red-400/80 hover:bg-red-500/10 hover:text-red-400"
|
||||
onClick={handleReject}
|
||||
>
|
||||
<X size={11} className="mr-0.5" />
|
||||
{t('ai.chat.reject')}
|
||||
</Button>
|
||||
<Button
|
||||
ref={approveBtnRef}
|
||||
size="sm"
|
||||
className="h-6 px-2.5 text-[11px] bg-green-600/80 hover:bg-green-600 text-white"
|
||||
onClick={handleApprove}
|
||||
>
|
||||
<Check size={11} className="mr-0.5" />
|
||||
{t('ai.chat.approve')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</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',
|
||||
'max-h-64 overflow-auto text-[11px] font-mono whitespace-pre [overflow-wrap:normal]',
|
||||
isError ? 'text-red-400/60' : 'text-muted-foreground/50',
|
||||
)}>
|
||||
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
|
||||
{formatToolResult(result)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -154,13 +154,6 @@ function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
|
||||
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';
|
||||
@@ -187,18 +180,27 @@ export const AgentIconBadge: React.FC<{
|
||||
|
||||
if (variant === 'plain') {
|
||||
return (
|
||||
<img
|
||||
src={visual.src}
|
||||
alt=""
|
||||
<div
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn('shrink-0', imageSize, visual.imageClassName, className)}
|
||||
className={cn('shrink-0', imageSize, className)}
|
||||
style={{
|
||||
maskImage: `url(${visual.src})`,
|
||||
WebkitMaskImage: `url(${visual.src})`,
|
||||
maskSize: 'contain',
|
||||
WebkitMaskSize: 'contain',
|
||||
maskRepeat: 'no-repeat',
|
||||
WebkitMaskRepeat: 'no-repeat',
|
||||
maskPosition: 'center',
|
||||
WebkitMaskPosition: 'center',
|
||||
backgroundColor: 'currentColor',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-agent-badge=""
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center overflow-hidden border',
|
||||
badgeSize,
|
||||
|
||||
@@ -208,7 +208,7 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
<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"
|
||||
className="w-[288px] overflow-hidden 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
|
||||
|
||||
@@ -11,7 +11,7 @@ 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 type { UploadedFile } from '../../application/state/useFileUpload';
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputFooter,
|
||||
@@ -40,12 +40,12 @@ interface ChatInputProps {
|
||||
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;
|
||||
/** Attached files (images, PDFs, etc.) */
|
||||
files?: UploadedFile[];
|
||||
/** Callback to add files (paste/drop) */
|
||||
onAddFiles?: (files: File[]) => void;
|
||||
/** Callback to remove a file */
|
||||
onRemoveFile?: (id: string) => void;
|
||||
/** Available hosts for @ mention */
|
||||
hosts?: Array<{ sessionId: string; hostname: string; label: string; connected: boolean }>;
|
||||
/** Permission mode (only shown for Catty Agent) */
|
||||
@@ -68,9 +68,9 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
modelPresets = [],
|
||||
selectedModelId,
|
||||
onModelSelect,
|
||||
images = [],
|
||||
onAddImages,
|
||||
onRemoveImage,
|
||||
files = [],
|
||||
onAddFiles,
|
||||
onRemoveFile,
|
||||
hosts = [],
|
||||
permissionMode,
|
||||
onPermissionModeChange,
|
||||
@@ -134,23 +134,22 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
}, [value, onChange, closeAllMenus]);
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||
const files = Array.from(e.clipboardData.items)
|
||||
.filter((item) => item.type.startsWith('image/'))
|
||||
const pastedFiles = Array.from(e.clipboardData.items)
|
||||
.map((item) => item.getAsFile())
|
||||
.filter(Boolean) as File[];
|
||||
if (files.length > 0) {
|
||||
if (pastedFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
onAddImages?.(files);
|
||||
onAddFiles?.(pastedFiles);
|
||||
}
|
||||
}, [onAddImages]);
|
||||
}, [onAddFiles]);
|
||||
|
||||
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);
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
if (droppedFiles.length > 0) {
|
||||
onAddFiles?.(droppedFiles);
|
||||
}
|
||||
}, [onAddImages]);
|
||||
}, [onAddFiles]);
|
||||
|
||||
const defaultPlaceholder = agentName
|
||||
? t('ai.chat.placeholder').replace('{agent}', agentName)
|
||||
@@ -183,19 +182,23 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
return (
|
||||
<div className="shrink-0 px-4 pb-4">
|
||||
<PromptInput onSubmit={handleSubmit}>
|
||||
{/* Image attachment chips */}
|
||||
{images.length > 0 && (
|
||||
{/* File attachment chips */}
|
||||
{files.length > 0 && (
|
||||
<div className="flex gap-1.5 px-3 pt-2 pb-0.5 flex-wrap">
|
||||
{images.map((img) => (
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={img.id}
|
||||
key={file.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>
|
||||
{file.mediaType.startsWith('image/') ? (
|
||||
<ImageIcon size={11} className="text-muted-foreground/60 shrink-0" />
|
||||
) : (
|
||||
<FileText size={11} className="text-muted-foreground/60 shrink-0" />
|
||||
)}
|
||||
<span className="truncate max-w-[80px]">{file.filename}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveImage?.(img.id)}
|
||||
onClick={() => onRemoveFile?.(file.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} />
|
||||
@@ -213,7 +216,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) {
|
||||
onAddImages?.(Array.from(e.target.files));
|
||||
onAddFiles?.(Array.from(e.target.files));
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
@@ -226,7 +229,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
value={value}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
placeholder={placeholder || defaultPlaceholder}
|
||||
disabled={disabled || isStreaming}
|
||||
disabled={disabled}
|
||||
className={expanded ? 'max-h-[220px]' : undefined}
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
* No avatars. Thinking blocks are collapsible.
|
||||
*/
|
||||
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { AlertCircle, FileText, RotateCcw, X, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { ChatMessage } from '../../infrastructure/ai/types';
|
||||
import { Dialog, DialogContent, DialogTitle } from '../ui/dialog';
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
@@ -17,17 +18,124 @@ import {
|
||||
} 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';
|
||||
import {
|
||||
onApprovalRequest,
|
||||
onApprovalCleared,
|
||||
replayPendingApprovals,
|
||||
resolveApproval,
|
||||
type ApprovalRequest,
|
||||
} from '../../infrastructure/ai/shared/approvalGate';
|
||||
|
||||
interface ChatMessageListProps {
|
||||
messages: ChatMessage[];
|
||||
isStreaming?: boolean;
|
||||
onApprove?: (messageId: string) => void;
|
||||
onReject?: (messageId: string) => void;
|
||||
/** Active chat session ID — used to filter standalone MCP approval blocks */
|
||||
activeSessionId?: string | null;
|
||||
}
|
||||
|
||||
const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming, onApprove, onReject }) => {
|
||||
const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming, activeSessionId }) => {
|
||||
// Track pending approvals from the approval gate
|
||||
const [pendingApprovals, setPendingApprovals] = useState<Map<string, ApprovalRequest>>(new Map());
|
||||
const [resolvedApprovals, setResolvedApprovals] = useState<Map<string, boolean>>(new Map());
|
||||
|
||||
// Subscribe to approval gate events (SDK + MCP tool calls)
|
||||
useEffect(() => {
|
||||
const handler = (request: ApprovalRequest) => {
|
||||
setPendingApprovals(prev => new Map(prev).set(request.toolCallId, request));
|
||||
};
|
||||
const unsub = onApprovalRequest(handler);
|
||||
// Replay any approvals that fired while this component was unmounted
|
||||
replayPendingApprovals(handler);
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
// Subscribe to approval cleared/removed events (fired on session stop or timeout)
|
||||
useEffect(() => {
|
||||
return onApprovalCleared((clearedIds) => {
|
||||
setPendingApprovals(prev => {
|
||||
const m = new Map(prev);
|
||||
for (const id of clearedIds) m.delete(id);
|
||||
return m;
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleApprove = useCallback((toolCallId: string) => {
|
||||
resolveApproval(toolCallId, true);
|
||||
setPendingApprovals(prev => { const m = new Map(prev); m.delete(toolCallId); return m; });
|
||||
setResolvedApprovals(prev => new Map(prev).set(toolCallId, true));
|
||||
}, []);
|
||||
|
||||
const handleReject = useCallback((toolCallId: string) => {
|
||||
resolveApproval(toolCallId, false);
|
||||
setPendingApprovals(prev => { const m = new Map(prev); m.delete(toolCallId); return m; });
|
||||
setResolvedApprovals(prev => new Map(prev).set(toolCallId, false));
|
||||
}, []);
|
||||
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
|
||||
const [zoom, setZoom] = useState(100);
|
||||
const [dragged, setDragged] = useState(false);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const dragPos = useRef({ x: 0, y: 0 });
|
||||
const dragStart = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
||||
|
||||
const applyTransform = useCallback((z: number, x: number, y: number, animate: boolean) => {
|
||||
if (!imgRef.current) return;
|
||||
imgRef.current.style.transition = animate ? 'transform 0.25s ease' : 'none';
|
||||
imgRef.current.style.transform = `scale(${z / 100}) translate(${x / (z / 100)}px, ${y / (z / 100)}px)`;
|
||||
}, []);
|
||||
|
||||
const zoomRef = useRef(100);
|
||||
const setZoomAndRef = useCallback((fn: (z: number) => number) => {
|
||||
setZoom(z => { const nz = fn(z); zoomRef.current = nz; return nz; });
|
||||
}, []);
|
||||
const zoomIn = useCallback(() => setZoomAndRef(z => { const nz = Math.min(z + 25, 200); applyTransform(nz, dragPos.current.x, dragPos.current.y, true); return nz; }), [applyTransform, setZoomAndRef]);
|
||||
const zoomOut = useCallback(() => setZoomAndRef(z => { const nz = Math.max(z - 25, 25); applyTransform(nz, dragPos.current.x, dragPos.current.y, true); return nz; }), [applyTransform, setZoomAndRef]);
|
||||
|
||||
const onWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -10 : 10;
|
||||
setZoomAndRef(z => {
|
||||
const nz = Math.max(25, Math.min(200, z + delta));
|
||||
applyTransform(nz, dragPos.current.x, dragPos.current.y, false);
|
||||
return nz;
|
||||
});
|
||||
}, [applyTransform, setZoomAndRef]);
|
||||
const openPreview = useCallback((src: string, name: string) => {
|
||||
setZoom(100); zoomRef.current = 100;
|
||||
setDragged(false);
|
||||
dragPos.current = { x: 0, y: 0 };
|
||||
setPreview({ src, name });
|
||||
}, []);
|
||||
|
||||
const resetPreview = useCallback(() => {
|
||||
setZoom(100); zoomRef.current = 100;
|
||||
setDragged(false);
|
||||
dragPos.current = { x: 0, y: 0 };
|
||||
applyTransform(100, 0, 0, true);
|
||||
}, [applyTransform]);
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
dragStart.current = { startX: e.clientX, startY: e.clientY, origX: dragPos.current.x, origY: dragPos.current.y };
|
||||
}, []);
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragStart.current) return;
|
||||
if ((e.buttons & 1) === 0) { dragStart.current = null; return; }
|
||||
const x = dragStart.current.origX + (e.clientX - dragStart.current.startX);
|
||||
const y = dragStart.current.origY + (e.clientY - dragStart.current.startY);
|
||||
dragPos.current = { x, y };
|
||||
applyTransform(zoomRef.current, x, y, false);
|
||||
}, [applyTransform]);
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
if (dragStart.current && (dragPos.current.x !== 0 || dragPos.current.y !== 0)) {
|
||||
setDragged(true);
|
||||
}
|
||||
dragStart.current = null;
|
||||
}, []);
|
||||
const { t } = useI18n();
|
||||
const visibleMessages = messages.filter(m => m.role !== 'system');
|
||||
const resolvedToolCallIds = new Set(
|
||||
@@ -36,6 +144,18 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
.flatMap((m) => m.toolResults?.map((tr) => tr.toolCallId) ?? []),
|
||||
);
|
||||
|
||||
// Build maps from toolCallId → toolName / toolArgs for display
|
||||
const toolCallNames = new Map<string, string>();
|
||||
const toolCallArgs = new Map<string, Record<string, unknown>>();
|
||||
for (const m of visibleMessages) {
|
||||
if (m.role === 'assistant' && m.toolCalls) {
|
||||
for (const tc of m.toolCalls) {
|
||||
toolCallNames.set(tc.id, tc.name);
|
||||
if (tc.arguments) toolCallArgs.set(tc.id, tc.arguments);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (visibleMessages.length === 0 && !isStreaming) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center px-6">
|
||||
@@ -49,6 +169,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
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) => {
|
||||
@@ -58,7 +179,8 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
{message.toolResults?.map((tr) => (
|
||||
<ToolCall
|
||||
key={tr.toolCallId}
|
||||
name={tr.toolCallId}
|
||||
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
|
||||
args={toolCallArgs.get(tr.toolCallId)}
|
||||
result={tr.content}
|
||||
isError={tr.isError}
|
||||
/>
|
||||
@@ -83,16 +205,27 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* User images */}
|
||||
{isUser && message.images && message.images.length > 0 && (
|
||||
{/* User attachments (images, files) — fallback to legacy `images` field */}
|
||||
{isUser && (message.attachments ?? message.images)?.length && (
|
||||
<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"
|
||||
/>
|
||||
{(message.attachments ?? message.images)!.map((att, i) => (
|
||||
att.mediaType.startsWith('image/') ? (
|
||||
<img
|
||||
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
|
||||
src={`data:${att.mediaType};base64,${att.base64Data}`}
|
||||
alt={att.filename || 'image'}
|
||||
className="max-h-[120px] max-w-[200px] rounded-md object-contain border border-border/20 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => openPreview(`data:${att.mediaType};base64,${att.base64Data}`, att.filename || 'image')}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-2 rounded-md bg-muted/20 border border-border/20 text-[11px] text-foreground/70"
|
||||
>
|
||||
<FileText size={12} className="text-muted-foreground/60 shrink-0" />
|
||||
<span className="truncate max-w-[120px]">{att.filename || 'file'}</span>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -106,26 +239,30 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
)}
|
||||
|
||||
{/* Tool calls */}
|
||||
{message.toolCalls?.map((tc) => (
|
||||
<ToolCall
|
||||
key={tc.id}
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isLoading={isThisStreaming && message.executionStatus === 'running'}
|
||||
isInterrupted={message.executionStatus === 'cancelled' && !resolvedToolCallIds.has(tc.id)}
|
||||
/>
|
||||
))}
|
||||
{message.toolCalls?.map((tc) => {
|
||||
const isPending = pendingApprovals.has(tc.id);
|
||||
const resolved = resolvedApprovals.get(tc.id);
|
||||
const approvalStatus = isPending
|
||||
? 'pending' as const
|
||||
: resolved === true
|
||||
? 'approved' as const
|
||||
: resolved === false
|
||||
? 'denied' as const
|
||||
: undefined;
|
||||
|
||||
{/* 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)}
|
||||
/>
|
||||
)}
|
||||
return (
|
||||
<ToolCall
|
||||
key={tc.id}
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isLoading={isThisStreaming && message.executionStatus === 'running' && !isPending}
|
||||
isInterrupted={message.executionStatus === 'cancelled' && !resolvedToolCallIds.has(tc.id)}
|
||||
approvalStatus={approvalStatus}
|
||||
onApprove={() => handleApprove(tc.id)}
|
||||
onReject={() => handleReject(tc.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Status text with shimmer */}
|
||||
{message.statusText && (
|
||||
@@ -153,6 +290,24 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Standalone MCP/ACP approval requests (not tied to SDK tool calls) */}
|
||||
{Array.from(pendingApprovals.entries())
|
||||
.filter((entry) => entry[0].startsWith('mcp_approval_') && (!activeSessionId || entry[1].chatSessionId === activeSessionId))
|
||||
.map((entry) => {
|
||||
const [id, req] = entry;
|
||||
return (
|
||||
<ToolCall
|
||||
key={id}
|
||||
name={req.toolName}
|
||||
args={req.args}
|
||||
isLoading={false}
|
||||
isInterrupted={false}
|
||||
approvalStatus={'pending'}
|
||||
onApprove={() => handleApprove(id)}
|
||||
onReject={() => handleReject(id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* Streaming indicator — only when no content and no thinking yet */}
|
||||
{isStreaming && !lastAssistantMessage?.content && !lastAssistantMessage?.thinking && (
|
||||
<div className="flex items-center gap-1 py-2">
|
||||
@@ -164,13 +319,89 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
|
||||
{/* Image preview lightbox */}
|
||||
<Dialog open={!!preview} onOpenChange={(open) => { if (!open) setPreview(null); }}>
|
||||
<DialogContent
|
||||
hideCloseButton
|
||||
className="max-w-[min(90vw,800px)] max-h-[min(90vh,700px)] min-w-[280px] min-h-[200px] w-fit p-0 gap-0 focus:outline-none shadow-2xl"
|
||||
>
|
||||
{/* Title bar: filename | zoom controls | close — all in one flex row */}
|
||||
<div className="flex items-center h-10 px-3 border-b border-border/40 gap-2 shrink-0">
|
||||
<DialogTitle className="text-sm font-medium truncate flex-1">{preview?.name}</DialogTitle>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={resetPreview}
|
||||
disabled={zoom === 100 && !dragged}
|
||||
className="p-1 rounded hover:bg-muted disabled:opacity-30 transition-colors text-muted-foreground"
|
||||
aria-label={t('common.reset')}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<div className="w-px h-3.5 bg-border/40 mx-0.5" />
|
||||
<button
|
||||
onClick={zoomOut}
|
||||
disabled={zoom <= 25}
|
||||
className="p-1 rounded hover:bg-muted disabled:opacity-30 transition-colors text-muted-foreground"
|
||||
aria-label={t('common.zoomOut')}
|
||||
>
|
||||
<ZoomOut size={14} />
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground tabular-nums w-9 text-center select-none">{zoom}%</span>
|
||||
<button
|
||||
onClick={zoomIn}
|
||||
disabled={zoom >= 200}
|
||||
className="p-1 rounded hover:bg-muted disabled:opacity-30 transition-colors text-muted-foreground"
|
||||
aria-label={t('common.zoomIn')}
|
||||
>
|
||||
<ZoomIn size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPreview(null)}
|
||||
className="p-1 rounded hover:bg-muted transition-colors text-muted-foreground shrink-0"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Image area with drag support */}
|
||||
{preview && (
|
||||
<div
|
||||
className="overflow-hidden flex items-center justify-center"
|
||||
style={{
|
||||
height: 'calc(min(90vh, 700px) - 40px)',
|
||||
cursor: 'grab',
|
||||
// Clamp aspect ratio: if image is extremely tall/wide, the container
|
||||
// constrains it; object-contain handles the rest.
|
||||
aspectRatio: 'auto',
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
onWheel={onWheel}
|
||||
onLostPointerCapture={endDrag}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={preview.src}
|
||||
alt={preview.name}
|
||||
draggable={false}
|
||||
className="select-none max-w-full max-h-full object-contain"
|
||||
style={{ transition: 'transform 0.25s ease' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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.activeSessionId !== next.activeSessionId) return false;
|
||||
if (prev.messages.length !== next.messages.length) return false;
|
||||
if (prev.messages === next.messages) return true;
|
||||
|
||||
@@ -188,7 +419,6 @@ function areMessagesEqual(prev: ChatMessageListProps, next: ChatMessageListProps
|
||||
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
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
@@ -1,193 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
@@ -1,200 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
@@ -16,7 +16,9 @@ import type {
|
||||
AIPermissionMode,
|
||||
AISession,
|
||||
ChatMessage,
|
||||
ChatMessageAttachment,
|
||||
ExternalAgentConfig,
|
||||
ProviderAdvancedParams,
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from '../../../infrastructure/ai/types';
|
||||
@@ -63,16 +65,30 @@ interface ToolResultChunk {
|
||||
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>;
|
||||
};
|
||||
/** Detect tool results that represent errors/denials (e.g. `{ error: "..." }` or `{ ok: false }`) */
|
||||
function isToolResultError(output: unknown): boolean {
|
||||
if (output == null) return false;
|
||||
|
||||
if (typeof output === 'object') {
|
||||
const obj = output as Record<string, unknown>;
|
||||
// Check for explicit error objects
|
||||
if ('error' in obj && typeof obj.error === 'string') return true;
|
||||
if ('ok' in obj && obj.ok === false) return true;
|
||||
}
|
||||
|
||||
// Check stringified JSON (common for tool result wrapping)
|
||||
if (typeof output === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(output);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const parsedObj = parsed as Record<string, unknown>;
|
||||
if ('error' in parsedObj && typeof parsedObj.error === 'string') return true;
|
||||
if ('ok' in parsedObj && parsedObj.ok === false) return true;
|
||||
}
|
||||
} catch { /* not JSON, not an error */ }
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Shape of an error chunk from the Vercel AI SDK fullStream. */
|
||||
@@ -87,9 +103,8 @@ type StreamChunk =
|
||||
| ReasoningChunk
|
||||
| ToolCallChunk
|
||||
| ToolResultChunk
|
||||
| ToolApprovalRequestChunk
|
||||
| ErrorChunk
|
||||
| { type: 'reasoning-end' | 'text-start' | 'text-end' | 'start' | 'finish' | 'start-step' | 'finish-step' };
|
||||
| { type: 'reasoning-end' | 'text-start' | 'text-end' | 'start' | 'finish' | 'start-step' | 'finish-step' | 'tool-approval-request' };
|
||||
|
||||
/** Shape of the netcatty bridge exposed on `window` (panel-specific subset). */
|
||||
export interface PanelBridge extends NetcattyBridge {
|
||||
@@ -109,6 +124,9 @@ export interface TerminalSessionInfo {
|
||||
label: string;
|
||||
os?: string;
|
||||
username?: string;
|
||||
protocol?: string;
|
||||
shellType?: string;
|
||||
deviceType?: string;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
@@ -118,27 +136,8 @@ export function getNetcattyBridge(): PanelBridge | undefined {
|
||||
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>;
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeLabel?: string;
|
||||
getExecutorContext: () => ExecutorContext;
|
||||
}
|
||||
// ApprovalInfo and PendingApprovalContext removed — approval is now handled
|
||||
// inside the tool's execute function via the approvalGate module.
|
||||
|
||||
function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -180,7 +179,7 @@ export interface UseAIChatStreamingReturn {
|
||||
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. */
|
||||
/** Process a Catty agent stream. */
|
||||
processCattyStream: (
|
||||
streamSessionId: string,
|
||||
model: ReturnType<typeof createModelFromConfig>,
|
||||
@@ -189,7 +188,8 @@ export interface UseAIChatStreamingReturn {
|
||||
sdkMessages: Array<ModelMessage>,
|
||||
signal: AbortSignal,
|
||||
currentAssistantMsgId: string,
|
||||
) => Promise<ApprovalInfo | null>;
|
||||
advancedParams?: ProviderAdvancedParams,
|
||||
) => Promise<void>;
|
||||
/** Send a message to the Catty agent (built-in). */
|
||||
sendToCattyAgent: (
|
||||
sessionId: string,
|
||||
@@ -199,6 +199,7 @@ export interface UseAIChatStreamingReturn {
|
||||
currentSession: AISession | undefined,
|
||||
assistantMsgId: string,
|
||||
context: SendToCattyContext,
|
||||
attachments?: ChatMessageAttachment[],
|
||||
) => Promise<void>;
|
||||
/** Send a message to an external agent (ACP or raw process). */
|
||||
sendToExternalAgent: (
|
||||
@@ -225,7 +226,6 @@ export interface SendToCattyContext {
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
getExecutorContext?: () => ExecutorContext;
|
||||
setPendingApproval: (ctx: PendingApprovalContext | null) => void;
|
||||
autoTitleSession: (sessionId: string, text: string) => void;
|
||||
}
|
||||
|
||||
@@ -323,7 +323,8 @@ export function useAIChatStreaming({
|
||||
sdkMessages: Array<ModelMessage>,
|
||||
signal: AbortSignal,
|
||||
currentAssistantMsgId: string,
|
||||
): Promise<ApprovalInfo | null> => {
|
||||
advancedParams?: ProviderAdvancedParams,
|
||||
): Promise<void> => {
|
||||
const result = streamText({
|
||||
model,
|
||||
messages: sdkMessages,
|
||||
@@ -331,13 +332,17 @@ export function useAIChatStreaming({
|
||||
tools,
|
||||
stopWhen: stepCountIs(maxIterations),
|
||||
abortSignal: signal,
|
||||
...(advancedParams?.maxTokens != null && { maxOutputTokens: advancedParams.maxTokens }),
|
||||
...(advancedParams?.temperature != null && { temperature: advancedParams.temperature }),
|
||||
...(advancedParams?.topP != null && { topP: advancedParams.topP }),
|
||||
...(advancedParams?.frequencyPenalty != null && { frequencyPenalty: advancedParams.frequencyPenalty }),
|
||||
...(advancedParams?.presencePenalty != null && { presencePenalty: advancedParams.presencePenalty }),
|
||||
});
|
||||
|
||||
// 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 = '';
|
||||
@@ -455,6 +460,7 @@ export function useAIChatStreaming({
|
||||
? { ...msg, executionStatus: 'completed', statusText: undefined } : msg,
|
||||
);
|
||||
const toolOutput = typedChunk.output ?? typedChunk.result;
|
||||
const toolError = isToolResultError(toolOutput);
|
||||
addMessageToSession(streamSessionId, {
|
||||
id: generateId(),
|
||||
role: 'tool',
|
||||
@@ -464,7 +470,7 @@ export function useAIChatStreaming({
|
||||
content: typeof toolOutput === 'string'
|
||||
? toolOutput
|
||||
: JSON.stringify(toolOutput),
|
||||
isError: false,
|
||||
isError: toolError,
|
||||
}],
|
||||
timestamp: Date.now(),
|
||||
executionStatus: 'completed',
|
||||
@@ -472,25 +478,9 @@ export function useAIChatStreaming({
|
||||
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;
|
||||
}
|
||||
// tool-approval-request is no longer handled here — approval is now
|
||||
// inside the tool's execute function via the approvalGate module.
|
||||
// The SDK may still emit this chunk type but we simply ignore it.
|
||||
case 'error': {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
@@ -522,7 +512,7 @@ export function useAIChatStreaming({
|
||||
flushText();
|
||||
reader.releaseLock();
|
||||
}
|
||||
return pendingApprovalInfo;
|
||||
return;
|
||||
}, [maxIterations, addMessageToSession, updateMessageById]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -592,23 +582,29 @@ export function useAIChatStreaming({
|
||||
...msg, thinkingDurationMs: msg.thinkingDurationMs || (Date.now() - msg.timestamp),
|
||||
}));
|
||||
},
|
||||
onToolCall: (toolName: string, args: Record<string, unknown>) => {
|
||||
onToolCall: (toolName: string, args: Record<string, unknown>, toolCallId?: string) => {
|
||||
maybeCreateAssistantMsg();
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg,
|
||||
toolCalls: [...(msg.toolCalls || []), { id: `tc_${Date.now()}`, name: toolName, arguments: args }],
|
||||
toolCalls: [...(msg.toolCalls || []), { id: toolCallId || `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,
|
||||
);
|
||||
onToolResult: (toolCallId: string, result: string, toolName?: string) => {
|
||||
updateLastMessage(sessionId, msg => {
|
||||
if (msg.role !== 'assistant' || msg.executionStatus !== 'running') return msg;
|
||||
// Only patch tool call name if the existing name is missing/generic
|
||||
// (don't overwrite a good name from onToolCall with a wrapper name from tool-result)
|
||||
const updatedToolCalls = toolName && !toolName.includes('acp_provider_agent_dynamic_tool') && msg.toolCalls
|
||||
? msg.toolCalls.map(tc => tc.id === toolCallId && !tc.name ? { ...tc, name: toolName } : tc)
|
||||
: msg.toolCalls;
|
||||
return { ...msg, toolCalls: updatedToolCalls, executionStatus: 'completed', statusText: undefined };
|
||||
});
|
||||
const toolError = isToolResultError(result);
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'tool', content: '',
|
||||
toolResults: [{ toolCallId, content: result, isError: false }],
|
||||
toolResults: [{ toolCallId, content: result, isError: toolError }],
|
||||
timestamp: Date.now(), executionStatus: 'completed',
|
||||
});
|
||||
needsNewAssistantMsg = true;
|
||||
@@ -668,6 +664,7 @@ export function useAIChatStreaming({
|
||||
currentSession: AISession | undefined,
|
||||
assistantMsgId: string,
|
||||
context: SendToCattyContext,
|
||||
attachments?: ChatMessageAttachment[],
|
||||
) => {
|
||||
const bridge = getNetcattyBridge();
|
||||
const getExecutorContext = context.getExecutorContext ?? (() => ({
|
||||
@@ -681,13 +678,19 @@ export function useAIChatStreaming({
|
||||
context.commandBlocklist,
|
||||
context.globalPermissionMode,
|
||||
context.webSearchConfig ?? undefined,
|
||||
sessionId,
|
||||
);
|
||||
|
||||
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,
|
||||
os: s.os,
|
||||
username: s.username,
|
||||
protocol: s.protocol,
|
||||
shellType: s.shellType,
|
||||
deviceType: s.deviceType,
|
||||
connected: s.connected,
|
||||
})),
|
||||
permissionMode: context.globalPermissionMode,
|
||||
webSearchEnabled: isWebSearchReady(context.webSearchConfig),
|
||||
@@ -741,7 +744,22 @@ export function useAIChatStreaming({
|
||||
const sdkMessages: Array<ModelMessage> = [];
|
||||
for (const m of allMessages) {
|
||||
if (m.role === 'user') {
|
||||
sdkMessages.push({ role: 'user', content: m.content });
|
||||
// Build multimodal content when attachments are present (fallback to legacy `images` field)
|
||||
const messageAttachments = m.attachments ?? m.images;
|
||||
if (messageAttachments?.length) {
|
||||
const parts: Array<{ type: 'text'; text: string } | { type: 'image'; image: string; mediaType?: string } | { type: 'file'; data: string; mediaType: string; filename?: string }> = [];
|
||||
parts.push({ type: 'text', text: m.content });
|
||||
for (const att of messageAttachments) {
|
||||
if (att.mediaType.startsWith('image/')) {
|
||||
parts.push({ type: 'image', image: att.base64Data, mediaType: att.mediaType });
|
||||
} else {
|
||||
parts.push({ type: 'file', data: att.base64Data, mediaType: att.mediaType, filename: att.filename });
|
||||
}
|
||||
}
|
||||
sdkMessages.push({ role: 'user', content: parts });
|
||||
} else {
|
||||
sdkMessages.push({ role: 'user', content: m.content });
|
||||
}
|
||||
} else if (m.role === 'assistant') {
|
||||
if (m.toolCalls?.length) {
|
||||
// Only include tool calls that have matching results
|
||||
@@ -780,25 +798,23 @@ export function useAIChatStreaming({
|
||||
});
|
||||
}
|
||||
}
|
||||
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,
|
||||
scopeType: context.scopeType,
|
||||
scopeLabel: context.scopeLabel,
|
||||
getExecutorContext,
|
||||
});
|
||||
return; // Keep streaming flag — waiting for user approval
|
||||
// Build the current user message — include attachments as multimodal content
|
||||
if (attachments?.length) {
|
||||
const parts: Array<{ type: 'text'; text: string } | { type: 'image'; image: string; mediaType?: string } | { type: 'file'; data: string; mediaType: string; filename?: string }> = [];
|
||||
parts.push({ type: 'text', text: trimmed });
|
||||
for (const att of attachments) {
|
||||
if (att.mediaType.startsWith('image/')) {
|
||||
parts.push({ type: 'image', image: att.base64Data, mediaType: att.mediaType });
|
||||
} else {
|
||||
parts.push({ type: 'file', data: att.base64Data, mediaType: att.mediaType, filename: att.filename });
|
||||
}
|
||||
}
|
||||
sdkMessages.push({ role: 'user', content: parts });
|
||||
} else {
|
||||
sdkMessages.push({ role: 'user', content: trimmed });
|
||||
}
|
||||
|
||||
await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId, context.activeProvider?.advancedParams);
|
||||
} catch (err) {
|
||||
console.error('[Catty] streamText error:', err);
|
||||
reportStreamError(sessionId, abortController.signal, err);
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
WebSearchConfig,
|
||||
} from '../../../infrastructure/ai/types';
|
||||
import { isWebSearchReady } 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,
|
||||
} 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)}`;
|
||||
}
|
||||
|
||||
let sharedPendingApprovalContext: PendingApprovalContext | null = null;
|
||||
let sharedPendingApprovalTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 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 {
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
commandBlocklist?: string[];
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 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>(sharedPendingApprovalContext);
|
||||
pendingApprovalContextRef.current = sharedPendingApprovalContext;
|
||||
|
||||
/** Set pending approval context with a 5-minute auto-clear timeout. */
|
||||
const setPendingApproval = useCallback((ctx: PendingApprovalContext | null) => {
|
||||
// Clear any existing timeout
|
||||
if (sharedPendingApprovalTimeout) {
|
||||
clearTimeout(sharedPendingApprovalTimeout);
|
||||
sharedPendingApprovalTimeout = null;
|
||||
}
|
||||
sharedPendingApprovalContext = ctx;
|
||||
pendingApprovalContextRef.current = ctx;
|
||||
if (ctx) {
|
||||
sharedPendingApprovalTimeout = setTimeout(() => {
|
||||
// Auto-clear after 5 minutes if user never responds
|
||||
if (sharedPendingApprovalContext?.sessionId === ctx.sessionId) {
|
||||
sharedPendingApprovalContext = null;
|
||||
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(),
|
||||
});
|
||||
}
|
||||
sharedPendingApprovalTimeout = 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,
|
||||
scopeType,
|
||||
scopeLabel,
|
||||
getExecutorContext,
|
||||
} = 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 settings, while keeping the original AI scope pinned to its workspace/session.
|
||||
const bridge = getNetcattyBridge();
|
||||
const freshExecutorContext = getExecutorContext();
|
||||
const freshTools = createCattyTools(
|
||||
bridge,
|
||||
getExecutorContext,
|
||||
approvalContext.commandBlocklist,
|
||||
approvalContext.globalPermissionMode,
|
||||
approvalContext.webSearchConfig ?? undefined,
|
||||
);
|
||||
const freshSystemPrompt = buildSystemPrompt({
|
||||
scopeType,
|
||||
scopeLabel,
|
||||
hosts: freshExecutorContext.sessions.map(s => ({
|
||||
sessionId: s.sessionId, hostname: s.hostname, label: s.label,
|
||||
os: s.os, username: s.username, connected: s.connected,
|
||||
})),
|
||||
permissionMode: approvalContext.globalPermissionMode,
|
||||
webSearchEnabled: isWebSearchReady(approvalContext.webSearchConfig),
|
||||
});
|
||||
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,
|
||||
scopeType,
|
||||
scopeLabel,
|
||||
getExecutorContext,
|
||||
});
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -25,6 +25,8 @@ export default function SettingsAppearanceTab(props: {
|
||||
setUiLanguage: (language: string) => void;
|
||||
customCSS: string;
|
||||
setCustomCSS: (css: string) => void;
|
||||
isImmersive?: boolean;
|
||||
onToggleImmersive?: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const availableUIFonts = useAvailableUIFonts();
|
||||
@@ -45,6 +47,8 @@ export default function SettingsAppearanceTab(props: {
|
||||
setUiLanguage,
|
||||
customCSS,
|
||||
setCustomCSS,
|
||||
isImmersive,
|
||||
onToggleImmersive,
|
||||
} = props;
|
||||
|
||||
const getHslStyle = useCallback((hsl: string) => ({ backgroundColor: `hsl(${hsl})` }), []);
|
||||
@@ -254,6 +258,19 @@ export default function SettingsAppearanceTab(props: {
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.appearance.immersiveMode")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
label={t("settings.appearance.immersiveMode")}
|
||||
description={t("settings.appearance.immersiveMode.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={!!isImmersive}
|
||||
onChange={() => onToggleImmersive?.()}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.appearance.customCss")} />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -29,13 +29,10 @@ const getOpenerLabel = (
|
||||
export default function SettingsFileAssociationsTab() {
|
||||
const { t } = useI18n();
|
||||
const { getAllAssociations, removeAssociation, setOpenerForExtension } = useSftpFileAssociations();
|
||||
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload } = useSettingsState();
|
||||
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload, sftpAutoOpenSidebar, setSftpAutoOpenSidebar } = useSettingsState();
|
||||
const associations = getAllAssociations();
|
||||
const [editingExtension, setEditingExtension] = useState<string | null>(null);
|
||||
|
||||
// Debug log for Settings page
|
||||
console.log('[SettingsFileAssociationsTab] Rendering with', associations.length, 'associations:', associations);
|
||||
|
||||
const handleRemove = useCallback((extension: string) => {
|
||||
if (confirm(t('settings.sftpFileAssociations.removeConfirm', { ext: extension === 'file' ? t('sftp.opener.noExtension') : extension }))) {
|
||||
removeAssociation(extension);
|
||||
@@ -253,6 +250,46 @@ export default function SettingsFileAssociationsTab() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Auto-open sidebar section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftp.autoOpenSidebar')} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.autoOpenSidebar.desc')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSftpAutoOpenSidebar(!sftpAutoOpenSidebar)}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
sftpAutoOpenSidebar
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
sftpAutoOpenSidebar
|
||||
? "border-primary bg-primary"
|
||||
: "border-muted-foreground/30"
|
||||
)}>
|
||||
{sftpAutoOpenSidebar && (
|
||||
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{t('settings.sftp.autoOpenSidebar.enable')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.autoOpenSidebar.enableDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* File associations section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftpFileAssociations.title')} />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useCallback } from "react";
|
||||
import type { PortForwardingRule } from "../../../domain/models";
|
||||
import type { SyncPayload } from "../../../domain/sync";
|
||||
import { buildSyncPayload, applySyncPayload } from "../../../domain/syncPayload";
|
||||
import type { SyncableVaultData } from "../../../domain/syncPayload";
|
||||
import { buildSyncPayload, applySyncPayload } from "../../../application/syncPayload";
|
||||
import type { SyncableVaultData } from "../../../application/syncPayload";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { getEffectiveKnownHosts } from "../../../infrastructure/syncHelpers";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Settings System Tab - System information, temp file management, session logs, and global hotkey
|
||||
*/
|
||||
import { Download, ExternalLink, FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
|
||||
import { AlertTriangle, ChevronDown, ChevronRight, Download, ExternalLink, FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection";
|
||||
@@ -13,6 +13,31 @@ import { Button } from "../../ui/button";
|
||||
import { Toggle, Select, SettingRow } from "../settings-ui";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
interface CrashLogFile {
|
||||
fileName: string;
|
||||
date: string;
|
||||
size: number;
|
||||
entryCount: number;
|
||||
}
|
||||
|
||||
interface CrashLogEntry {
|
||||
timestamp: string;
|
||||
source: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
errorMeta?: Record<string, unknown>;
|
||||
extra?: Record<string, unknown>;
|
||||
pid?: number;
|
||||
platform?: string;
|
||||
arch?: string;
|
||||
version?: string;
|
||||
electronVersion?: string;
|
||||
osVersion?: string;
|
||||
memoryMB?: { rss: number; heapUsed: number; heapTotal: number };
|
||||
activeSessionCount?: number;
|
||||
uptimeSeconds?: number;
|
||||
}
|
||||
|
||||
interface TempDirInfo {
|
||||
path: string;
|
||||
fileCount: number;
|
||||
@@ -64,6 +89,7 @@ interface SettingsSystemTabProps {
|
||||
checkNow: () => Promise<unknown>;
|
||||
installUpdate: () => void;
|
||||
openReleasePage: () => void;
|
||||
startDownload: () => void;
|
||||
}
|
||||
|
||||
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
@@ -86,6 +112,7 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
checkNow,
|
||||
installUpdate,
|
||||
openReleasePage,
|
||||
startDownload,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
|
||||
@@ -98,6 +125,12 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
const [hotkeyError, setHotkeyError] = useState<string | null>(null);
|
||||
const [credentialsAvailable, setCredentialsAvailable] = useState<boolean | null>(null);
|
||||
const [isCheckingCredentials, setIsCheckingCredentials] = useState(false);
|
||||
const [crashLogs, setCrashLogs] = useState<CrashLogFile[]>([]);
|
||||
const [isLoadingCrashLogs, setIsLoadingCrashLogs] = useState(false);
|
||||
const [expandedLog, setExpandedLog] = useState<string | null>(null);
|
||||
const [logEntries, setLogEntries] = useState<CrashLogEntry[]>([]);
|
||||
const [isClearingCrashLogs, setIsClearingCrashLogs] = useState(false);
|
||||
const [crashLogClearResult, setCrashLogClearResult] = useState<{ deletedCount: number } | null>(null);
|
||||
|
||||
const [appVersion, setAppVersion] = useState('');
|
||||
|
||||
@@ -144,6 +177,73 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
void loadCredentialProtectionStatus();
|
||||
}, [loadCredentialProtectionStatus]);
|
||||
|
||||
const loadCrashLogs = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getCrashLogs) return;
|
||||
setIsLoadingCrashLogs(true);
|
||||
try {
|
||||
const logs = await bridge.getCrashLogs();
|
||||
setCrashLogs(logs);
|
||||
} catch (err) {
|
||||
console.error("[SettingsSystemTab] Failed to load crash logs:", err);
|
||||
} finally {
|
||||
setIsLoadingCrashLogs(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCrashLogs();
|
||||
}, [loadCrashLogs]);
|
||||
|
||||
const expandRequestRef = React.useRef(0);
|
||||
const handleExpandCrashLog = useCallback(async (fileName: string) => {
|
||||
if (expandedLog === fileName) {
|
||||
setExpandedLog(null);
|
||||
setLogEntries([]);
|
||||
return;
|
||||
}
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.readCrashLog) return;
|
||||
const requestId = ++expandRequestRef.current;
|
||||
// Optimistically show expanded state while loading
|
||||
setExpandedLog(fileName);
|
||||
setLogEntries([]);
|
||||
try {
|
||||
const entries = await bridge.readCrashLog(fileName);
|
||||
// Discard if user clicked a different file while awaiting
|
||||
if (expandRequestRef.current !== requestId) return;
|
||||
setLogEntries(entries);
|
||||
} catch (err) {
|
||||
if (expandRequestRef.current !== requestId) return;
|
||||
console.error("[SettingsSystemTab] Failed to read crash log:", err);
|
||||
}
|
||||
}, [expandedLog]);
|
||||
|
||||
const handleClearCrashLogs = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.clearCrashLogs) return;
|
||||
setIsClearingCrashLogs(true);
|
||||
setCrashLogClearResult(null);
|
||||
try {
|
||||
const result = await bridge.clearCrashLogs();
|
||||
setCrashLogClearResult(result);
|
||||
setExpandedLog(null);
|
||||
setLogEntries([]);
|
||||
// Reload the list so partial failures still show remaining files
|
||||
await loadCrashLogs();
|
||||
} catch (err) {
|
||||
console.error("[SettingsSystemTab] Failed to clear crash logs:", err);
|
||||
} finally {
|
||||
setIsClearingCrashLogs(false);
|
||||
}
|
||||
}, [loadCrashLogs]);
|
||||
|
||||
const handleOpenCrashLogsDir = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.openCrashLogsDir) return;
|
||||
await bridge.openCrashLogsDir();
|
||||
}, []);
|
||||
|
||||
const handleClearTempFiles = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.clearTempDir) return;
|
||||
@@ -365,7 +465,16 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Open releases — shown when update found on unsupported platform, or on check error */}
|
||||
{/* Download button — shown when update found and no download in progress */}
|
||||
{updateState.autoDownloadStatus === 'idle' &&
|
||||
updateState.manualCheckStatus === 'available' && (
|
||||
<Button variant="outline" size="sm" onClick={startDownload}>
|
||||
<Download size={14} className="mr-1.5" />
|
||||
{t('update.downloadNow')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Open releases — fallback for unsupported platforms or check errors */}
|
||||
{updateState.autoDownloadStatus === 'idle' &&
|
||||
(updateState.manualCheckStatus === 'available' || updateState.manualCheckStatus === 'error' || (updateState.manualCheckStatus === 'idle' && updateState.hasUpdate)) && (
|
||||
<Button variant="ghost" size="sm" onClick={openReleasePage}>
|
||||
@@ -449,6 +558,148 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Crash Logs Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t("settings.system.crashLogs.title")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.system.crashLogs.description")}
|
||||
</p>
|
||||
|
||||
{crashLogs.length === 0 && !isLoadingCrashLogs && (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
{t("settings.system.crashLogs.noLogs")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{crashLogs.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{crashLogs.map((log) => (
|
||||
<div key={log.fileName} className="border border-border/60 rounded-md overflow-hidden">
|
||||
<button
|
||||
onClick={() => handleExpandCrashLog(log.fileName)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedLog === log.fileName ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span className="font-mono">{log.date}</span>
|
||||
<span className="text-muted-foreground">
|
||||
({t("settings.system.crashLogs.entries").replace("{count}", String(log.entryCount))})
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{formatBytes(log.size)}</span>
|
||||
</button>
|
||||
|
||||
{expandedLog === log.fileName && logEntries.length > 0 && (
|
||||
<div className="border-t border-border/60 max-h-64 overflow-y-auto">
|
||||
{logEntries.map((entry, idx) => (
|
||||
<div key={idx} className="px-3 py-2 text-xs border-b border-border/30 last:border-b-0 space-y-1">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{new Date(entry.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
<span className="px-1.5 py-0.5 rounded bg-destructive/10 text-destructive font-medium">
|
||||
{entry.source}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-mono break-all">{entry.message}</p>
|
||||
{entry.errorMeta && Object.keys(entry.errorMeta).length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{Object.entries(entry.errorMeta).map(([k, v]) => (
|
||||
<span key={k} className="px-1.5 py-0.5 rounded bg-muted font-mono">
|
||||
{k}={String(v)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{entry.extra && Object.keys(entry.extra).length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{Object.entries(entry.extra).map(([k, v]) => (
|
||||
<span key={k} className="px-1.5 py-0.5 rounded bg-muted font-mono">
|
||||
{k}={String(v)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const parts: string[] = [];
|
||||
if (entry.version) parts.push(`v${entry.version}`);
|
||||
if (entry.electronVersion) parts.push(`Electron ${entry.electronVersion}`);
|
||||
if (entry.platform) parts.push(`${entry.platform}/${entry.arch}`);
|
||||
if (entry.osVersion) parts.push(`OS ${entry.osVersion}`);
|
||||
if (entry.pid) parts.push(`PID ${entry.pid}`);
|
||||
if (entry.activeSessionCount != null && entry.activeSessionCount >= 0) parts.push(`Sessions: ${entry.activeSessionCount}`);
|
||||
if (entry.memoryMB) parts.push(`RAM: ${entry.memoryMB.rss}MB`);
|
||||
if (entry.uptimeSeconds != null) parts.push(`Uptime: ${entry.uptimeSeconds}s`);
|
||||
const text = parts.join(' ');
|
||||
return text ? (
|
||||
<div className="text-muted-foreground truncate" title={text}>
|
||||
{text}
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
{entry.stack && (
|
||||
<pre className="mt-1 p-2 bg-muted rounded text-[11px] leading-relaxed overflow-x-auto whitespace-pre-wrap break-all text-muted-foreground">
|
||||
{entry.stack}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadCrashLogs}
|
||||
disabled={isLoadingCrashLogs}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw size={14} className={isLoadingCrashLogs ? "animate-spin" : ""} />
|
||||
{t("settings.system.refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearCrashLogs}
|
||||
disabled={isClearingCrashLogs || crashLogs.length === 0}
|
||||
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("settings.system.crashLogs.clear")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleOpenCrashLogsDir}
|
||||
title={t("settings.system.openFolder")}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{crashLogClearResult && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.system.crashLogs.cleared").replace("{count}", String(crashLogClearResult.deletedCount))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.system.crashLogs.hint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Temp Directory Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -114,23 +114,33 @@ export default function SettingsTerminalTab(props: {
|
||||
|| TERMINAL_THEMES[0];
|
||||
}, [terminalThemeId, customThemes]);
|
||||
|
||||
const handleAutocompleteGhostTextChange = useCallback((enabled: boolean) => {
|
||||
updateTerminalSetting("autocompleteGhostText", enabled);
|
||||
if (enabled) {
|
||||
updateTerminalSetting("autocompletePopupMenu", false);
|
||||
}
|
||||
}, [updateTerminalSetting]);
|
||||
|
||||
const handleAutocompletePopupMenuChange = useCallback((enabled: boolean) => {
|
||||
updateTerminalSetting("autocompletePopupMenu", enabled);
|
||||
if (enabled) {
|
||||
updateTerminalSetting("autocompleteGhostText", false);
|
||||
}
|
||||
}, [updateTerminalSetting]);
|
||||
|
||||
// Import .itermcolors file
|
||||
const importFileRef = useRef<HTMLInputElement>(null);
|
||||
const handleImportItermcolors = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) {
|
||||
console.log('[Settings] No file selected');
|
||||
return;
|
||||
}
|
||||
console.log('[Settings] File selected:', file.name, 'size:', file.size);
|
||||
const name = file.name.replace(/\.(itermcolors|xml)$/i, '');
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const xml = reader.result as string;
|
||||
console.log('[Settings] File read successfully, length:', xml.length);
|
||||
const parsed = parseItermcolors(xml, name);
|
||||
if (parsed) {
|
||||
console.log('[Settings] Theme parsed successfully:', parsed.id, parsed.name);
|
||||
customThemeStore.addTheme(parsed);
|
||||
setTerminalThemeId(parsed.id);
|
||||
} else {
|
||||
@@ -620,6 +630,13 @@ export default function SettingsTerminalTab(props: {
|
||||
<Toggle checked={terminalSettings.scrollOnPaste} onChange={(v) => updateTerminalSetting("scrollOnPaste", v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.smoothScrolling")}
|
||||
description={t("settings.terminal.behavior.smoothScrolling.desc")}
|
||||
>
|
||||
<Toggle checked={terminalSettings.smoothScrolling} onChange={(v) => updateTerminalSetting("smoothScrolling", v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.linkModifier")}
|
||||
description={t("settings.terminal.behavior.linkModifier.desc")}
|
||||
@@ -848,6 +865,39 @@ export default function SettingsTerminalTab(props: {
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
{/* Autocomplete */}
|
||||
<SectionHeader title={t("settings.terminal.section.autocomplete")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
label={t("settings.terminal.autocomplete.enabled")}
|
||||
description={t("settings.terminal.autocomplete.enabled.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.autocompleteEnabled}
|
||||
onChange={(v) => updateTerminalSetting("autocompleteEnabled", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.autocomplete.ghostText")}
|
||||
description={t("settings.terminal.autocomplete.ghostText.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.autocompleteGhostText}
|
||||
onChange={handleAutocompleteGhostTextChange}
|
||||
disabled={!terminalSettings.autocompleteEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.autocomplete.popupMenu")}
|
||||
description={t("settings.terminal.autocomplete.popupMenu.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.autocompletePopupMenu}
|
||||
onChange={handleAutocompletePopupMenuChange}
|
||||
disabled={!terminalSettings.autocompleteEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Check, Eye, EyeOff } from "lucide-react";
|
||||
import type { ProviderConfig } from "../../../../infrastructure/ai/types";
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff } from "lucide-react";
|
||||
import type { ProviderConfig, ProviderAdvancedParams } from "../../../../infrastructure/ai/types";
|
||||
import { PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
|
||||
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
@@ -20,10 +20,12 @@ export const ProviderConfigForm: React.FC<{
|
||||
baseURL: provider.baseURL ?? PROVIDER_PRESETS[provider.providerId]?.defaultBaseURL ?? "",
|
||||
defaultModel: provider.defaultModel ?? "",
|
||||
skipTLSVerify: provider.skipTLSVerify ?? false,
|
||||
advancedParams: provider.advancedParams ?? {},
|
||||
});
|
||||
const isCustom = provider.providerId === "custom";
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isDecrypting, setIsDecrypting] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const preset = PROVIDER_PRESETS[provider.providerId];
|
||||
|
||||
@@ -43,11 +45,37 @@ export const ProviderConfigForm: React.FC<{
|
||||
}
|
||||
}, [provider.apiKey]);
|
||||
|
||||
const [advancedParamRaw, setAdvancedParamRaw] = useState<Record<string, string>>({});
|
||||
const handleAdvancedParam = useCallback((key: keyof ProviderAdvancedParams, raw: string) => {
|
||||
setAdvancedParamRaw((prev) => ({ ...prev, [key]: raw }));
|
||||
setForm((prev) => {
|
||||
const next = { ...prev.advancedParams };
|
||||
if (raw.trim() === "" || raw.trim() === "-") {
|
||||
delete next[key];
|
||||
} else {
|
||||
const num = Number(raw);
|
||||
if (!Number.isNaN(num)) {
|
||||
next[key] = num;
|
||||
}
|
||||
}
|
||||
return { ...prev, advancedParams: next };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const cleanedParams: ProviderAdvancedParams = {};
|
||||
const ap = form.advancedParams;
|
||||
if (ap.maxTokens != null && Number.isFinite(ap.maxTokens) && ap.maxTokens > 0) cleanedParams.maxTokens = Math.max(1, Math.round(ap.maxTokens));
|
||||
if (ap.temperature != null) cleanedParams.temperature = Math.min(2, Math.max(0, ap.temperature));
|
||||
if (ap.topP != null) cleanedParams.topP = Math.min(1, Math.max(0, ap.topP));
|
||||
if (ap.frequencyPenalty != null) cleanedParams.frequencyPenalty = Math.min(2, Math.max(-2, ap.frequencyPenalty));
|
||||
if (ap.presencePenalty != null) cleanedParams.presencePenalty = Math.min(2, Math.max(-2, ap.presencePenalty));
|
||||
|
||||
const updates: Partial<ProviderConfig> = {
|
||||
baseURL: form.baseURL || undefined,
|
||||
defaultModel: form.defaultModel || undefined,
|
||||
skipTLSVerify: form.skipTLSVerify || undefined,
|
||||
advancedParams: Object.keys(cleanedParams).length > 0 ? cleanedParams : undefined,
|
||||
...(isCustom && form.name.trim() ? { name: form.name.trim() } : {}),
|
||||
};
|
||||
|
||||
@@ -137,6 +165,92 @@ export const ProviderConfigForm: React.FC<{
|
||||
<span className="text-xs text-muted-foreground">{t('ai.providers.skipTLSVerify')}</span>
|
||||
</label>
|
||||
|
||||
{/* Advanced Parameters */}
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showAdvanced ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
{t('ai.providers.advancedParams')}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="space-y-2.5 pl-1 border-l-2 border-border/40 ml-1">
|
||||
<p className="text-[11px] text-muted-foreground/70 pl-3">{t('ai.providers.advancedParams.hint')}</p>
|
||||
{/* max_tokens */}
|
||||
<div className="space-y-1 pl-3">
|
||||
<label className="text-xs text-muted-foreground">max_tokens</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={advancedParamRaw.maxTokens ?? (form.advancedParams.maxTokens != null ? String(form.advancedParams.maxTokens) : "")}
|
||||
onChange={(e) => handleAdvancedParam("maxTokens", e.target.value)}
|
||||
placeholder={t('ai.providers.advancedParams.maxTokens.placeholder')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
{/* temperature */}
|
||||
<div className="space-y-1 pl-3">
|
||||
<label className="text-xs text-muted-foreground">temperature <span className="text-muted-foreground/50">(0–2)</span></label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={advancedParamRaw.temperature ?? (form.advancedParams.temperature != null ? String(form.advancedParams.temperature) : "")}
|
||||
onChange={(e) => handleAdvancedParam("temperature", e.target.value)}
|
||||
placeholder={t('ai.providers.advancedParams.default')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
{/* top_p */}
|
||||
<div className="space-y-1 pl-3">
|
||||
<label className="text-xs text-muted-foreground">top_p <span className="text-muted-foreground/50">(0–1)</span></label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={advancedParamRaw.topP ?? (form.advancedParams.topP != null ? String(form.advancedParams.topP) : "")}
|
||||
onChange={(e) => handleAdvancedParam("topP", e.target.value)}
|
||||
placeholder={t('ai.providers.advancedParams.default')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
{/* frequency_penalty */}
|
||||
<div className="space-y-1 pl-3">
|
||||
<label className="text-xs text-muted-foreground">frequency_penalty <span className="text-muted-foreground/50">(-2–2)</span></label>
|
||||
<input
|
||||
type="number"
|
||||
min={-2}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={advancedParamRaw.frequencyPenalty ?? (form.advancedParams.frequencyPenalty != null ? String(form.advancedParams.frequencyPenalty) : "")}
|
||||
onChange={(e) => handleAdvancedParam("frequencyPenalty", e.target.value)}
|
||||
placeholder={t('ai.providers.advancedParams.default')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
{/* presence_penalty */}
|
||||
<div className="space-y-1 pl-3">
|
||||
<label className="text-xs text-muted-foreground">presence_penalty <span className="text-muted-foreground/50">(-2–2)</span></label>
|
||||
<input
|
||||
type="number"
|
||||
min={-2}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={advancedParamRaw.presencePenalty ?? (form.advancedParams.presencePenalty != null ? String(form.advancedParams.presencePenalty) : "")}
|
||||
onChange={(e) => handleAdvancedParam("presencePenalty", e.target.value)}
|
||||
placeholder={t('ai.providers.advancedParams.default')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button variant="default" size="sm" onClick={() => void handleSave()}>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import type {
|
||||
AIProviderId,
|
||||
ExternalAgentConfig,
|
||||
ProviderAdvancedParams,
|
||||
} from "../../../../infrastructure/ai/types";
|
||||
|
||||
export type CodexIntegrationState =
|
||||
@@ -42,6 +43,7 @@ export interface ProviderFormState {
|
||||
baseURL: string;
|
||||
defaultModel: string;
|
||||
skipTLSVerify: boolean;
|
||||
advancedParams: ProviderAdvancedParams;
|
||||
}
|
||||
|
||||
export interface FetchedModel {
|
||||
@@ -77,7 +79,7 @@ export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "co
|
||||
name: "Claude Code",
|
||||
args: ["-p", "--output-format", "text", "{prompt}"],
|
||||
icon: "claude",
|
||||
acpCommand: "claude-code-acp",
|
||||
acpCommand: "claude-agent-acp",
|
||||
acpArgs: [],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import React from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
|
||||
import { Input } from "../ui/input";
|
||||
import type { RemoteFile } from "../../types";
|
||||
|
||||
interface PermissionsState {
|
||||
owner: { read: boolean; write: boolean; execute: boolean };
|
||||
group: { read: boolean; write: boolean; execute: boolean };
|
||||
others: { read: boolean; write: boolean; execute: boolean };
|
||||
}
|
||||
|
||||
interface SftpModalDialogsProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
showRenameDialog: boolean;
|
||||
setShowRenameDialog: (open: boolean) => void;
|
||||
renameTarget: RemoteFile | null;
|
||||
renameName: string;
|
||||
setRenameName: (value: string) => void;
|
||||
handleRename: () => void;
|
||||
isRenaming: boolean;
|
||||
showPermissionsDialog: boolean;
|
||||
setShowPermissionsDialog: (open: boolean) => void;
|
||||
permissionsTarget: RemoteFile | null;
|
||||
permissions: PermissionsState;
|
||||
togglePermission: (role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => void;
|
||||
getOctalPermissions: () => string;
|
||||
getSymbolicPermissions: () => string;
|
||||
handleSavePermissions: () => void;
|
||||
isChangingPermissions: boolean;
|
||||
showCreateDialog: boolean;
|
||||
setShowCreateDialog: (open: boolean) => void;
|
||||
createType: "file" | "folder";
|
||||
createName: string;
|
||||
setCreateName: (value: string) => void;
|
||||
isCreating: boolean;
|
||||
handleCreateSubmit: () => void;
|
||||
}
|
||||
|
||||
export const SftpModalDialogs: React.FC<SftpModalDialogsProps> = ({
|
||||
t,
|
||||
showRenameDialog,
|
||||
setShowRenameDialog,
|
||||
renameTarget,
|
||||
renameName,
|
||||
setRenameName,
|
||||
handleRename,
|
||||
isRenaming,
|
||||
showPermissionsDialog,
|
||||
setShowPermissionsDialog,
|
||||
permissionsTarget,
|
||||
permissions,
|
||||
togglePermission,
|
||||
getOctalPermissions,
|
||||
getSymbolicPermissions,
|
||||
handleSavePermissions,
|
||||
isChangingPermissions,
|
||||
showCreateDialog,
|
||||
setShowCreateDialog,
|
||||
createType,
|
||||
createName,
|
||||
setCreateName,
|
||||
isCreating,
|
||||
handleCreateSubmit,
|
||||
}) => (
|
||||
<>
|
||||
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sftp.rename.title")}</DialogTitle>
|
||||
<DialogDescription className="truncate">
|
||||
{renameTarget?.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Input
|
||||
value={renameName}
|
||||
onChange={(e) => setRenameName(e.target.value)}
|
||||
placeholder={t("sftp.rename.placeholder")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleRename();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowRenameDialog(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleRename} disabled={isRenaming || !renameName.trim()}>
|
||||
{isRenaming ? <Loader2 size={14} className="mr-2 animate-spin" /> : null}
|
||||
{t("common.apply")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showPermissionsDialog} onOpenChange={setShowPermissionsDialog}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sftp.permissions.title")}</DialogTitle>
|
||||
<DialogDescription className="truncate">
|
||||
{permissionsTarget?.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-3">
|
||||
{(["owner", "group", "others"] as const).map((role) => (
|
||||
<div key={role} className="flex items-center gap-4">
|
||||
<div className="w-16 text-sm font-medium">
|
||||
{t(`sftp.permissions.${role}`)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{(["read", "write", "execute"] as const).map((perm) => (
|
||||
<label key={perm} className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={permissions[role][perm]}
|
||||
onChange={() => togglePermission(role, perm)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-xs">
|
||||
{perm === "read" ? "R" : perm === "write" ? "W" : "X"}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2 border-t border-border/60">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("sftp.permissions.octal")}: <span className="font-mono text-foreground">{getOctalPermissions()}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("sftp.permissions.symbolic")}: <span className="font-mono text-foreground">{getSymbolicPermissions()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowPermissionsDialog(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSavePermissions} disabled={isChangingPermissions}>
|
||||
{isChangingPermissions ? <Loader2 size={14} className="mr-2 animate-spin" /> : null}
|
||||
{t("common.apply")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t(createType === "folder" ? "sftp.newFolder" : "sftp.newFile")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(createType === "folder" ? "sftp.prompt.newFolderName" : "sftp.fileName.placeholder")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Input
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
placeholder={t(createType === "folder" ? "sftp.prompt.newFolderName" : "sftp.fileName.placeholder")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleCreateSubmit();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleCreateSubmit} disabled={isCreating || !createName.trim()}>
|
||||
{isCreating ? <Loader2 size={14} className="mr-2 animate-spin" /> : null}
|
||||
{t("common.apply")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
@@ -1,438 +0,0 @@
|
||||
import React from "react";
|
||||
import { Download, Edit2, Folder, FolderOpen, FolderUp, Link, Loader2, MoreHorizontal, Plus, RefreshCw, Shield, Trash2, Upload } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import type { RemoteFile } from "../../types";
|
||||
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from "../ui/context-menu";
|
||||
import { Button } from "../ui/button";
|
||||
import { getFileIcon } from "./fileIcons";
|
||||
|
||||
interface VisibleRow {
|
||||
file: RemoteFile;
|
||||
index: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
interface SftpModalFileListProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
hasFiles: boolean;
|
||||
hasDisplayFiles: boolean;
|
||||
selectedFiles: Set<string>;
|
||||
dragActive: boolean;
|
||||
loading: boolean;
|
||||
loadingTextContent: boolean;
|
||||
reconnecting: boolean;
|
||||
columnWidths: { name: number; size: number; modified: number; actions: number };
|
||||
sortField: "name" | "size" | "modified";
|
||||
sortOrder: "asc" | "desc";
|
||||
shouldVirtualize: boolean;
|
||||
totalHeight: number;
|
||||
visibleRows: VisibleRow[];
|
||||
fileListRef: React.RefObject<HTMLDivElement>;
|
||||
inputRef: React.RefObject<HTMLInputElement>;
|
||||
folderInputRef: React.RefObject<HTMLInputElement>;
|
||||
handleSort: (field: "name" | "size" | "modified") => void;
|
||||
handleResizeStart: (field: string, e: React.MouseEvent) => void;
|
||||
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
|
||||
handleDrag: (e: React.DragEvent) => void;
|
||||
handleDrop: (e: React.DragEvent) => void;
|
||||
handleFileClick: (file: RemoteFile, index: number, e: React.MouseEvent) => void;
|
||||
handleFileDoubleClick: (file: RemoteFile) => void;
|
||||
handleDownload: (file: RemoteFile) => void;
|
||||
handleDelete: (file: RemoteFile) => void;
|
||||
handleOpenFile: (file: RemoteFile) => void;
|
||||
openFileOpenerDialog: (file: RemoteFile) => void;
|
||||
handleEditFile: (file: RemoteFile) => void;
|
||||
openRenameDialog: (file: RemoteFile) => void;
|
||||
openPermissionsDialog: (file: RemoteFile) => void;
|
||||
handleNavigate: (path: string) => void;
|
||||
handleCreateFolder: () => void;
|
||||
handleCreateFile: () => void;
|
||||
handleDownloadSelected: () => void;
|
||||
handleDeleteSelected: () => void;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => void;
|
||||
formatBytes: (bytes: number | string) => string;
|
||||
formatDate: (dateStr: string | number | undefined) => string;
|
||||
}
|
||||
|
||||
export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
|
||||
t,
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
hasFiles,
|
||||
hasDisplayFiles,
|
||||
selectedFiles,
|
||||
dragActive,
|
||||
loading,
|
||||
loadingTextContent,
|
||||
reconnecting,
|
||||
columnWidths,
|
||||
sortField,
|
||||
sortOrder,
|
||||
shouldVirtualize,
|
||||
totalHeight,
|
||||
visibleRows,
|
||||
fileListRef,
|
||||
inputRef,
|
||||
folderInputRef,
|
||||
handleSort,
|
||||
handleResizeStart,
|
||||
handleFileListScroll,
|
||||
handleDrag,
|
||||
handleDrop,
|
||||
handleFileClick,
|
||||
handleFileDoubleClick,
|
||||
handleDownload,
|
||||
handleDelete,
|
||||
handleOpenFile,
|
||||
openFileOpenerDialog,
|
||||
handleEditFile,
|
||||
openRenameDialog,
|
||||
openPermissionsDialog,
|
||||
handleNavigate,
|
||||
handleCreateFolder,
|
||||
handleCreateFile,
|
||||
handleDownloadSelected,
|
||||
handleDeleteSelected,
|
||||
loadFiles,
|
||||
formatBytes,
|
||||
formatDate,
|
||||
}) => (
|
||||
<>
|
||||
<div
|
||||
className="shrink-0 bg-muted/80 backdrop-blur-sm border-b border-border/60 px-4 py-2 flex items-center text-xs font-medium text-muted-foreground select-none"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
|
||||
onClick={() => handleSort("name")}
|
||||
>
|
||||
<span>{t("sftp.columns.name")}</span>
|
||||
{sortField === "name" && (
|
||||
<span className="text-primary">{sortOrder === "asc" ? "^" : "v"}</span>
|
||||
)}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
|
||||
onMouseDown={(e) => handleResizeStart("name", e)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
|
||||
onClick={() => handleSort("size")}
|
||||
>
|
||||
<span>{t("sftp.columns.size")}</span>
|
||||
{sortField === "size" && (
|
||||
<span className="text-primary">{sortOrder === "asc" ? "^" : "v"}</span>
|
||||
)}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
|
||||
onMouseDown={(e) => handleResizeStart("size", e)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
|
||||
onClick={() => handleSort("modified")}
|
||||
>
|
||||
<span>{t("sftp.columns.modified")}</span>
|
||||
{sortField === "modified" && (
|
||||
<span className="text-primary">{sortOrder === "asc" ? "^" : "v"}</span>
|
||||
)}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
|
||||
onMouseDown={(e) => handleResizeStart("modified", e)}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right">{t("sftp.columns.actions")}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={fileListRef}
|
||||
className={cn(
|
||||
"flex-1 min-h-0 overflow-y-auto relative",
|
||||
dragActive && "bg-primary/5 ring-2 ring-inset ring-primary",
|
||||
)}
|
||||
onScroll={handleFileListScroll}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{dragActive && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||
<div className="bg-background/95 p-6 rounded-xl shadow-lg border-2 border-dashed border-primary text-primary font-medium flex flex-col items-center gap-2">
|
||||
<Upload size={32} />
|
||||
<span>{t("sftp.dropFilesHere")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !hasFiles && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingTextContent && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-20">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("sftp.status.loading")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reconnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-20">
|
||||
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-secondary/90 border border-border/60 shadow-lg">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium">{t("sftp.reconnecting.title")}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t("sftp.reconnecting.desc")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasDisplayFiles && !loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Folder size={48} className="mb-3 opacity-50" />
|
||||
<div className="text-sm font-medium">{t("sftp.emptyDirectory")}</div>
|
||||
<div className="text-xs mt-1">{t("sftp.dragDropToUpload")}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={shouldVirtualize ? "relative" : "divide-y divide-border/30"}
|
||||
style={shouldVirtualize ? { height: totalHeight } : undefined}
|
||||
>
|
||||
{visibleRows.map(({ file, index: idx, top }) => {
|
||||
const isNavigableDirectory =
|
||||
file.type === "directory" ||
|
||||
(file.type === "symlink" && file.linkTarget === "directory");
|
||||
const isDownloadableFile =
|
||||
file.type === "file" ||
|
||||
(file.type === "symlink" && file.linkTarget === "file");
|
||||
const isParentEntry = file.name === "..";
|
||||
|
||||
return (
|
||||
<ContextMenu key={file.name}>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
data-sftp-modal-row="true"
|
||||
className={cn(
|
||||
"px-4 py-2.5 items-center hover:bg-muted/50 cursor-pointer transition-colors text-sm",
|
||||
selectedFiles.has(file.name) && !isParentEntry && "bg-primary/10",
|
||||
shouldVirtualize ? "absolute left-0 right-0 border-b border-border/30" : "",
|
||||
)}
|
||||
style={
|
||||
shouldVirtualize
|
||||
? {
|
||||
top,
|
||||
display: "grid",
|
||||
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
|
||||
}
|
||||
: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
|
||||
}
|
||||
}
|
||||
onClick={(e) => handleFileClick(file, idx, e)}
|
||||
onDoubleClick={() => handleFileDoubleClick(file)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="relative shrink-0 h-7 w-7 flex items-center justify-center">
|
||||
{getFileIcon(
|
||||
file.name,
|
||||
isNavigableDirectory,
|
||||
file.type === "symlink" && !isNavigableDirectory,
|
||||
)}
|
||||
{file.type === "symlink" && (
|
||||
<Link
|
||||
size={10}
|
||||
className="absolute -bottom-0.5 -right-0.5 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate font-medium",
|
||||
file.type === "symlink" && "italic pr-1",
|
||||
)}
|
||||
>
|
||||
{file.name}
|
||||
{file.type === "symlink" && (
|
||||
<span className="sr-only"> (symbolic link)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{isNavigableDirectory ? "--" : formatBytes(file.size)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{formatDate(file.lastModified)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{isDownloadableFile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownload(file);
|
||||
}}
|
||||
title={t("sftp.context.download")}
|
||||
>
|
||||
<Download size={14} />
|
||||
</Button>
|
||||
)}
|
||||
{!isParentEntry && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(file);
|
||||
}}
|
||||
title={t("sftp.context.delete")}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{isParentEntry ? (
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
const segments = currentPath.split("/").filter(Boolean);
|
||||
segments.pop();
|
||||
const parentPath =
|
||||
segments.length === 0 ? "/" : `/${segments.join("/")}`;
|
||||
handleNavigate(parentPath);
|
||||
}}
|
||||
>
|
||||
{t("sftp.context.open")}
|
||||
</ContextMenuItem>
|
||||
) : (
|
||||
<>
|
||||
{isNavigableDirectory && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
handleNavigate(
|
||||
currentPath === "/"
|
||||
? `/${file.name}`
|
||||
: `${currentPath}/${file.name}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<FolderOpen size={14} className="mr-2" />
|
||||
{t("sftp.context.open")}
|
||||
</ContextMenuItem>
|
||||
{!isLocalSession && (
|
||||
<ContextMenuItem onClick={() => handleDownload(file)}>
|
||||
<Download size={14} className="mr-2" />
|
||||
{t("sftp.context.download")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isDownloadableFile && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => handleOpenFile(file)}>
|
||||
<FolderOpen size={14} className="mr-2" />
|
||||
{t("sftp.context.open")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => openFileOpenerDialog(file)}>
|
||||
<MoreHorizontal size={14} className="mr-2" />
|
||||
{t("sftp.context.openWith")}
|
||||
</ContextMenuItem>
|
||||
{!isKnownBinaryFile(file.name) && (
|
||||
<ContextMenuItem onClick={() => handleEditFile(file)}>
|
||||
<Edit2 size={14} className="mr-2" />
|
||||
{t("sftp.context.edit")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => handleDownload(file)}>
|
||||
<Download size={14} className="mr-2" />
|
||||
{t("sftp.context.download")}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
<ContextMenuItem onClick={() => openRenameDialog(file)}>
|
||||
<Edit2 size={14} className="mr-2" />
|
||||
{t("sftp.context.rename")}
|
||||
</ContextMenuItem>
|
||||
{!isLocalSession && (
|
||||
<ContextMenuItem onClick={() => openPermissionsDialog(file)}>
|
||||
<Shield size={14} className="mr-2" />
|
||||
{t("sftp.context.permissions")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDelete(file)}
|
||||
>
|
||||
<Trash2 size={14} className="mr-2" />
|
||||
{t("sftp.context.delete")}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={handleCreateFolder}>
|
||||
<Plus className="h-4 w-4 mr-2" /> {t("sftp.newFolder")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handleCreateFile}>
|
||||
<Plus className="h-4 w-4 mr-2" /> {t("sftp.newFile")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => inputRef.current?.click()}>
|
||||
<Upload className="h-4 w-4 mr-2" /> {t("sftp.uploadFiles")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => folderInputRef.current?.click()}>
|
||||
<FolderUp className="h-4 w-4 mr-2" /> {t("sftp.uploadFolder")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => loadFiles(currentPath, { force: true })}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" /> {t("sftp.context.refresh")}
|
||||
</ContextMenuItem>
|
||||
{selectedFiles.size > 0 && (
|
||||
<>
|
||||
<ContextMenuItem onClick={handleDownloadSelected}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
{t("sftp.context.downloadSelected", { count: selectedFiles.size })}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={handleDeleteSelected}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t("sftp.context.deleteSelected", { count: selectedFiles.size })}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -1,61 +0,0 @@
|
||||
import React from "react";
|
||||
import { Download, Trash2 } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import type { RemoteFile } from "../../types";
|
||||
|
||||
interface SftpModalFooterProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
files: RemoteFile[];
|
||||
selectedFiles: Set<string>;
|
||||
loading: boolean;
|
||||
uploading: boolean;
|
||||
onDownloadSelected: () => void;
|
||||
onDeleteSelected: () => void;
|
||||
}
|
||||
|
||||
export const SftpModalFooter: React.FC<SftpModalFooterProps> = ({
|
||||
t,
|
||||
files,
|
||||
selectedFiles,
|
||||
loading,
|
||||
uploading,
|
||||
onDownloadSelected,
|
||||
onDeleteSelected,
|
||||
}) => (
|
||||
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
|
||||
<span>
|
||||
{t("sftp.itemsCount", { count: files.length })}
|
||||
{selectedFiles.size > 0 && (
|
||||
<>
|
||||
<span className="mx-2">|</span>
|
||||
<span className="text-primary">
|
||||
{t("sftp.selectedCount", { count: selectedFiles.size })}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-2 ml-2 text-xs text-primary hover:text-primary"
|
||||
onClick={onDownloadSelected}
|
||||
>
|
||||
<Download size={10} className="mr-1" /> {t("sftp.context.download")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-2 text-xs text-destructive hover:text-destructive"
|
||||
onClick={onDeleteSelected}
|
||||
>
|
||||
<Trash2 size={10} className="mr-1" /> {t("sftp.context.delete")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{loading
|
||||
? t("sftp.status.loading")
|
||||
: uploading
|
||||
? t("sftp.status.uploading")
|
||||
: t("sftp.status.ready")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -1,480 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ArrowUp, Bookmark, Check, ChevronRight, Eye, EyeOff, FilePlus, FolderPlus, FolderUp, Home, Languages, MoreHorizontal, RefreshCw, Trash2, Upload, X } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import type { Host, SftpFilenameEncoding } from "../../types";
|
||||
import { useSftpBookmarks } from "../sftp/hooks/useSftpBookmarks";
|
||||
import { DistroAvatar } from "../DistroAvatar";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
|
||||
|
||||
interface BreadcrumbPart {
|
||||
part: string;
|
||||
originalIndex: number;
|
||||
}
|
||||
|
||||
interface SftpModalHeaderProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
host: Host;
|
||||
credentials: { username?: string; hostname: string; port?: number };
|
||||
showEncoding: boolean;
|
||||
filenameEncoding: SftpFilenameEncoding;
|
||||
onFilenameEncodingChange: (encoding: SftpFilenameEncoding) => void;
|
||||
currentPath: string;
|
||||
isEditingPath: boolean;
|
||||
editingPathValue: string;
|
||||
setEditingPathValue: (value: string) => void;
|
||||
handlePathSubmit: () => void;
|
||||
handlePathKeyDown: (e: React.KeyboardEvent) => void;
|
||||
handlePathDoubleClick: () => void;
|
||||
isAtRoot: boolean;
|
||||
rootLabel: string;
|
||||
isRefreshing: boolean;
|
||||
onUp: () => void;
|
||||
onHome: () => void;
|
||||
onRefresh: () => void;
|
||||
visibleBreadcrumbs: BreadcrumbPart[];
|
||||
hiddenBreadcrumbs: BreadcrumbPart[];
|
||||
needsBreadcrumbTruncation: boolean;
|
||||
breadcrumbs: string[];
|
||||
onBreadcrumbSelect: (index: number) => void;
|
||||
onRootSelect: () => void;
|
||||
inputRef: React.RefObject<HTMLInputElement>;
|
||||
folderInputRef: React.RefObject<HTMLInputElement>;
|
||||
pathInputRef: React.RefObject<HTMLInputElement>;
|
||||
uploading: boolean;
|
||||
onTriggerUpload: () => void;
|
||||
onTriggerFolderUpload: () => void;
|
||||
onCreateFolder: () => void;
|
||||
onCreateFile: () => void;
|
||||
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onFolderSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
showHiddenFiles: boolean;
|
||||
onToggleShowHiddenFiles: () => void;
|
||||
onUpdateHost?: (host: Host) => void;
|
||||
onNavigateToBookmark?: (path: string) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
|
||||
t,
|
||||
host,
|
||||
credentials,
|
||||
showEncoding,
|
||||
filenameEncoding,
|
||||
onFilenameEncodingChange,
|
||||
currentPath,
|
||||
isEditingPath,
|
||||
editingPathValue,
|
||||
setEditingPathValue,
|
||||
handlePathSubmit,
|
||||
handlePathKeyDown,
|
||||
handlePathDoubleClick,
|
||||
isAtRoot,
|
||||
rootLabel,
|
||||
isRefreshing,
|
||||
onUp,
|
||||
onHome,
|
||||
onRefresh,
|
||||
visibleBreadcrumbs,
|
||||
hiddenBreadcrumbs,
|
||||
needsBreadcrumbTruncation,
|
||||
breadcrumbs,
|
||||
onBreadcrumbSelect,
|
||||
onRootSelect,
|
||||
inputRef,
|
||||
folderInputRef,
|
||||
pathInputRef,
|
||||
uploading,
|
||||
onTriggerUpload,
|
||||
onTriggerFolderUpload,
|
||||
onCreateFolder,
|
||||
onCreateFile,
|
||||
onFileSelect,
|
||||
onFolderSelect,
|
||||
showHiddenFiles,
|
||||
onToggleShowHiddenFiles,
|
||||
onUpdateHost,
|
||||
onNavigateToBookmark,
|
||||
onClose,
|
||||
}) => {
|
||||
// Delay tooltip activation to prevent flickering when modal opens
|
||||
const [tooltipsReady, setTooltipsReady] = useState(false);
|
||||
const [openTooltip, setOpenTooltip] = useState<string | null>(null);
|
||||
|
||||
// Bookmarks
|
||||
const {
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
toggleBookmark,
|
||||
deleteBookmark,
|
||||
} = useSftpBookmarks({
|
||||
host,
|
||||
currentPath,
|
||||
onUpdateHost,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setTooltipsReady(true), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const handleTooltipOpenChange = (id: string) => (open: boolean) => {
|
||||
if (!tooltipsReady) return;
|
||||
setOpenTooltip(open ? id : null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="px-4 py-3 border-b border-border/60 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.label.slice(0, 2).toUpperCase()}
|
||||
className="h-8 w-8"
|
||||
size="sm"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold">
|
||||
{host.label}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{credentials.username || "root"}@{credentials.hostname}:
|
||||
{credentials.port || 22}
|
||||
</div>
|
||||
</div>
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TooltipProvider delayDuration={500} skipDelayDuration={800} disableHoverableContent>
|
||||
<div className="px-4 py-2 border-b border-border/60 flex items-center gap-2 flex-shrink-0 bg-muted/30">
|
||||
<Tooltip open={openTooltip === 'up'} onOpenChange={handleTooltipOpenChange('up')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onUp}
|
||||
disabled={isAtRoot}
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.nav.up")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip open={openTooltip === 'home'} onOpenChange={handleTooltipOpenChange('home')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onHome}
|
||||
>
|
||||
<Home size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.nav.home")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip open={openTooltip === 'refresh'} onOpenChange={handleTooltipOpenChange('refresh')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw
|
||||
size={14}
|
||||
className={cn(isRefreshing && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.nav.refresh")}</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Bookmark button */}
|
||||
{onUpdateHost && (
|
||||
<Popover>
|
||||
<Tooltip open={openTooltip === 'bookmark'} onOpenChange={handleTooltipOpenChange('bookmark')}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<Bookmark
|
||||
size={14}
|
||||
className={cn(
|
||||
isCurrentPathBookmarked && "fill-yellow-500 text-yellow-500"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-56 p-1" align="start">
|
||||
{/* Toggle button */}
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors"
|
||||
onClick={toggleBookmark}
|
||||
>
|
||||
<Bookmark
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isCurrentPathBookmarked && "fill-yellow-500 text-yellow-500"
|
||||
)}
|
||||
/>
|
||||
{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
|
||||
</button>
|
||||
{/* Divider + list */}
|
||||
{bookmarks.length > 0 && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border/60" />
|
||||
{bookmarks.map((bm) => (
|
||||
<div
|
||||
key={bm.id}
|
||||
className="group flex items-center gap-1 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors cursor-pointer"
|
||||
onClick={() => onNavigateToBookmark?.(bm.path)}
|
||||
title={bm.path}
|
||||
>
|
||||
<Bookmark size={10} className="shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">{bm.label}</span>
|
||||
<span className="flex-1 truncate text-muted-foreground text-[10px]">{bm.path}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteBookmark(bm.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{bookmarks.length === 0 && (
|
||||
<div className="p-2 text-xs text-muted-foreground text-center">
|
||||
{t("sftp.bookmark.empty")}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
{showEncoding && (
|
||||
<Popover>
|
||||
<Tooltip open={openTooltip === 'encoding'} onOpenChange={handleTooltipOpenChange('encoding')}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<Languages size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.encoding.label")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-36 p-1" align="start">
|
||||
{(["auto", "utf-8", "gb18030"] as const).map((encoding) => (
|
||||
<PopoverClose asChild key={encoding}>
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-sm hover:bg-secondary transition-colors",
|
||||
filenameEncoding === encoding && "bg-secondary"
|
||||
)}
|
||||
onClick={() => onFilenameEncodingChange(encoding)}
|
||||
>
|
||||
<Check
|
||||
size={14}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
filenameEncoding === encoding ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{t(`sftp.encoding.${encoding === "utf-8" ? "utf8" : encoding}`)}
|
||||
</button>
|
||||
</PopoverClose>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<Tooltip
|
||||
open={openTooltip === 'showHiddenFiles'}
|
||||
onOpenChange={handleTooltipOpenChange('showHiddenFiles')}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={showHiddenFiles ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className={cn("h-7 w-7", showHiddenFiles && "text-primary")}
|
||||
onClick={onToggleShowHiddenFiles}
|
||||
>
|
||||
{showHiddenFiles ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("settings.sftp.showHiddenFiles")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-hidden">
|
||||
{isEditingPath ? (
|
||||
<Input
|
||||
ref={pathInputRef}
|
||||
value={editingPathValue}
|
||||
onChange={(e) => setEditingPathValue(e.target.value)}
|
||||
onBlur={handlePathSubmit}
|
||||
onKeyDown={handlePathKeyDown}
|
||||
className="h-7 text-sm bg-background"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-1 flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 py-0.5 transition-colors"
|
||||
onDoubleClick={handlePathDoubleClick}
|
||||
title={currentPath}
|
||||
>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground px-1 shrink-0"
|
||||
onClick={onRootSelect}
|
||||
>
|
||||
{rootLabel}
|
||||
</button>
|
||||
{visibleBreadcrumbs.map(({ part, originalIndex }, displayIdx) => {
|
||||
const isLast = originalIndex === breadcrumbs.length - 1;
|
||||
const showEllipsisBefore =
|
||||
needsBreadcrumbTruncation && displayIdx === 1;
|
||||
|
||||
return (
|
||||
<React.Fragment key={originalIndex}>
|
||||
{showEllipsisBefore && (
|
||||
<>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<span
|
||||
className="text-muted-foreground px-1 shrink-0 flex items-center cursor-default"
|
||||
title={`${t("sftp.showHiddenPaths")}: ${hiddenBreadcrumbs
|
||||
.map((h) => h.part)
|
||||
.join(" > ")}`}
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground truncate px-1 max-w-[100px]",
|
||||
isLast && "text-foreground font-medium",
|
||||
)}
|
||||
onClick={() => onBreadcrumbSelect(originalIndex)}
|
||||
title={part}
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<Tooltip open={openTooltip === 'upload'} onOpenChange={handleTooltipOpenChange('upload')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onTriggerUpload}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Upload size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.upload")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip open={openTooltip === 'uploadFolder'} onOpenChange={handleTooltipOpenChange('uploadFolder')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onTriggerFolderUpload}
|
||||
disabled={uploading}
|
||||
>
|
||||
<FolderUp size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.uploadFolder")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip open={openTooltip === 'newFolder'} onOpenChange={handleTooltipOpenChange('newFolder')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onCreateFolder}
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.newFolder")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip open={openTooltip === 'newFile'} onOpenChange={handleTooltipOpenChange('newFile')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onCreateFile}
|
||||
>
|
||||
<FilePlus size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.newFile")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
ref={inputRef}
|
||||
onChange={onFileSelect}
|
||||
multiple
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
ref={folderInputRef}
|
||||
onChange={onFolderSelect}
|
||||
webkitdirectory=""
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,228 +0,0 @@
|
||||
import React from "react";
|
||||
import { Download, Loader2, Upload, X, XCircle } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface TransferTask {
|
||||
id: string;
|
||||
fileName: string;
|
||||
totalBytes: number;
|
||||
transferredBytes: number;
|
||||
progress: number;
|
||||
speed: number;
|
||||
status: "pending" | "uploading" | "downloading" | "completed" | "failed" | "cancelled";
|
||||
error?: string;
|
||||
direction: "upload" | "download";
|
||||
targetPath?: string;
|
||||
}
|
||||
|
||||
interface SftpModalUploadTasksProps {
|
||||
tasks: TransferTask[];
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
onCancel?: () => void;
|
||||
onCancelTask?: (taskId: string) => void;
|
||||
onDismiss?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ tasks, t, onCancel, onCancelTask, onDismiss }) => {
|
||||
if (tasks.length === 0) return null;
|
||||
|
||||
// Helper function to get localized display name for compressed uploads
|
||||
const getDisplayName = (task: TransferTask) => {
|
||||
// Check for explicit phase marker format: "folderName|phase"
|
||||
// This is the format sent by uploadService.ts for compressed uploads
|
||||
if (task.fileName.includes('|')) {
|
||||
const pipeIndex = task.fileName.lastIndexOf('|');
|
||||
const baseName = task.fileName.substring(0, pipeIndex);
|
||||
const phase = task.fileName.substring(pipeIndex + 1);
|
||||
|
||||
if (phase === 'compressing' || phase === 'extracting' || phase === 'uploading' || phase === 'compressed') {
|
||||
const phaseLabel = t(`sftp.upload.phase.${phase}`);
|
||||
return `${baseName} (${phaseLabel})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for exact matches of phase status strings (legacy support)
|
||||
if (task.fileName === t('sftp.upload.compressing') || task.fileName === 'Compressing...' || task.fileName === 'Compressing') {
|
||||
return t('sftp.upload.compressing');
|
||||
}
|
||||
if (task.fileName === t('sftp.upload.extracting') || task.fileName === 'Extracting...' || task.fileName === 'Extracting') {
|
||||
return t('sftp.upload.extracting');
|
||||
}
|
||||
if (task.fileName === t('sftp.upload.scanning') || task.fileName === 'Scanning files...' || task.fileName === 'Scanning files') {
|
||||
return t('sftp.upload.scanning');
|
||||
}
|
||||
|
||||
// Check if this is a compressed upload task (legacy format)
|
||||
if (task.fileName.includes('(compressed)')) {
|
||||
const baseName = task.fileName.replace(' (compressed)', '');
|
||||
return `${baseName} (${t('sftp.upload.compressed')})`;
|
||||
}
|
||||
|
||||
return task.fileName;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-border/60 bg-secondary/50 flex-shrink-0">
|
||||
<div className="max-h-40 overflow-y-auto overflow-x-hidden">
|
||||
{[...tasks].reverse().map((task) => {
|
||||
const formatSpeed = (bytesPerSec: number) => {
|
||||
if (bytesPerSec <= 0) return "";
|
||||
if (bytesPerSec >= 1024 * 1024)
|
||||
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
|
||||
if (bytesPerSec >= 1024)
|
||||
return `${(bytesPerSec / 1024).toFixed(1)} KB/s`;
|
||||
return `${Math.round(bytesPerSec)} B/s`;
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return "0 B";
|
||||
if (bytes >= 1024 * 1024)
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${bytes} B`;
|
||||
};
|
||||
|
||||
const remainingBytes = task.totalBytes - task.transferredBytes;
|
||||
const effectiveSpeed = task.speed > 0 ? task.speed : 0;
|
||||
const remainingTime =
|
||||
effectiveSpeed > 0 ? Math.ceil(remainingBytes / effectiveSpeed) : 0;
|
||||
const remainingStr =
|
||||
remainingTime > 60
|
||||
? `~${Math.ceil(remainingTime / 60)}m left`
|
||||
: remainingTime > 0
|
||||
? `~${remainingTime}s left`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="px-4 py-2.5 flex items-center gap-3 border-b border-border/30 last:border-b-0"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
{(task.status === "uploading" || task.status === "downloading") && (
|
||||
<Loader2 size={14} className="animate-spin text-primary" />
|
||||
)}
|
||||
{task.status === "pending" && (
|
||||
task.direction === "download"
|
||||
? <Download size={14} className="text-muted-foreground animate-pulse" />
|
||||
: <Upload size={14} className="text-muted-foreground animate-pulse" />
|
||||
)}
|
||||
{task.status === "completed" && (
|
||||
task.direction === "download"
|
||||
? <Download size={14} className="text-green-500" />
|
||||
: <Upload size={14} className="text-green-500" />
|
||||
)}
|
||||
{task.status === "failed" && (
|
||||
<XCircle size={14} className="text-destructive" />
|
||||
)}
|
||||
{task.status === "cancelled" && (
|
||||
<XCircle size={14} className="text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium truncate">
|
||||
{getDisplayName(task)}
|
||||
</span>
|
||||
{(task.status === "uploading" || task.status === "downloading") && effectiveSpeed > 0 && (
|
||||
<span className="text-[10px] text-primary font-mono shrink-0">
|
||||
{formatSpeed(effectiveSpeed)}
|
||||
</span>
|
||||
)}
|
||||
{(task.status === "uploading" || task.status === "downloading") && remainingStr && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
{remainingStr}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(task.status === "uploading" || task.status === "downloading" || task.status === "pending") && (
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-150",
|
||||
task.status === "pending"
|
||||
? "bg-muted-foreground/50 animate-pulse w-full"
|
||||
: "bg-primary",
|
||||
)}
|
||||
style={{
|
||||
width:
|
||||
task.status === "uploading" || task.status === "downloading"
|
||||
? `${task.progress}%`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-mono shrink-0 w-8 text-right">
|
||||
{task.status === "uploading" || task.status === "downloading" ? `${Math.round(task.progress)}%` : "..."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(task.status === "uploading" || task.status === "downloading") && task.totalBytes > 0 && (
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
|
||||
{formatBytes(task.transferredBytes)} / {formatBytes(task.totalBytes)}
|
||||
</div>
|
||||
)}
|
||||
{task.status === "completed" && (
|
||||
<div className="text-[10px] text-green-600 mt-0.5">
|
||||
{t(task.direction === "download" ? "sftp.download.completed" : "sftp.upload.completed")} - {formatBytes(task.totalBytes)}
|
||||
{task.targetPath && (
|
||||
<span className="text-muted-foreground ml-1">→ {task.targetPath}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{task.status === "cancelled" && (
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5">
|
||||
{t(task.direction === "download" ? "sftp.download.cancelled" : "sftp.upload.cancelled")}
|
||||
</div>
|
||||
)}
|
||||
{task.status === "failed" && task.error && (
|
||||
<div className="text-[10px] text-destructive truncate mt-0.5">
|
||||
{task.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-1">
|
||||
{task.status === "pending" && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("sftp.task.waiting")}
|
||||
</span>
|
||||
)}
|
||||
{(task.status === "uploading" || task.status === "downloading" || task.status === "pending") && (onCancelTask || onCancel) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
// For download tasks or when onCancelTask is available, use task-specific cancel
|
||||
if (onCancelTask) {
|
||||
onCancelTask(task.id);
|
||||
} else if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
title={t("sftp.action.cancel")}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
)}
|
||||
{(task.status === "completed" || task.status === "failed" || task.status === "cancelled") && onDismiss && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onDismiss(task.id)}
|
||||
title={t("sftp.action.dismiss")}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,149 +0,0 @@
|
||||
import {
|
||||
Database,
|
||||
ExternalLink,
|
||||
File,
|
||||
FileArchive,
|
||||
FileAudio,
|
||||
FileCode,
|
||||
FileImage,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileType,
|
||||
FileVideo,
|
||||
Folder,
|
||||
Globe,
|
||||
Lock,
|
||||
Settings,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
export const getFileIcon = (fileName: string, isDirectory: boolean, isSymlink?: boolean) => {
|
||||
if (isDirectory)
|
||||
return (
|
||||
<Folder
|
||||
size={18}
|
||||
fill="currentColor"
|
||||
fillOpacity={0.2}
|
||||
className="text-blue-400"
|
||||
/>
|
||||
);
|
||||
|
||||
if (isSymlink) {
|
||||
return <ExternalLink size={18} className="text-cyan-500" />;
|
||||
}
|
||||
|
||||
const ext = fileName.split(".").pop()?.toLowerCase() || "";
|
||||
|
||||
if (["doc", "docx", "rtf", "odt"].includes(ext))
|
||||
return <FileText size={18} className="text-blue-500" />;
|
||||
if (["xls", "xlsx", "csv", "ods"].includes(ext))
|
||||
return <FileSpreadsheet size={18} className="text-green-500" />;
|
||||
if (["ppt", "pptx", "odp"].includes(ext))
|
||||
return <FileType size={18} className="text-orange-500" />;
|
||||
if (["pdf"].includes(ext))
|
||||
return <FileText size={18} className="text-red-500" />;
|
||||
|
||||
if (["js", "jsx", "ts", "tsx", "mjs", "cjs"].includes(ext))
|
||||
return <FileCode size={18} className="text-yellow-500" />;
|
||||
if (["py", "pyc", "pyw"].includes(ext))
|
||||
return <FileCode size={18} className="text-blue-400" />;
|
||||
if (["sh", "bash", "zsh", "fish", "bat", "cmd", "ps1"].includes(ext))
|
||||
return <Terminal size={18} className="text-green-400" />;
|
||||
if (["c", "cpp", "h", "hpp", "cc", "cxx"].includes(ext))
|
||||
return <FileCode size={18} className="text-blue-600" />;
|
||||
if (["java", "class", "jar"].includes(ext))
|
||||
return <FileCode size={18} className="text-orange-600" />;
|
||||
if (["go"].includes(ext))
|
||||
return <FileCode size={18} className="text-cyan-500" />;
|
||||
if (["rs"].includes(ext))
|
||||
return <FileCode size={18} className="text-orange-400" />;
|
||||
if (["rb"].includes(ext))
|
||||
return <FileCode size={18} className="text-red-400" />;
|
||||
if (["php"].includes(ext))
|
||||
return <FileCode size={18} className="text-purple-500" />;
|
||||
if (["html", "htm", "xhtml"].includes(ext))
|
||||
return <Globe size={18} className="text-orange-500" />;
|
||||
if (["css", "scss", "sass", "less"].includes(ext))
|
||||
return <FileCode size={18} className="text-blue-500" />;
|
||||
if (["vue", "svelte"].includes(ext))
|
||||
return <FileCode size={18} className="text-green-500" />;
|
||||
|
||||
if (["json", "json5"].includes(ext))
|
||||
return <FileCode size={18} className="text-yellow-600" />;
|
||||
if (["xml", "xsl", "xslt"].includes(ext))
|
||||
return <FileCode size={18} className="text-orange-400" />;
|
||||
if (["yml", "yaml"].includes(ext))
|
||||
return <Settings size={18} className="text-pink-400" />;
|
||||
if (["toml", "ini", "conf", "cfg", "config"].includes(ext))
|
||||
return <Settings size={18} className="text-gray-400" />;
|
||||
if (["env"].includes(ext))
|
||||
return <Lock size={18} className="text-yellow-500" />;
|
||||
if (["sql", "sqlite", "db"].includes(ext))
|
||||
return <Database size={18} className="text-blue-400" />;
|
||||
|
||||
if (
|
||||
[
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"gif",
|
||||
"bmp",
|
||||
"webp",
|
||||
"svg",
|
||||
"ico",
|
||||
"tiff",
|
||||
"tif",
|
||||
"heic",
|
||||
"heif",
|
||||
"avif",
|
||||
].includes(ext)
|
||||
)
|
||||
return <FileImage size={18} className="text-purple-400" />;
|
||||
|
||||
if (
|
||||
[
|
||||
"mp4",
|
||||
"mkv",
|
||||
"avi",
|
||||
"mov",
|
||||
"wmv",
|
||||
"flv",
|
||||
"webm",
|
||||
"m4v",
|
||||
"3gp",
|
||||
"mpeg",
|
||||
"mpg",
|
||||
].includes(ext)
|
||||
)
|
||||
return <FileVideo size={18} className="text-pink-500" />;
|
||||
|
||||
if (
|
||||
["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma", "opus", "aiff"].includes(
|
||||
ext,
|
||||
)
|
||||
)
|
||||
return <FileAudio size={18} className="text-green-400" />;
|
||||
|
||||
if (
|
||||
[
|
||||
"zip",
|
||||
"rar",
|
||||
"7z",
|
||||
"tar",
|
||||
"gz",
|
||||
"bz2",
|
||||
"xz",
|
||||
"tgz",
|
||||
"tbz2",
|
||||
"lz",
|
||||
"lzma",
|
||||
"cab",
|
||||
"iso",
|
||||
"dmg",
|
||||
].includes(ext)
|
||||
)
|
||||
return <FileArchive size={18} className="text-yellow-600" />;
|
||||
|
||||
return <File size={18} className="text-muted-foreground" />;
|
||||
};
|
||||
@@ -1,140 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
interface UseSftpModalCreateDeleteParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
deleteLocalFile: (path: string) => Promise<void>;
|
||||
deleteSftp: (sftpId: string, path: string) => Promise<void>;
|
||||
mkdirLocal: (path: string) => Promise<void>;
|
||||
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
|
||||
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
|
||||
writeSftpBinary: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
|
||||
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
interface UseSftpModalCreateDeleteResult {
|
||||
handleDelete: (file: RemoteFile) => Promise<void>;
|
||||
handleCreateFolder: () => void;
|
||||
handleCreateFile: () => void;
|
||||
// Create dialog state
|
||||
showCreateDialog: boolean;
|
||||
setShowCreateDialog: (open: boolean) => void;
|
||||
createType: "file" | "folder";
|
||||
createName: string;
|
||||
setCreateName: (value: string) => void;
|
||||
isCreating: boolean;
|
||||
handleCreateSubmit: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpModalCreateDelete = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
deleteLocalFile,
|
||||
deleteSftp,
|
||||
mkdirLocal,
|
||||
mkdirSftp,
|
||||
writeLocalFile,
|
||||
writeSftpBinary,
|
||||
writeSftp,
|
||||
t,
|
||||
}: UseSftpModalCreateDeleteParams): UseSftpModalCreateDeleteResult => {
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [createType, setCreateType] = useState<"file" | "folder">("folder");
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (file: RemoteFile) => {
|
||||
if (file.name === "..") return;
|
||||
if (!confirm(t("sftp.deleteConfirm.single", { name: file.name }))) return;
|
||||
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, file.name);
|
||||
if (isLocalSession) {
|
||||
await deleteLocalFile(fullPath);
|
||||
} else {
|
||||
await deleteSftp(await ensureSftp(), fullPath);
|
||||
}
|
||||
await loadFiles(currentPath, { force: true });
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.deleteFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
},
|
||||
[currentPath, deleteLocalFile, deleteSftp, ensureSftp, isLocalSession, joinPath, loadFiles, t],
|
||||
);
|
||||
|
||||
const handleCreateFolder = useCallback(() => {
|
||||
setCreateType("folder");
|
||||
setCreateName("");
|
||||
setShowCreateDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleCreateFile = useCallback(() => {
|
||||
setCreateType("file");
|
||||
setCreateName("");
|
||||
setShowCreateDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleCreateSubmit = useCallback(async () => {
|
||||
const name = createName.trim();
|
||||
if (!name || isCreating) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, name);
|
||||
if (createType === "folder") {
|
||||
if (isLocalSession) {
|
||||
await mkdirLocal(fullPath);
|
||||
} else {
|
||||
await mkdirSftp(await ensureSftp(), fullPath);
|
||||
}
|
||||
} else {
|
||||
if (isLocalSession) {
|
||||
await writeLocalFile(fullPath, new ArrayBuffer(0));
|
||||
} else {
|
||||
try {
|
||||
await writeSftpBinary(await ensureSftp(), fullPath, new ArrayBuffer(0));
|
||||
} catch {
|
||||
await writeSftp(await ensureSftp(), fullPath, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
setShowCreateDialog(false);
|
||||
setCreateName("");
|
||||
await loadFiles(currentPath, { force: true });
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: t(createType === "folder" ? "sftp.error.createFolderFailed" : "sftp.error.createFileFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [createName, createType, currentPath, ensureSftp, isCreating, isLocalSession, joinPath, loadFiles, mkdirLocal, mkdirSftp, t, writeLocalFile, writeSftp, writeSftpBinary]);
|
||||
|
||||
return {
|
||||
handleDelete,
|
||||
handleCreateFolder,
|
||||
handleCreateFile,
|
||||
showCreateDialog,
|
||||
setShowCreateDialog,
|
||||
createType,
|
||||
createName,
|
||||
setCreateName,
|
||||
isCreating,
|
||||
handleCreateSubmit,
|
||||
};
|
||||
};
|
||||
@@ -1,277 +0,0 @@
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { useSftpModalCreateDelete } from "./useSftpModalCreateDelete";
|
||||
import { useSftpModalRename } from "./useSftpModalRename";
|
||||
import { useSftpModalPermissions } from "./useSftpModalPermissions";
|
||||
import { useSftpModalTextEditor } from "./useSftpModalTextEditor";
|
||||
import { useSftpModalFileOpener } from "./useSftpModalFileOpener";
|
||||
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
|
||||
interface UseSftpModalFileActionsParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
readLocalFile: (path: string) => Promise<ArrayBuffer>;
|
||||
readSftp: (sftpId: string, path: string) => Promise<string>;
|
||||
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
|
||||
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
|
||||
writeSftpBinary: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
|
||||
deleteLocalFile: (path: string) => Promise<void>;
|
||||
deleteSftp: (sftpId: string, path: string) => Promise<void>;
|
||||
mkdirLocal: (path: string) => Promise<void>;
|
||||
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
|
||||
renameSftp: (sftpId: string, oldPath: string, newPath: string) => Promise<void>;
|
||||
chmodSftp: (sftpId: string, path: string, permissions: string) => Promise<void>;
|
||||
statSftp: (sftpId: string, path: string) => Promise<{ permissions?: string }>;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
sftpAutoSync: boolean;
|
||||
getOpenerForFile: (name: string) => { openerType: FileOpenerType; systemApp?: SystemAppInfo } | null;
|
||||
setOpenerForExtension: (ext: string, openerType: FileOpenerType, systemApp?: SystemAppInfo) => void;
|
||||
downloadSftpToTempAndOpen: (sftpId: string, path: string, fileName: string, appPath: string, opts: { enableWatch: boolean }) => Promise<void>;
|
||||
selectApplication: () => Promise<{ path: string; name: string } | null>;
|
||||
}
|
||||
|
||||
interface UseSftpModalFileActionsResult {
|
||||
handleDelete: (file: RemoteFile) => Promise<void>;
|
||||
handleCreateFolder: () => void;
|
||||
handleCreateFile: () => void;
|
||||
showCreateDialog: boolean;
|
||||
setShowCreateDialog: (open: boolean) => void;
|
||||
createType: "file" | "folder";
|
||||
createName: string;
|
||||
setCreateName: (value: string) => void;
|
||||
isCreating: boolean;
|
||||
handleCreateSubmit: () => Promise<void>;
|
||||
showRenameDialog: boolean;
|
||||
setShowRenameDialog: (open: boolean) => void;
|
||||
renameTarget: RemoteFile | null;
|
||||
renameName: string;
|
||||
setRenameName: (value: string) => void;
|
||||
isRenaming: boolean;
|
||||
openRenameDialog: (file: RemoteFile) => void;
|
||||
handleRename: () => Promise<void>;
|
||||
showPermissionsDialog: boolean;
|
||||
setShowPermissionsDialog: (open: boolean) => void;
|
||||
permissionsTarget: RemoteFile | null;
|
||||
permissions: {
|
||||
owner: { read: boolean; write: boolean; execute: boolean };
|
||||
group: { read: boolean; write: boolean; execute: boolean };
|
||||
others: { read: boolean; write: boolean; execute: boolean };
|
||||
};
|
||||
isChangingPermissions: boolean;
|
||||
openPermissionsDialog: (file: RemoteFile) => Promise<void>;
|
||||
togglePermission: (role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => void;
|
||||
getOctalPermissions: () => string;
|
||||
getSymbolicPermissions: () => string;
|
||||
handleSavePermissions: () => Promise<void>;
|
||||
showFileOpenerDialog: boolean;
|
||||
setShowFileOpenerDialog: (open: boolean) => void;
|
||||
fileOpenerTarget: RemoteFile | null;
|
||||
setFileOpenerTarget: (target: RemoteFile | null) => void;
|
||||
openFileOpenerDialog: (file: RemoteFile) => void;
|
||||
handleFileOpenerSelect: (
|
||||
openerType: FileOpenerType,
|
||||
setAsDefault: boolean,
|
||||
systemApp?: SystemAppInfo,
|
||||
) => Promise<void>;
|
||||
handleSelectSystemApp: () => Promise<SystemAppInfo | null>;
|
||||
showTextEditor: boolean;
|
||||
setShowTextEditor: (open: boolean) => void;
|
||||
textEditorTarget: RemoteFile | null;
|
||||
setTextEditorTarget: (target: RemoteFile | null) => void;
|
||||
textEditorContent: string;
|
||||
setTextEditorContent: (value: string) => void;
|
||||
loadingTextContent: boolean;
|
||||
handleEditFile: (file: RemoteFile) => Promise<void>;
|
||||
handleSaveTextFile: (content: string) => Promise<void>;
|
||||
handleOpenFile: (file: RemoteFile) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpModalFileActions = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
readLocalFile,
|
||||
readSftp,
|
||||
writeLocalFile,
|
||||
writeSftp,
|
||||
writeSftpBinary,
|
||||
deleteLocalFile,
|
||||
deleteSftp,
|
||||
mkdirLocal,
|
||||
mkdirSftp,
|
||||
renameSftp,
|
||||
chmodSftp,
|
||||
statSftp,
|
||||
t,
|
||||
sftpAutoSync,
|
||||
getOpenerForFile,
|
||||
setOpenerForExtension,
|
||||
downloadSftpToTempAndOpen,
|
||||
selectApplication,
|
||||
}: UseSftpModalFileActionsParams): UseSftpModalFileActionsResult => {
|
||||
const {
|
||||
handleDelete,
|
||||
handleCreateFolder,
|
||||
handleCreateFile,
|
||||
showCreateDialog,
|
||||
setShowCreateDialog,
|
||||
createType,
|
||||
createName,
|
||||
setCreateName,
|
||||
isCreating,
|
||||
handleCreateSubmit,
|
||||
} =
|
||||
useSftpModalCreateDelete({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
deleteLocalFile,
|
||||
deleteSftp,
|
||||
mkdirLocal,
|
||||
mkdirSftp,
|
||||
writeLocalFile,
|
||||
writeSftpBinary,
|
||||
writeSftp,
|
||||
t,
|
||||
});
|
||||
|
||||
const {
|
||||
showRenameDialog,
|
||||
setShowRenameDialog,
|
||||
renameTarget,
|
||||
renameName,
|
||||
setRenameName,
|
||||
isRenaming,
|
||||
openRenameDialog,
|
||||
handleRename,
|
||||
} = useSftpModalRename({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
renameSftp,
|
||||
t,
|
||||
});
|
||||
|
||||
const {
|
||||
showPermissionsDialog,
|
||||
setShowPermissionsDialog,
|
||||
permissionsTarget,
|
||||
permissions,
|
||||
isChangingPermissions,
|
||||
openPermissionsDialog,
|
||||
togglePermission,
|
||||
getOctalPermissions,
|
||||
getSymbolicPermissions,
|
||||
handleSavePermissions,
|
||||
} = useSftpModalPermissions({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
chmodSftp,
|
||||
statSftp,
|
||||
t,
|
||||
});
|
||||
|
||||
const {
|
||||
showTextEditor,
|
||||
setShowTextEditor,
|
||||
textEditorTarget,
|
||||
setTextEditorTarget,
|
||||
textEditorContent,
|
||||
setTextEditorContent,
|
||||
loadingTextContent,
|
||||
handleEditFile,
|
||||
handleSaveTextFile,
|
||||
} = useSftpModalTextEditor({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
readLocalFile,
|
||||
readSftp,
|
||||
writeLocalFile,
|
||||
writeSftp,
|
||||
t,
|
||||
});
|
||||
|
||||
const {
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
openFileOpenerDialog,
|
||||
handleOpenFile,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
} = useSftpModalFileOpener({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
sftpAutoSync,
|
||||
getOpenerForFile,
|
||||
setOpenerForExtension,
|
||||
downloadSftpToTempAndOpen,
|
||||
selectApplication,
|
||||
t,
|
||||
handleEditFile,
|
||||
});
|
||||
|
||||
return {
|
||||
handleDelete,
|
||||
handleCreateFolder,
|
||||
handleCreateFile,
|
||||
showCreateDialog,
|
||||
setShowCreateDialog,
|
||||
createType,
|
||||
createName,
|
||||
setCreateName,
|
||||
isCreating,
|
||||
handleCreateSubmit,
|
||||
showRenameDialog,
|
||||
setShowRenameDialog,
|
||||
renameTarget,
|
||||
renameName,
|
||||
setRenameName,
|
||||
isRenaming,
|
||||
openRenameDialog,
|
||||
handleRename,
|
||||
showPermissionsDialog,
|
||||
setShowPermissionsDialog,
|
||||
permissionsTarget,
|
||||
permissions,
|
||||
isChangingPermissions,
|
||||
openPermissionsDialog,
|
||||
togglePermission,
|
||||
getOctalPermissions,
|
||||
getSymbolicPermissions,
|
||||
handleSavePermissions,
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
openFileOpenerDialog,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
showTextEditor,
|
||||
setShowTextEditor,
|
||||
textEditorTarget,
|
||||
setTextEditorTarget,
|
||||
textEditorContent,
|
||||
setTextEditorContent,
|
||||
loadingTextContent,
|
||||
handleEditFile,
|
||||
handleSaveTextFile,
|
||||
handleOpenFile,
|
||||
};
|
||||
};
|
||||
@@ -1,154 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
import { getFileExtension, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
|
||||
interface UseSftpModalFileOpenerParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
sftpAutoSync: boolean;
|
||||
getOpenerForFile: (name: string) => { openerType: FileOpenerType; systemApp?: SystemAppInfo } | null;
|
||||
setOpenerForExtension: (ext: string, openerType: FileOpenerType, systemApp?: SystemAppInfo) => void;
|
||||
downloadSftpToTempAndOpen: (sftpId: string, path: string, fileName: string, appPath: string, opts: { enableWatch: boolean }) => Promise<void>;
|
||||
selectApplication: () => Promise<{ path: string; name: string } | null>;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
handleEditFile: (file: RemoteFile) => Promise<void>;
|
||||
}
|
||||
|
||||
interface UseSftpModalFileOpenerResult {
|
||||
showFileOpenerDialog: boolean;
|
||||
setShowFileOpenerDialog: (open: boolean) => void;
|
||||
fileOpenerTarget: RemoteFile | null;
|
||||
setFileOpenerTarget: (target: RemoteFile | null) => void;
|
||||
openFileOpenerDialog: (file: RemoteFile) => void;
|
||||
handleOpenFile: (file: RemoteFile) => Promise<void>;
|
||||
handleFileOpenerSelect: (
|
||||
openerType: FileOpenerType,
|
||||
setAsDefault: boolean,
|
||||
systemApp?: SystemAppInfo,
|
||||
) => Promise<void>;
|
||||
handleSelectSystemApp: () => Promise<SystemAppInfo | null>;
|
||||
}
|
||||
|
||||
export const useSftpModalFileOpener = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
sftpAutoSync,
|
||||
getOpenerForFile,
|
||||
setOpenerForExtension,
|
||||
downloadSftpToTempAndOpen,
|
||||
selectApplication,
|
||||
t,
|
||||
handleEditFile,
|
||||
}: UseSftpModalFileOpenerParams): UseSftpModalFileOpenerResult => {
|
||||
const [showFileOpenerDialog, setShowFileOpenerDialog] = useState(false);
|
||||
const [fileOpenerTarget, setFileOpenerTarget] = useState<RemoteFile | null>(null);
|
||||
|
||||
const openFileOpenerDialog = useCallback((file: RemoteFile) => {
|
||||
setFileOpenerTarget(file);
|
||||
setShowFileOpenerDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleOpenFile = useCallback(async (file: RemoteFile) => {
|
||||
const savedOpener = getOpenerForFile(file.name);
|
||||
|
||||
if (savedOpener) {
|
||||
if (savedOpener.openerType === "builtin-editor") {
|
||||
await handleEditFile(file);
|
||||
} else if (savedOpener.openerType === "system-app" && savedOpener.systemApp) {
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, file.name);
|
||||
if (isLocalSession) {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (bridge?.openWithApplication) {
|
||||
await bridge.openWithApplication(fullPath, savedOpener.systemApp.path);
|
||||
}
|
||||
} else {
|
||||
const sftpId = await ensureSftp();
|
||||
await downloadSftpToTempAndOpen(
|
||||
sftpId,
|
||||
fullPath,
|
||||
file.name,
|
||||
savedOpener.systemApp.path,
|
||||
{ enableWatch: sftpAutoSync },
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.openFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
openFileOpenerDialog(file);
|
||||
}
|
||||
}, [currentPath, downloadSftpToTempAndOpen, ensureSftp, getOpenerForFile, handleEditFile, isLocalSession, joinPath, openFileOpenerDialog, sftpAutoSync, t]);
|
||||
|
||||
const handleFileOpenerSelect = useCallback(
|
||||
async (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => {
|
||||
if (!fileOpenerTarget) return;
|
||||
|
||||
if (setAsDefault) {
|
||||
const ext = getFileExtension(fileOpenerTarget.name);
|
||||
setOpenerForExtension(ext, openerType, systemApp);
|
||||
}
|
||||
|
||||
setShowFileOpenerDialog(false);
|
||||
|
||||
if (openerType === "builtin-editor") {
|
||||
await handleEditFile(fileOpenerTarget);
|
||||
} else if (openerType === "system-app" && systemApp) {
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, fileOpenerTarget.name);
|
||||
if (isLocalSession) {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (bridge?.openWithApplication) {
|
||||
await bridge.openWithApplication(fullPath, systemApp.path);
|
||||
}
|
||||
} else {
|
||||
const sftpId = await ensureSftp();
|
||||
await downloadSftpToTempAndOpen(
|
||||
sftpId,
|
||||
fullPath,
|
||||
fileOpenerTarget.name,
|
||||
systemApp.path,
|
||||
{ enableWatch: sftpAutoSync },
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.openFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setFileOpenerTarget(null);
|
||||
},
|
||||
[currentPath, downloadSftpToTempAndOpen, ensureSftp, fileOpenerTarget, handleEditFile, isLocalSession, joinPath, sftpAutoSync, setOpenerForExtension, t],
|
||||
);
|
||||
|
||||
const handleSelectSystemApp = useCallback(async (): Promise<SystemAppInfo | null> => {
|
||||
const result = await selectApplication();
|
||||
if (result) {
|
||||
return { path: result.path, name: result.name };
|
||||
}
|
||||
return null;
|
||||
}, [selectApplication]);
|
||||
|
||||
return {
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
openFileOpenerDialog,
|
||||
handleOpenFile,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
};
|
||||
};
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* useSftpModalKeyboardShortcuts
|
||||
*
|
||||
* Hook that handles keyboard shortcuts for SFTPModal operations.
|
||||
* Supports select all, rename, delete, refresh, and new folder.
|
||||
* Note: Copy/Cut/Paste are not supported in the modal as it's a single-pane view.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { KeyBinding, matchesKeyBinding } from "../../../domain/models";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
|
||||
// SFTP Modal action names that we handle (subset of main SFTP actions)
|
||||
const SFTP_MODAL_ACTIONS = new Set([
|
||||
"sftpSelectAll",
|
||||
"sftpRename",
|
||||
"sftpDelete",
|
||||
"sftpRefresh",
|
||||
"sftpNewFolder",
|
||||
]);
|
||||
|
||||
interface UseSftpModalKeyboardShortcutsParams {
|
||||
keyBindings: KeyBinding[];
|
||||
hotkeyScheme: "disabled" | "mac" | "pc";
|
||||
open: boolean;
|
||||
files: RemoteFile[];
|
||||
visibleFiles: RemoteFile[];
|
||||
selectedFiles: Set<string>;
|
||||
setSelectedFiles: (files: Set<string>) => void;
|
||||
onRefresh: () => void;
|
||||
onRename?: (file: RemoteFile) => void;
|
||||
onDelete?: (fileNames: string[]) => void;
|
||||
onNewFolder?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a keyboard event matches any SFTP action
|
||||
*/
|
||||
const matchSftpAction = (
|
||||
e: KeyboardEvent,
|
||||
keyBindings: KeyBinding[],
|
||||
isMac: boolean
|
||||
): { action: string; binding: KeyBinding } | null => {
|
||||
for (const binding of keyBindings) {
|
||||
if (binding.category !== "sftp") continue;
|
||||
const keyStr = isMac ? binding.mac : binding.pc;
|
||||
if (matchesKeyBinding(e, keyStr, isMac)) {
|
||||
return { action: binding.action, binding };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const useSftpModalKeyboardShortcuts = ({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
open,
|
||||
files,
|
||||
visibleFiles,
|
||||
selectedFiles,
|
||||
setSelectedFiles,
|
||||
onRefresh,
|
||||
onRename,
|
||||
onDelete,
|
||||
onNewFolder,
|
||||
}: UseSftpModalKeyboardShortcutsParams) => {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
// Skip if shortcuts are disabled or modal is not open
|
||||
if (hotkeyScheme === "disabled" || !open) return;
|
||||
|
||||
// Skip if focus is on an input element
|
||||
const target = e.target as HTMLElement;
|
||||
const isEditableTarget =
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable ||
|
||||
!!target.closest?.(".monaco-editor, .monaco-diff-editor, .monaco-inputbox");
|
||||
if (isEditableTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isMac = hotkeyScheme === "mac";
|
||||
const matched = matchSftpAction(e, keyBindings, isMac);
|
||||
if (!matched) return;
|
||||
|
||||
const { action } = matched;
|
||||
if (!SFTP_MODAL_ACTIONS.has(action)) return;
|
||||
|
||||
// Prevent default behavior
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
switch (action) {
|
||||
case "sftpSelectAll": {
|
||||
// Select all files
|
||||
const allFileNames = new Set(
|
||||
visibleFiles.filter((f) => f.name !== "..").map((f) => f.name)
|
||||
);
|
||||
setSelectedFiles(allFileNames);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpRename": {
|
||||
// Trigger rename for the first selected file
|
||||
const selectedArray = Array.from(selectedFiles);
|
||||
if (selectedArray.length !== 1) return;
|
||||
const file = files.find((f) => f.name === selectedArray[0]);
|
||||
if (file && onRename) {
|
||||
onRename(file);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpDelete": {
|
||||
// Delete selected files
|
||||
const selectedArray = Array.from(selectedFiles);
|
||||
if (selectedArray.length === 0) return;
|
||||
onDelete?.(selectedArray);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpRefresh": {
|
||||
// Refresh file list
|
||||
onRefresh();
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpNewFolder": {
|
||||
// Create new folder
|
||||
onNewFolder?.();
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
hotkeyScheme,
|
||||
open,
|
||||
files,
|
||||
visibleFiles,
|
||||
selectedFiles,
|
||||
setSelectedFiles,
|
||||
onRefresh,
|
||||
onRename,
|
||||
onDelete,
|
||||
onNewFolder,
|
||||
keyBindings,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Use capture phase to intercept before other handlers
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, [handleKeyDown]);
|
||||
};
|
||||
@@ -1,135 +0,0 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { breadcrumbPathAt, getBreadcrumbs, getRootPath, getWindowsDrive, isWindowsPath } from "../pathUtils";
|
||||
|
||||
interface UseSftpModalPathParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
localHomePath: string | null;
|
||||
onNavigate: (path: string) => void;
|
||||
maxVisibleBreadcrumbParts?: number;
|
||||
}
|
||||
|
||||
interface UseSftpModalPathResult {
|
||||
isEditingPath: boolean;
|
||||
editingPathValue: string;
|
||||
setEditingPathValue: (value: string) => void;
|
||||
pathInputRef: React.RefObject<HTMLInputElement>;
|
||||
handlePathDoubleClick: () => void;
|
||||
handlePathSubmit: () => void;
|
||||
handlePathKeyDown: (e: React.KeyboardEvent) => void;
|
||||
breadcrumbs: string[];
|
||||
visibleBreadcrumbs: { part: string; originalIndex: number }[];
|
||||
hiddenBreadcrumbs: { part: string; originalIndex: number }[];
|
||||
needsBreadcrumbTruncation: boolean;
|
||||
breadcrumbPathAtForIndex: (index: number) => string;
|
||||
rootLabel: string;
|
||||
rootPath: string;
|
||||
}
|
||||
|
||||
export const useSftpModalPath = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
localHomePath,
|
||||
onNavigate,
|
||||
maxVisibleBreadcrumbParts = 4,
|
||||
}: UseSftpModalPathParams): UseSftpModalPathResult => {
|
||||
const [isEditingPath, setIsEditingPath] = useState(false);
|
||||
const [editingPathValue, setEditingPathValue] = useState("");
|
||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handlePathDoubleClick = () => {
|
||||
setEditingPathValue(currentPath);
|
||||
setIsEditingPath(true);
|
||||
setTimeout(() => pathInputRef.current?.select(), 0);
|
||||
};
|
||||
|
||||
const handlePathSubmit = () => {
|
||||
const fallbackPath = localHomePath || getRootPath(currentPath, isLocalSession);
|
||||
const newPath = editingPathValue.trim() || fallbackPath;
|
||||
setIsEditingPath(false);
|
||||
if (newPath !== currentPath) {
|
||||
if (isLocalSession) {
|
||||
onNavigate(newPath);
|
||||
} else {
|
||||
onNavigate(newPath.startsWith("/") ? newPath : `/${newPath}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePathKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handlePathSubmit();
|
||||
} else if (e.key === "Escape") {
|
||||
setIsEditingPath(false);
|
||||
}
|
||||
};
|
||||
|
||||
const breadcrumbs = useMemo(
|
||||
() => getBreadcrumbs(currentPath, isLocalSession),
|
||||
[currentPath, isLocalSession],
|
||||
);
|
||||
|
||||
const { visibleBreadcrumbs, hiddenBreadcrumbs, needsBreadcrumbTruncation } =
|
||||
useMemo(() => {
|
||||
if (breadcrumbs.length <= maxVisibleBreadcrumbParts) {
|
||||
return {
|
||||
visibleBreadcrumbs: breadcrumbs.map((part, idx) => ({ part, originalIndex: idx })),
|
||||
hiddenBreadcrumbs: [] as { part: string; originalIndex: number }[],
|
||||
needsBreadcrumbTruncation: false,
|
||||
};
|
||||
}
|
||||
|
||||
const firstPart = [{ part: breadcrumbs[0], originalIndex: 0 }];
|
||||
const lastPartsCount = maxVisibleBreadcrumbParts - 1;
|
||||
const lastParts = breadcrumbs.slice(-lastPartsCount).map((part, idx) => ({
|
||||
part,
|
||||
originalIndex: breadcrumbs.length - lastPartsCount + idx,
|
||||
}));
|
||||
const hidden = breadcrumbs.slice(1, -lastPartsCount).map((part, idx) => ({
|
||||
part,
|
||||
originalIndex: idx + 1,
|
||||
}));
|
||||
|
||||
return {
|
||||
visibleBreadcrumbs: [...firstPart, ...lastParts],
|
||||
hiddenBreadcrumbs: hidden,
|
||||
needsBreadcrumbTruncation: true,
|
||||
};
|
||||
}, [breadcrumbs, maxVisibleBreadcrumbParts]);
|
||||
|
||||
const breadcrumbPathAtForIndex = useCallback(
|
||||
(index: number) =>
|
||||
breadcrumbPathAt(breadcrumbs, index, currentPath, isLocalSession),
|
||||
[breadcrumbs, currentPath, isLocalSession],
|
||||
);
|
||||
|
||||
const rootLabel = useMemo(
|
||||
() =>
|
||||
isLocalSession && isWindowsPath(currentPath)
|
||||
? getWindowsDrive(currentPath) ?? "C:"
|
||||
: "/",
|
||||
[currentPath, isLocalSession],
|
||||
);
|
||||
|
||||
const rootPath = useMemo(
|
||||
() => getRootPath(currentPath, isLocalSession),
|
||||
[currentPath, isLocalSession],
|
||||
);
|
||||
|
||||
return {
|
||||
isEditingPath,
|
||||
editingPathValue,
|
||||
setEditingPathValue,
|
||||
pathInputRef,
|
||||
handlePathDoubleClick,
|
||||
handlePathSubmit,
|
||||
handlePathKeyDown,
|
||||
breadcrumbs,
|
||||
visibleBreadcrumbs,
|
||||
hiddenBreadcrumbs,
|
||||
needsBreadcrumbTruncation,
|
||||
breadcrumbPathAtForIndex,
|
||||
rootLabel,
|
||||
rootPath,
|
||||
};
|
||||
};
|
||||
@@ -1,189 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
interface UseSftpModalPermissionsParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
chmodSftp: (sftpId: string, path: string, permissions: string) => Promise<void>;
|
||||
statSftp: (sftpId: string, path: string) => Promise<{ permissions?: string }>;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
interface PermissionsState {
|
||||
owner: { read: boolean; write: boolean; execute: boolean };
|
||||
group: { read: boolean; write: boolean; execute: boolean };
|
||||
others: { read: boolean; write: boolean; execute: boolean };
|
||||
}
|
||||
|
||||
interface UseSftpModalPermissionsResult {
|
||||
showPermissionsDialog: boolean;
|
||||
setShowPermissionsDialog: (open: boolean) => void;
|
||||
permissionsTarget: RemoteFile | null;
|
||||
permissions: PermissionsState;
|
||||
isChangingPermissions: boolean;
|
||||
openPermissionsDialog: (file: RemoteFile) => Promise<void>;
|
||||
togglePermission: (role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => void;
|
||||
getOctalPermissions: () => string;
|
||||
getSymbolicPermissions: () => string;
|
||||
handleSavePermissions: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpModalPermissions = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
chmodSftp,
|
||||
statSftp,
|
||||
t,
|
||||
}: UseSftpModalPermissionsParams): UseSftpModalPermissionsResult => {
|
||||
const [showPermissionsDialog, setShowPermissionsDialog] = useState(false);
|
||||
const [permissionsTarget, setPermissionsTarget] = useState<RemoteFile | null>(null);
|
||||
const [permissions, setPermissions] = useState<PermissionsState>({
|
||||
owner: { read: false, write: false, execute: false },
|
||||
group: { read: false, write: false, execute: false },
|
||||
others: { read: false, write: false, execute: false },
|
||||
});
|
||||
const [isChangingPermissions, setIsChangingPermissions] = useState(false);
|
||||
|
||||
const parsePermissions = useCallback((perms: string | undefined) => {
|
||||
const defaultPerms = {
|
||||
owner: { read: false, write: false, execute: false },
|
||||
group: { read: false, write: false, execute: false },
|
||||
others: { read: false, write: false, execute: false },
|
||||
};
|
||||
if (!perms) return defaultPerms;
|
||||
|
||||
if (/^[0-7]{3,4}$/.test(perms)) {
|
||||
const octal = perms.length === 4 ? perms.slice(1) : perms;
|
||||
const ownerBits = parseInt(octal[0], 10);
|
||||
const groupBits = parseInt(octal[1], 10);
|
||||
const othersBits = parseInt(octal[2], 10);
|
||||
return {
|
||||
owner: {
|
||||
read: (ownerBits & 4) !== 0,
|
||||
write: (ownerBits & 2) !== 0,
|
||||
execute: (ownerBits & 1) !== 0,
|
||||
},
|
||||
group: {
|
||||
read: (groupBits & 4) !== 0,
|
||||
write: (groupBits & 2) !== 0,
|
||||
execute: (groupBits & 1) !== 0,
|
||||
},
|
||||
others: {
|
||||
read: (othersBits & 4) !== 0,
|
||||
write: (othersBits & 2) !== 0,
|
||||
execute: (othersBits & 1) !== 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const pStr = perms.length === 10 ? perms.slice(1) : perms;
|
||||
if (pStr.length >= 9) {
|
||||
return {
|
||||
owner: {
|
||||
read: pStr[0] === "r",
|
||||
write: pStr[1] === "w",
|
||||
execute: pStr[2] === "x" || pStr[2] === "s",
|
||||
},
|
||||
group: {
|
||||
read: pStr[3] === "r",
|
||||
write: pStr[4] === "w",
|
||||
execute: pStr[5] === "x" || pStr[5] === "s",
|
||||
},
|
||||
others: {
|
||||
read: pStr[6] === "r",
|
||||
write: pStr[7] === "w",
|
||||
execute: pStr[8] === "x" || pStr[8] === "t",
|
||||
},
|
||||
};
|
||||
}
|
||||
return defaultPerms;
|
||||
}, []);
|
||||
|
||||
const openPermissionsDialog = useCallback(async (file: RemoteFile) => {
|
||||
if (isLocalSession) {
|
||||
toast.error("Permissions not available for local files", "SFTP");
|
||||
return;
|
||||
}
|
||||
setPermissionsTarget(file);
|
||||
|
||||
let permsStr = file.permissions;
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, file.name);
|
||||
const stat = await statSftp(await ensureSftp(), fullPath);
|
||||
if (stat.permissions) {
|
||||
permsStr = stat.permissions;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to fetch file permissions:", e);
|
||||
}
|
||||
|
||||
setPermissions(parsePermissions(permsStr));
|
||||
setShowPermissionsDialog(true);
|
||||
}, [currentPath, ensureSftp, isLocalSession, joinPath, parsePermissions, statSftp]);
|
||||
|
||||
const togglePermission = useCallback(
|
||||
(role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => {
|
||||
setPermissions((prev) => ({
|
||||
...prev,
|
||||
[role]: { ...prev[role], [perm]: !prev[role][perm] },
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getOctalPermissions = useCallback(() => {
|
||||
const getNum = (p: { read: boolean; write: boolean; execute: boolean }) =>
|
||||
(p.read ? 4 : 0) + (p.write ? 2 : 0) + (p.execute ? 1 : 0);
|
||||
return `${getNum(permissions.owner)}${getNum(permissions.group)}${getNum(permissions.others)}`;
|
||||
}, [permissions]);
|
||||
|
||||
const getSymbolicPermissions = useCallback(() => {
|
||||
const getSym = (p: { read: boolean; write: boolean; execute: boolean }) =>
|
||||
`${p.read ? "r" : "-"}${p.write ? "w" : "-"}${p.execute ? "x" : "-"}`;
|
||||
return (
|
||||
getSym(permissions.owner) +
|
||||
getSym(permissions.group) +
|
||||
getSym(permissions.others)
|
||||
);
|
||||
}, [permissions]);
|
||||
|
||||
const handleSavePermissions = useCallback(async () => {
|
||||
if (!permissionsTarget || isChangingPermissions) return;
|
||||
setIsChangingPermissions(true);
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, permissionsTarget.name);
|
||||
await chmodSftp(await ensureSftp(), fullPath, getOctalPermissions());
|
||||
setShowPermissionsDialog(false);
|
||||
setPermissionsTarget(null);
|
||||
await loadFiles(currentPath, { force: true });
|
||||
toast.success(t("sftp.permissions.success"), "SFTP");
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.permissions.failed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setIsChangingPermissions(false);
|
||||
}
|
||||
}, [chmodSftp, currentPath, ensureSftp, getOctalPermissions, isChangingPermissions, joinPath, loadFiles, permissionsTarget, t]);
|
||||
|
||||
return {
|
||||
showPermissionsDialog,
|
||||
setShowPermissionsDialog,
|
||||
permissionsTarget,
|
||||
permissions,
|
||||
isChangingPermissions,
|
||||
openPermissionsDialog,
|
||||
togglePermission,
|
||||
getOctalPermissions,
|
||||
getSymbolicPermissions,
|
||||
handleSavePermissions,
|
||||
};
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
interface UseSftpModalRenameParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
renameSftp: (sftpId: string, oldPath: string, newPath: string) => Promise<void>;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
interface UseSftpModalRenameResult {
|
||||
showRenameDialog: boolean;
|
||||
setShowRenameDialog: (open: boolean) => void;
|
||||
renameTarget: RemoteFile | null;
|
||||
renameName: string;
|
||||
setRenameName: (value: string) => void;
|
||||
isRenaming: boolean;
|
||||
openRenameDialog: (file: RemoteFile) => void;
|
||||
handleRename: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpModalRename = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
renameSftp,
|
||||
t,
|
||||
}: UseSftpModalRenameParams): UseSftpModalRenameResult => {
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false);
|
||||
const [renameTarget, setRenameTarget] = useState<RemoteFile | null>(null);
|
||||
const [renameName, setRenameName] = useState("");
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
|
||||
const openRenameDialog = useCallback((file: RemoteFile) => {
|
||||
setRenameTarget(file);
|
||||
setRenameName(file.name);
|
||||
setShowRenameDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleRename = useCallback(async () => {
|
||||
if (!renameTarget || !renameName.trim() || isRenaming) return;
|
||||
if (renameName.trim() === renameTarget.name) {
|
||||
setShowRenameDialog(false);
|
||||
return;
|
||||
}
|
||||
setIsRenaming(true);
|
||||
try {
|
||||
const oldPath = joinPath(currentPath, renameTarget.name);
|
||||
const newPath = joinPath(currentPath, renameName.trim());
|
||||
if (isLocalSession) {
|
||||
toast.error("Local rename not implemented", "SFTP");
|
||||
} else {
|
||||
await renameSftp(await ensureSftp(), oldPath, newPath);
|
||||
}
|
||||
setShowRenameDialog(false);
|
||||
setRenameTarget(null);
|
||||
setRenameName("");
|
||||
await loadFiles(currentPath, { force: true });
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.renameFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setIsRenaming(false);
|
||||
}
|
||||
}, [currentPath, ensureSftp, isLocalSession, joinPath, loadFiles, renameName, renameSftp, renameTarget, t, isRenaming]);
|
||||
|
||||
return {
|
||||
showRenameDialog,
|
||||
setShowRenameDialog,
|
||||
renameTarget,
|
||||
renameName,
|
||||
setRenameName,
|
||||
isRenaming,
|
||||
openRenameDialog,
|
||||
handleRename,
|
||||
};
|
||||
};
|
||||
@@ -1,99 +0,0 @@
|
||||
import React, { useCallback, useRef } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
|
||||
interface UseSftpModalSelectionParams {
|
||||
files: RemoteFile[];
|
||||
setSelectedFiles: (value: Set<string> | ((prev: Set<string>) => Set<string>)) => void;
|
||||
currentPath: string;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
onNavigate: (path: string) => void;
|
||||
onOpenFile: (file: RemoteFile) => void;
|
||||
onNavigateUp: () => void;
|
||||
}
|
||||
|
||||
interface UseSftpModalSelectionResult {
|
||||
handleFileClick: (file: RemoteFile, index: number, e: React.MouseEvent) => void;
|
||||
handleFileDoubleClick: (file: RemoteFile) => void;
|
||||
}
|
||||
|
||||
export const useSftpModalSelection = ({
|
||||
files,
|
||||
setSelectedFiles,
|
||||
currentPath,
|
||||
joinPath,
|
||||
onNavigate,
|
||||
onOpenFile,
|
||||
onNavigateUp,
|
||||
}: UseSftpModalSelectionParams): UseSftpModalSelectionResult => {
|
||||
const lastSelectedIndexRef = useRef<number | null>(null);
|
||||
|
||||
const handleFileClick = useCallback(
|
||||
(file: RemoteFile, index: number, e: React.MouseEvent) => {
|
||||
if (file.name === "..") return;
|
||||
|
||||
if (file.type === "directory") {
|
||||
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
|
||||
const start = Math.min(lastSelectedIndexRef.current, index);
|
||||
const end = Math.max(lastSelectedIndexRef.current, index);
|
||||
const newSelection = new Set<string>();
|
||||
for (let i = start; i <= end; i++) {
|
||||
if (files[i] && files[i].type !== "directory") {
|
||||
newSelection.add(files[i].name);
|
||||
}
|
||||
}
|
||||
setSelectedFiles(newSelection);
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
setSelectedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
|
||||
const start = Math.min(lastSelectedIndexRef.current, index);
|
||||
const end = Math.max(lastSelectedIndexRef.current, index);
|
||||
const newSelection = new Set<string>();
|
||||
for (let i = start; i <= end; i++) {
|
||||
if (files[i] && files[i].type !== "directory") {
|
||||
newSelection.add(files[i].name);
|
||||
}
|
||||
}
|
||||
setSelectedFiles(newSelection);
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
setSelectedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(file.name)) {
|
||||
next.delete(file.name);
|
||||
} else {
|
||||
next.add(file.name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
lastSelectedIndexRef.current = index;
|
||||
} else {
|
||||
setSelectedFiles(new Set([file.name]));
|
||||
lastSelectedIndexRef.current = index;
|
||||
}
|
||||
},
|
||||
[files, setSelectedFiles],
|
||||
);
|
||||
|
||||
const handleFileDoubleClick = useCallback(
|
||||
(file: RemoteFile) => {
|
||||
if (file.name === "..") {
|
||||
onNavigateUp();
|
||||
return;
|
||||
}
|
||||
if (file.type === "directory" || (file.type === "symlink" && file.linkTarget === "directory")) {
|
||||
onNavigate(joinPath(currentPath, file.name));
|
||||
} else {
|
||||
onOpenFile(file);
|
||||
}
|
||||
},
|
||||
[currentPath, joinPath, onNavigate, onNavigateUp, onOpenFile],
|
||||
);
|
||||
|
||||
return { handleFileClick, handleFileDoubleClick };
|
||||
};
|
||||
@@ -1,462 +0,0 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { Host, RemoteFile } from "../../../types";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { isSessionError } from "../../../application/state/sftp/errors";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
interface UseSftpModalSessionParams {
|
||||
open: boolean;
|
||||
host: Host;
|
||||
credentials: {
|
||||
username?: string;
|
||||
hostname: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
passphrase?: string;
|
||||
publicKey?: string;
|
||||
keyId?: string;
|
||||
keySource?: "generated" | "imported";
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
sftpSudo?: boolean;
|
||||
legacyAlgorithms?: boolean;
|
||||
};
|
||||
initialPath?: string;
|
||||
isLocalSession: boolean;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
openSftp: (params: {
|
||||
sessionId: string;
|
||||
hostname: string;
|
||||
username: string;
|
||||
port: number;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
passphrase?: string;
|
||||
publicKey?: string;
|
||||
keyId?: string;
|
||||
keySource?: "generated" | "imported";
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
sudo?: boolean;
|
||||
legacyAlgorithms?: boolean;
|
||||
}) => Promise<string>;
|
||||
closeSftp: (sftpId: string) => Promise<void>;
|
||||
listSftp: (sftpId: string, path: string) => Promise<RemoteFile[]>;
|
||||
listLocalDir: (path: string) => Promise<RemoteFile[]>;
|
||||
getHomeDir: () => Promise<string | null>;
|
||||
onClearSelection: () => void;
|
||||
}
|
||||
|
||||
interface UseSftpModalSessionResult {
|
||||
currentPath: string;
|
||||
setCurrentPath: (path: string) => void;
|
||||
currentPathRef: React.MutableRefObject<string>;
|
||||
files: RemoteFile[];
|
||||
setFiles: (files: RemoteFile[]) => void;
|
||||
loading: boolean;
|
||||
setLoading: (loading: boolean) => void;
|
||||
reconnecting: boolean;
|
||||
sessionVersion: number;
|
||||
ensureSftp: () => Promise<string>;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
closeSftpSession: () => Promise<void>;
|
||||
localHomeRef: React.MutableRefObject<string | null>;
|
||||
}
|
||||
|
||||
export const useSftpModalSession = ({
|
||||
open,
|
||||
host,
|
||||
credentials,
|
||||
initialPath,
|
||||
isLocalSession,
|
||||
t,
|
||||
openSftp,
|
||||
closeSftp,
|
||||
listSftp,
|
||||
listLocalDir,
|
||||
getHomeDir,
|
||||
onClearSelection,
|
||||
}: UseSftpModalSessionParams): UseSftpModalSessionResult => {
|
||||
const [currentPath, setCurrentPathState] = useState("/");
|
||||
const [files, setFiles] = useState<RemoteFile[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
const [sessionVersion, setSessionVersion] = useState(0);
|
||||
const currentPathRef = useRef(currentPath);
|
||||
const sftpIdRef = useRef<string | null>(null);
|
||||
const closingPromiseRef = useRef<Promise<void> | null>(null);
|
||||
const initializedRef = useRef(false);
|
||||
const initializingRef = useRef(false);
|
||||
const lastInitialPathRef = useRef<string | undefined>(undefined);
|
||||
const localHomeRef = useRef<string | null>(null);
|
||||
|
||||
const reconnectingRef = useRef(false);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const MAX_RECONNECT_ATTEMPTS = 3;
|
||||
|
||||
const DIR_CACHE_TTL_MS = 10_000;
|
||||
const dirCacheRef = useRef<
|
||||
Map<string, { files: RemoteFile[]; timestamp: number }>
|
||||
>(new Map());
|
||||
const loadSeqRef = useRef(0);
|
||||
const setCurrentPath = useCallback((path: string) => {
|
||||
currentPathRef.current = path;
|
||||
setCurrentPathState(path);
|
||||
}, []);
|
||||
const bumpSessionVersion = useCallback(() => {
|
||||
setSessionVersion((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
const ensureSftp = useCallback(async () => {
|
||||
if (isLocalSession) throw new Error("Local session does not use SFTP");
|
||||
if (closingPromiseRef.current) {
|
||||
await closingPromiseRef.current;
|
||||
}
|
||||
if (sftpIdRef.current) return sftpIdRef.current;
|
||||
const sftpId = await openSftp({
|
||||
sessionId: `sftp-modal-${host.id}`,
|
||||
hostname: credentials.hostname,
|
||||
username: credentials.username || "root",
|
||||
port: credentials.port || 22,
|
||||
password: credentials.password,
|
||||
privateKey: credentials.privateKey,
|
||||
certificate: credentials.certificate,
|
||||
passphrase: credentials.passphrase,
|
||||
publicKey: credentials.publicKey,
|
||||
keyId: credentials.keyId,
|
||||
keySource: credentials.keySource,
|
||||
proxy: credentials.proxy,
|
||||
jumpHosts: credentials.jumpHosts,
|
||||
sudo: credentials.sftpSudo,
|
||||
legacyAlgorithms: credentials.legacyAlgorithms,
|
||||
});
|
||||
if (sftpIdRef.current !== sftpId) {
|
||||
sftpIdRef.current = sftpId;
|
||||
bumpSessionVersion();
|
||||
}
|
||||
return sftpId;
|
||||
}, [
|
||||
isLocalSession,
|
||||
host.id,
|
||||
credentials.hostname,
|
||||
credentials.username,
|
||||
credentials.port,
|
||||
credentials.password,
|
||||
credentials.privateKey,
|
||||
credentials.certificate,
|
||||
credentials.passphrase,
|
||||
credentials.publicKey,
|
||||
credentials.keyId,
|
||||
credentials.keySource,
|
||||
credentials.proxy,
|
||||
credentials.jumpHosts,
|
||||
credentials.sftpSudo,
|
||||
credentials.legacyAlgorithms,
|
||||
bumpSessionVersion,
|
||||
openSftp,
|
||||
]);
|
||||
|
||||
const closeSftpSession = useCallback(async () => {
|
||||
if (isLocalSession) {
|
||||
if (sftpIdRef.current !== null) {
|
||||
sftpIdRef.current = null;
|
||||
bumpSessionVersion();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear ref before awaiting backend close to avoid handing out a stale ID
|
||||
// if the modal is reopened while close is still in flight.
|
||||
const sftpIdToClose = sftpIdRef.current;
|
||||
if (sftpIdToClose !== null) {
|
||||
sftpIdRef.current = null;
|
||||
bumpSessionVersion();
|
||||
}
|
||||
if (!sftpIdToClose) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentClosePromise = (async () => {
|
||||
try {
|
||||
await closeSftp(sftpIdToClose);
|
||||
} catch {
|
||||
// Silently ignore close errors - connection may already be closed
|
||||
} finally {
|
||||
if (closingPromiseRef.current === currentClosePromise) {
|
||||
closingPromiseRef.current = null;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
closingPromiseRef.current = currentClosePromise;
|
||||
await currentClosePromise;
|
||||
}, [bumpSessionVersion, closeSftp, isLocalSession]);
|
||||
|
||||
// Use shared session-error classifier from errors.ts
|
||||
|
||||
const handleSessionError = useCallback(async () => {
|
||||
if (reconnectingRef.current) return;
|
||||
reconnectingRef.current = true;
|
||||
setReconnecting(true);
|
||||
reconnectAttemptsRef.current = 0;
|
||||
|
||||
while (reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) {
|
||||
try {
|
||||
reconnectAttemptsRef.current += 1;
|
||||
await closeSftpSession();
|
||||
const newSftpId = await ensureSftp();
|
||||
reconnectingRef.current = false;
|
||||
setReconnecting(false);
|
||||
|
||||
// Auto-reload current directory after successful reconnect
|
||||
try {
|
||||
const reloadPath = currentPathRef.current;
|
||||
const reloadRequestId = loadSeqRef.current;
|
||||
const list = await listSftp(newSftpId, reloadPath);
|
||||
if (
|
||||
reloadRequestId !== loadSeqRef.current ||
|
||||
currentPathRef.current !== reloadPath
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onClearSelection();
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::${reloadPath}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
// Reload failed — UI still shows old data, user can manually refresh
|
||||
}
|
||||
return;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`[SFTP] Reconnect attempt ${reconnectAttemptsRef.current} failed`,
|
||||
err,
|
||||
);
|
||||
if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
|
||||
reconnectingRef.current = false;
|
||||
setReconnecting(false);
|
||||
toast.error(t("sftp.error.reconnectFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
}, [closeSftpSession, ensureSftp, listSftp, host.id, onClearSelection, t]);
|
||||
|
||||
const loadFiles = useCallback(
|
||||
async (path: string, options?: { force?: boolean }) => {
|
||||
const requestId = ++loadSeqRef.current;
|
||||
setLoading(true);
|
||||
onClearSelection();
|
||||
|
||||
try {
|
||||
if (isLocalSession) {
|
||||
const list = await listLocalDir(path);
|
||||
if (requestId === loadSeqRef.current) {
|
||||
setFiles(list);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = `${host.id}::${path}`;
|
||||
const cached = dirCacheRef.current.get(cacheKey);
|
||||
const isFresh =
|
||||
cached && Date.now() - cached.timestamp < DIR_CACHE_TTL_MS;
|
||||
if (cached && isFresh && !options?.force) {
|
||||
setFiles(cached.files);
|
||||
return;
|
||||
}
|
||||
|
||||
const sftpId = await ensureSftp();
|
||||
const list = await listSftp(sftpId, path);
|
||||
if (requestId !== loadSeqRef.current) return;
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(cacheKey, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (e) {
|
||||
if (!isLocalSession && isSessionError(e) && files.length > 0) {
|
||||
logger.info("[SFTP] Session lost, attempting to reconnect...");
|
||||
handleSessionError();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error("Failed to load files", e);
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
setFiles([]);
|
||||
} finally {
|
||||
if (loadSeqRef.current === requestId) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t, handleSessionError, files.length, onClearSelection],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const cacheKey = `${host.id}::${currentPath}`;
|
||||
const cached = dirCacheRef.current.get(cacheKey);
|
||||
const isFresh = cached && Date.now() - cached.timestamp < DIR_CACHE_TTL_MS;
|
||||
if (!isFresh) {
|
||||
setFiles([]);
|
||||
onClearSelection();
|
||||
}
|
||||
}, [currentPath, host.id, onClearSelection, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (!initializedRef.current || lastInitialPathRef.current !== initialPath) {
|
||||
initializedRef.current = true;
|
||||
initializingRef.current = true;
|
||||
lastInitialPathRef.current = initialPath;
|
||||
onClearSelection();
|
||||
setLoading(true);
|
||||
|
||||
if (isLocalSession) {
|
||||
(async () => {
|
||||
try {
|
||||
const homePath = await getHomeDir();
|
||||
localHomeRef.current = homePath ?? null;
|
||||
const startPath = initialPath || homePath || "/";
|
||||
try {
|
||||
const list = await listLocalDir(startPath);
|
||||
setCurrentPath(startPath);
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::${startPath}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} finally {
|
||||
initializingRef.current = false;
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const homePath = await getHomeDir();
|
||||
localHomeRef.current = homePath ?? null;
|
||||
if (initialPath) {
|
||||
try {
|
||||
const sftpId = await ensureSftp();
|
||||
const list = await listSftp(sftpId, initialPath);
|
||||
setCurrentPath(initialPath);
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::${initialPath}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
} catch {
|
||||
logger.warn(
|
||||
`[SFTP] Initial path ${initialPath} not accessible, falling back to home`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const sftpId = await ensureSftp();
|
||||
const list = await listSftp(sftpId, homePath || "/");
|
||||
setCurrentPath(homePath || "/");
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::${homePath || "/"}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setLoading(false);
|
||||
} catch {
|
||||
logger.warn(`[SFTP] Home ${homePath} not accessible, using /`);
|
||||
try {
|
||||
const sftpId = await ensureSftp();
|
||||
const list = await listSftp(sftpId, "/");
|
||||
setCurrentPath("/");
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::/`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("[SFTP] Failed to load root directory", e);
|
||||
toast.error(t("sftp.error.loadFailed"), "SFTP");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
initializingRef.current = false;
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
// Skip redundant loadFiles while async initialization is still in flight.
|
||||
// Without this guard, dependency changes (e.g. loadFiles recreation from
|
||||
// files.length change) can re-trigger this effect and call loadFiles with
|
||||
// the stale currentPath before the initialization IIFE has resolved and
|
||||
// updated currentPathRef — causing uploads to target the wrong directory.
|
||||
if (!initializingRef.current) {
|
||||
void loadFiles(currentPath);
|
||||
}
|
||||
} else {
|
||||
loadSeqRef.current += 1;
|
||||
initializedRef.current = false;
|
||||
initializingRef.current = false;
|
||||
}
|
||||
}, [
|
||||
closeSftpSession,
|
||||
currentPath,
|
||||
ensureSftp,
|
||||
getHomeDir,
|
||||
host.id,
|
||||
initialPath,
|
||||
isLocalSession,
|
||||
listLocalDir,
|
||||
listSftp,
|
||||
loadFiles,
|
||||
onClearSelection,
|
||||
open,
|
||||
setCurrentPath,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
void closeSftpSession();
|
||||
};
|
||||
}, [closeSftpSession]);
|
||||
|
||||
return {
|
||||
currentPath,
|
||||
setCurrentPath,
|
||||
currentPathRef,
|
||||
files,
|
||||
setFiles,
|
||||
loading,
|
||||
setLoading,
|
||||
reconnecting,
|
||||
sessionVersion,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
closeSftpSession,
|
||||
localHomeRef,
|
||||
};
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
|
||||
export type SortField = "name" | "size" | "modified";
|
||||
export type SortOrder = "asc" | "desc";
|
||||
|
||||
interface UseSftpModalSortingResult {
|
||||
sortField: SortField;
|
||||
sortOrder: SortOrder;
|
||||
columnWidths: { name: number; size: number; modified: number; actions: number };
|
||||
handleSort: (field: SortField) => void;
|
||||
handleResizeStart: (field: string, e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export const useSftpModalSorting = (): UseSftpModalSortingResult => {
|
||||
const [sortField, setSortField] = useState<SortField>("name");
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc");
|
||||
const [columnWidths, setColumnWidths] = useState({
|
||||
name: 45,
|
||||
size: 15,
|
||||
modified: 25,
|
||||
actions: 15,
|
||||
});
|
||||
|
||||
const resizingRef = useRef<{
|
||||
field: string;
|
||||
startX: number;
|
||||
startWidth: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const handleResizeMove = useCallback((e: MouseEvent) => {
|
||||
if (!resizingRef.current) return;
|
||||
const diff = e.clientX - resizingRef.current.startX;
|
||||
const newWidth = Math.max(
|
||||
10,
|
||||
Math.min(60, resizingRef.current.startWidth + diff / 5),
|
||||
);
|
||||
setColumnWidths((prev) => ({
|
||||
...prev,
|
||||
[resizingRef.current!.field]: newWidth,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
resizingRef.current = null;
|
||||
document.removeEventListener("mousemove", handleResizeMove);
|
||||
document.removeEventListener("mouseup", handleResizeEnd);
|
||||
}, [handleResizeMove]);
|
||||
|
||||
const handleResizeStart = (field: string, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
resizingRef.current = {
|
||||
field,
|
||||
startX: e.clientX,
|
||||
startWidth: columnWidths[field as keyof typeof columnWidths],
|
||||
};
|
||||
document.addEventListener("mousemove", handleResizeMove);
|
||||
document.addEventListener("mouseup", handleResizeEnd);
|
||||
};
|
||||
|
||||
return {
|
||||
sortField,
|
||||
sortOrder,
|
||||
columnWidths,
|
||||
handleSort,
|
||||
handleResizeStart,
|
||||
};
|
||||
};
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
interface UseSftpModalTextEditorParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
joinPath: (base: string, name: string) => string;
|
||||
ensureSftp: () => Promise<string>;
|
||||
readLocalFile: (path: string) => Promise<ArrayBuffer>;
|
||||
readSftp: (sftpId: string, path: string) => Promise<string>;
|
||||
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
|
||||
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
interface UseSftpModalTextEditorResult {
|
||||
showTextEditor: boolean;
|
||||
setShowTextEditor: (open: boolean) => void;
|
||||
textEditorTarget: RemoteFile | null;
|
||||
setTextEditorTarget: (target: RemoteFile | null) => void;
|
||||
textEditorContent: string;
|
||||
setTextEditorContent: (value: string) => void;
|
||||
loadingTextContent: boolean;
|
||||
handleEditFile: (file: RemoteFile) => Promise<void>;
|
||||
handleSaveTextFile: (content: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpModalTextEditor = ({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
joinPath,
|
||||
ensureSftp,
|
||||
readLocalFile,
|
||||
readSftp,
|
||||
writeLocalFile,
|
||||
writeSftp,
|
||||
t,
|
||||
}: UseSftpModalTextEditorParams): UseSftpModalTextEditorResult => {
|
||||
const [showTextEditor, setShowTextEditor] = useState(false);
|
||||
const [textEditorTarget, setTextEditorTarget] = useState<RemoteFile | null>(null);
|
||||
const [textEditorContent, setTextEditorContent] = useState("");
|
||||
const [loadingTextContent, setLoadingTextContent] = useState(false);
|
||||
|
||||
const handleEditFile = useCallback(async (file: RemoteFile) => {
|
||||
try {
|
||||
setLoadingTextContent(true);
|
||||
setTextEditorTarget(file);
|
||||
const fullPath = joinPath(currentPath, file.name);
|
||||
const content = isLocalSession
|
||||
? await readLocalFile(fullPath).then((buf) => new TextDecoder().decode(buf))
|
||||
: await readSftp(await ensureSftp(), fullPath);
|
||||
setTextEditorContent(content);
|
||||
setShowTextEditor(true);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setLoadingTextContent(false);
|
||||
}
|
||||
}, [currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, readSftp, t]);
|
||||
|
||||
const handleSaveTextFile = useCallback(async (content: string) => {
|
||||
if (!textEditorTarget) return;
|
||||
const fullPath = joinPath(currentPath, textEditorTarget.name);
|
||||
if (isLocalSession) {
|
||||
const encoder = new TextEncoder();
|
||||
await writeLocalFile(fullPath, encoder.encode(content).buffer);
|
||||
} else {
|
||||
await writeSftp(await ensureSftp(), fullPath, content);
|
||||
}
|
||||
}, [currentPath, ensureSftp, isLocalSession, joinPath, textEditorTarget, writeLocalFile, writeSftp]);
|
||||
|
||||
return {
|
||||
showTextEditor,
|
||||
setShowTextEditor,
|
||||
textEditorTarget,
|
||||
setTextEditorTarget,
|
||||
textEditorContent,
|
||||
setTextEditorContent,
|
||||
loadingTextContent,
|
||||
handleEditFile,
|
||||
handleSaveTextFile,
|
||||
};
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user