Compare commits
265 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af074c5704 | ||
|
|
c60afdd8fe | ||
|
|
a1d05ca5b3 | ||
|
|
327ca3806a | ||
|
|
2f71dd3927 | ||
|
|
3844edd49f | ||
|
|
8f97a7e81d | ||
|
|
5daf1f0d6f | ||
|
|
b1a5b92ce4 | ||
|
|
c99a70831a | ||
|
|
4b0468b0d2 | ||
|
|
f32078f270 | ||
|
|
a525c073b9 | ||
|
|
afceb92a55 | ||
|
|
4822894efb | ||
|
|
d9b51c3a50 | ||
|
|
15b1dba558 | ||
|
|
fd6b3930c1 | ||
|
|
53cb160a6e | ||
|
|
bb590f140d | ||
|
|
945992b80e | ||
|
|
b8de9ce2b6 | ||
|
|
2c7bce31d4 | ||
|
|
004a5f18de | ||
|
|
731d57d355 | ||
|
|
8c6ff1a6a4 | ||
|
|
f7630b3574 | ||
|
|
76bfe26561 | ||
|
|
7079ea66aa | ||
|
|
6562351955 | ||
|
|
986fdda008 | ||
|
|
af2dc66113 | ||
|
|
cca4a3a37e | ||
|
|
75ec050c31 | ||
|
|
db604e4c41 | ||
|
|
05c48b3d28 | ||
|
|
3bb98c9c27 | ||
|
|
7f4dcce3cb | ||
|
|
766451d9bb | ||
|
|
6f5a2181b2 | ||
|
|
297adbb818 | ||
|
|
13eeb2cf6d | ||
|
|
e9ad65fef6 | ||
|
|
ddb6b5af1e | ||
|
|
c1171d4c7b | ||
|
|
21daccf6ed | ||
|
|
2eed15b4b2 | ||
|
|
de7fdfc4b4 | ||
|
|
709ed12259 | ||
|
|
0826bbb435 | ||
|
|
ec87eb593e | ||
|
|
ecbd50dde4 | ||
|
|
4dd7640452 | ||
|
|
0b08521e63 | ||
|
|
59e768c447 | ||
|
|
6a37b8bbc6 | ||
|
|
9397a781b5 | ||
|
|
255a4730e7 | ||
|
|
de0d1e1912 | ||
|
|
dd50f95583 | ||
|
|
e57376c461 | ||
|
|
3a5a558837 | ||
|
|
506ab33b11 | ||
|
|
198d9c365a | ||
|
|
fbc17356e0 | ||
|
|
a04a28049e | ||
|
|
65267b3c90 | ||
|
|
2196733133 | ||
|
|
67348b42b1 | ||
|
|
e754b2bdc9 | ||
|
|
87e49bc897 | ||
|
|
53212b8669 | ||
|
|
ce7549bb25 | ||
|
|
b5ff5a468e | ||
|
|
b1f9ec43de | ||
|
|
eed2dfb811 | ||
|
|
b7fa6c0405 | ||
|
|
c8d145f52e | ||
|
|
aeacd913f5 | ||
|
|
67b78abfce | ||
|
|
e3b882bdf9 | ||
|
|
6d19413025 | ||
|
|
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 |
53
.github/workflows/build.yml
vendored
53
.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 }}
|
||||
@@ -159,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 }}
|
||||
@@ -226,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
|
||||
@@ -239,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:
|
||||
|
||||
462
App.tsx
462
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';
|
||||
@@ -29,7 +35,7 @@ import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './componen
|
||||
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
|
||||
import { cn } from './lib/utils';
|
||||
import { classifyLocalShellType } from './lib/localShell';
|
||||
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
|
||||
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';
|
||||
@@ -98,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 })),
|
||||
@@ -172,6 +177,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const {
|
||||
setTheme,
|
||||
resolvedTheme,
|
||||
terminalThemeId,
|
||||
setTerminalThemeId,
|
||||
currentTerminalTheme,
|
||||
terminalFontFamilyId,
|
||||
@@ -187,13 +193,26 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
sftpAutoOpenSidebar,
|
||||
sftpDefaultViewMode,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
sessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
reapplyCurrentTheme,
|
||||
immersiveMode,
|
||||
workspaceFocusStyle,
|
||||
} = settings;
|
||||
|
||||
// Sync workspace focus indicator style to DOM for CSS targeting
|
||||
useEffect(() => {
|
||||
if (workspaceFocusStyle === 'border') {
|
||||
document.documentElement.setAttribute('data-workspace-focus', 'border');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-workspace-focus');
|
||||
}
|
||||
}, [workspaceFocusStyle]);
|
||||
|
||||
const {
|
||||
hosts,
|
||||
keys,
|
||||
@@ -271,6 +290,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();
|
||||
|
||||
@@ -316,10 +403,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(() => {
|
||||
@@ -351,7 +576,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;
|
||||
@@ -374,23 +599,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
|
||||
@@ -431,111 +651,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(() => {
|
||||
@@ -852,96 +995,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 [];
|
||||
@@ -954,7 +1022,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);
|
||||
@@ -1043,10 +1111,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: '',
|
||||
@@ -1210,7 +1278,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}
|
||||
@@ -1231,6 +1299,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onToggleTheme={handleToggleTheme}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onSyncNow={handleSyncNowManual}
|
||||
isImmersiveActive={immersiveMode && activeTerminalTheme !== null}
|
||||
onStartSessionDrag={setDraggingSessionId}
|
||||
onEndSessionDrag={handleEndSessionDrag}
|
||||
onReorderTabs={reorderTabs}
|
||||
@@ -1252,6 +1321,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessions={sessions}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
terminalThemeId={terminalThemeId}
|
||||
terminalFontSize={terminalFontSize}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
@@ -1280,7 +1351,21 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
/>
|
||||
</VaultViewContainer>
|
||||
|
||||
<SftpViewMount hosts={hosts} keys={keys} identities={identities} updateHosts={updateHosts} />
|
||||
<SftpViewMount
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
updateHosts={updateHosts}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
sftpShowHiddenFiles={sftpShowHiddenFiles}
|
||||
sftpUseCompressedUpload={sftpUseCompressedUpload}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
/>
|
||||
|
||||
<TerminalLayerMount
|
||||
hosts={hosts}
|
||||
@@ -1321,6 +1406,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
updateHosts={updateHosts}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
sftpShowHiddenFiles={sftpShowHiddenFiles}
|
||||
|
||||
@@ -99,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',
|
||||
@@ -216,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.',
|
||||
@@ -296,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)',
|
||||
@@ -334,6 +355,22 @@ 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 > Workspace Focus Indicator
|
||||
'settings.terminal.section.workspaceFocus': 'Workspace Focus Indicator',
|
||||
'settings.terminal.workspaceFocus.style': 'Focus indicator style',
|
||||
'settings.terminal.workspaceFocus.style.desc': 'How to indicate which pane is focused in split view.',
|
||||
'settings.terminal.workspaceFocus.dim': 'Dim unfocused panes',
|
||||
'settings.terminal.workspaceFocus.border': 'Border on focused pane',
|
||||
|
||||
// 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',
|
||||
@@ -583,6 +620,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',
|
||||
@@ -599,8 +638,21 @@ const en: Messages = {
|
||||
'sftp.dragDropToUpload': 'Drag and drop files here to upload',
|
||||
'sftp.retry': 'Retry',
|
||||
'sftp.context.open': 'Open',
|
||||
'sftp.context.navigateTo': 'Navigate to',
|
||||
'sftp.context.moveTo': 'Move to...',
|
||||
'sftp.context.moveToParent': 'Move to parent directory',
|
||||
'sftp.moveTo.title': 'Move to directory',
|
||||
'sftp.moveTo.placeholder': 'Enter target directory path',
|
||||
'sftp.moveTo.confirm': 'Move',
|
||||
'sftp.moveTo.pathNotFound': 'Directory not found or inaccessible',
|
||||
'sftp.context.download': 'Download',
|
||||
'sftp.context.copyToOtherPane': 'Copy to other pane',
|
||||
'sftp.viewMode.label': 'View mode',
|
||||
'sftp.viewMode.list': 'List view',
|
||||
'sftp.viewMode.tree': 'Tree view',
|
||||
'sftp.tree.loadError': 'Failed to load directory',
|
||||
'sftp.tree.loading': 'Loading...',
|
||||
'sftp.kind.folder': 'Folder',
|
||||
'sftp.context.rename': 'Rename',
|
||||
'sftp.context.permissions': 'Permissions',
|
||||
'sftp.context.delete': 'Delete',
|
||||
@@ -621,6 +673,13 @@ const en: Messages = {
|
||||
'sftp.transfers.active': '{count} active',
|
||||
'sftp.transfers.clearCompleted': 'Clear completed',
|
||||
'sftp.transfers.calculatingTotal': 'Calculating total size...',
|
||||
'sftp.transfers.filesCount': '{count} files',
|
||||
'sftp.transfers.filesProgress': '{current}/{total} files',
|
||||
'sftp.transfers.expandChildren': 'Show files',
|
||||
'sftp.transfers.collapseChildren': 'Hide files',
|
||||
'sftp.transfers.expandChildList': 'Show detail',
|
||||
'sftp.transfers.collapseChildList': 'Hide',
|
||||
'sftp.transfers.dragToResize': 'Drag to resize',
|
||||
'sftp.goUp': 'Go up',
|
||||
'sftp.goToTerminalCwd': 'Go to terminal directory',
|
||||
'sftp.encoding.label': 'Filename Encoding',
|
||||
@@ -640,6 +699,9 @@ const en: Messages = {
|
||||
'sftp.deleteConfirm.single': 'Delete "{name}"?',
|
||||
'sftp.deleteConfirm.title': 'Delete {count} item(s)?',
|
||||
'sftp.deleteConfirm.desc': 'This action cannot be undone. The following will be deleted:',
|
||||
'sftp.deleteConfirm.descSingle': 'This action cannot be undone.',
|
||||
'sftp.deleteConfirm.host': 'Host',
|
||||
'sftp.deleteConfirm.path': 'Path',
|
||||
'sftp.error.loadFailed': 'Failed to load directory',
|
||||
'sftp.error.downloadFailed': 'Download failed',
|
||||
'sftp.error.uploadFailed': 'Upload failed',
|
||||
@@ -694,6 +756,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',
|
||||
@@ -730,6 +793,15 @@ const en: Messages = {
|
||||
|
||||
// Settings > SFTP File Associations
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftp.transferConcurrency': 'Transfer Concurrency',
|
||||
'settings.sftp.transferConcurrency.desc': 'Number of files to transfer in parallel when uploading or downloading folders. Higher values may improve speed but can overwhelm some servers.',
|
||||
'settings.sftp.defaultOpener': 'Default File Opener',
|
||||
'settings.sftp.defaultOpener.desc': 'Choose the default application for opening files without a specific file association',
|
||||
'settings.sftp.defaultOpener.ask': 'Always ask',
|
||||
'settings.sftp.defaultOpener.askDesc': 'Show a dialog to choose an application each time',
|
||||
'settings.sftp.defaultOpener.builtInDesc': 'Open text files in the built-in editor by default',
|
||||
'settings.sftp.defaultOpener.systemApp': 'Choose Application...',
|
||||
'settings.sftp.defaultOpener.systemAppDesc': 'Open files with a specific application by default',
|
||||
'settings.sftpFileAssociations.title': 'SFTP File Associations',
|
||||
'settings.sftpFileAssociations.desc': 'Configure default applications for opening files by extension',
|
||||
'settings.sftpFileAssociations.extension': 'Extension',
|
||||
@@ -758,6 +830,13 @@ const en: Messages = {
|
||||
'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',
|
||||
|
||||
'settings.sftp.defaultViewMode': 'Default View Mode',
|
||||
'settings.sftp.defaultViewMode.desc': 'Choose the default view mode when opening a new SFTP tab. Per-host preferences override this setting.',
|
||||
'settings.sftp.defaultViewMode.list': 'List View',
|
||||
'settings.sftp.defaultViewMode.listDesc': 'Display files in a flat list for the current directory',
|
||||
'settings.sftp.defaultViewMode.tree': 'Tree View',
|
||||
'settings.sftp.defaultViewMode.treeDesc': 'Display files in a hierarchical tree structure',
|
||||
|
||||
'sftp.autoSync.success': 'File synced to remote: {fileName}',
|
||||
'sftp.autoSync.error': 'Failed to sync file: {error}',
|
||||
|
||||
@@ -876,9 +955,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',
|
||||
@@ -889,6 +971,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.',
|
||||
@@ -1497,6 +1583,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',
|
||||
@@ -1563,6 +1650,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',
|
||||
@@ -1596,6 +1687,17 @@ const en: Messages = {
|
||||
'ai.claude.customPathPlaceholder': 'e.g. /usr/local/bin/claude',
|
||||
'ai.claude.check': 'Check',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
'ai.copilot.description': 'Uses GitHub Copilot CLI via ACP over stdio (`copilot --acp --stdio`). Once detected, it can be selected as an external coding agent.',
|
||||
'ai.copilot.detecting': 'Detecting...',
|
||||
'ai.copilot.detected': 'Detected',
|
||||
'ai.copilot.notFound': 'Not found',
|
||||
'ai.copilot.path': 'Path:',
|
||||
'ai.copilot.notFoundHint': 'Could not find copilot in PATH. Install it or specify the executable path below.',
|
||||
'ai.copilot.customPathPlaceholder': 'e.g. /usr/local/bin/copilot',
|
||||
'ai.copilot.check': 'Check',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': 'Default Agent',
|
||||
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
|
||||
|
||||
@@ -83,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': '当前版本',
|
||||
@@ -200,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':
|
||||
@@ -410,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': '修改时间',
|
||||
@@ -426,8 +446,21 @@ const zhCN: Messages = {
|
||||
'sftp.dragDropToUpload': '拖拽文件到这里上传',
|
||||
'sftp.retry': '重试',
|
||||
'sftp.context.open': '打开',
|
||||
'sftp.context.navigateTo': '跳转到这里',
|
||||
'sftp.context.moveTo': '移动到...',
|
||||
'sftp.context.moveToParent': '移动到上级目录',
|
||||
'sftp.moveTo.title': '移动到目录',
|
||||
'sftp.moveTo.placeholder': '输入目标目录路径',
|
||||
'sftp.moveTo.confirm': '移动',
|
||||
'sftp.moveTo.pathNotFound': '目录不存在或无法访问',
|
||||
'sftp.context.download': '下载',
|
||||
'sftp.context.copyToOtherPane': '复制到另一侧',
|
||||
'sftp.viewMode.label': '视图模式',
|
||||
'sftp.viewMode.list': '列表视图',
|
||||
'sftp.viewMode.tree': '树形视图',
|
||||
'sftp.tree.loadError': '加载目录失败',
|
||||
'sftp.tree.loading': '加载中...',
|
||||
'sftp.kind.folder': '文件夹',
|
||||
'sftp.context.rename': '重命名',
|
||||
'sftp.context.permissions': '权限',
|
||||
'sftp.context.delete': '删除',
|
||||
@@ -448,6 +481,13 @@ const zhCN: Messages = {
|
||||
'sftp.transfers.active': '{count} 个进行中',
|
||||
'sftp.transfers.clearCompleted': '清除已完成',
|
||||
'sftp.transfers.calculatingTotal': '正在统计总大小...',
|
||||
'sftp.transfers.filesCount': '{count} 个文件',
|
||||
'sftp.transfers.filesProgress': '{current}/{total} 个文件',
|
||||
'sftp.transfers.expandChildren': '展开文件',
|
||||
'sftp.transfers.collapseChildren': '收起文件',
|
||||
'sftp.transfers.expandChildList': '展开详情',
|
||||
'sftp.transfers.collapseChildList': '收起',
|
||||
'sftp.transfers.dragToResize': '拖拽调整高度',
|
||||
'sftp.goUp': '上一级',
|
||||
'sftp.goToTerminalCwd': '定位到终端当前目录',
|
||||
'sftp.encoding.label': '文件名编码',
|
||||
@@ -467,6 +507,9 @@ const zhCN: Messages = {
|
||||
'sftp.deleteConfirm.single': '删除 "{name}"?',
|
||||
'sftp.deleteConfirm.title': '删除 {count} 个项目?',
|
||||
'sftp.deleteConfirm.desc': '此操作不可撤销,将删除以下内容:',
|
||||
'sftp.deleteConfirm.descSingle': '此操作不可撤销。',
|
||||
'sftp.deleteConfirm.host': '主机',
|
||||
'sftp.deleteConfirm.path': '路径',
|
||||
'sftp.error.loadFailed': '加载目录失败',
|
||||
'sftp.error.downloadFailed': '下载失败',
|
||||
'sftp.error.uploadFailed': '上传失败',
|
||||
@@ -568,9 +611,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': '暂无密钥',
|
||||
@@ -581,6 +627,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 等)以连接老旧网络设备。',
|
||||
@@ -1035,6 +1085,7 @@ const zhCN: Messages = {
|
||||
'sftp.upload.phase.compressed': '压缩传输',
|
||||
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': '复制文件路径',
|
||||
'sftp.context.openWith': '打开方式...',
|
||||
'sftp.context.edit': '编辑',
|
||||
'sftp.context.preview': '预览',
|
||||
@@ -1071,6 +1122,15 @@ const zhCN: Messages = {
|
||||
|
||||
// Settings > SFTP File Associations
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftp.transferConcurrency': '传输并发数',
|
||||
'settings.sftp.transferConcurrency.desc': '上传或下载文件夹时并行传输的文件数量。较高的值可能提高速度,但可能导致某些服务器过载。',
|
||||
'settings.sftp.defaultOpener': '默认文件打开方式',
|
||||
'settings.sftp.defaultOpener.desc': '选择没有特定文件关联时的默认打开方式',
|
||||
'settings.sftp.defaultOpener.ask': '每次询问',
|
||||
'settings.sftp.defaultOpener.askDesc': '每次打开文件时弹出选择对话框',
|
||||
'settings.sftp.defaultOpener.builtInDesc': '默认使用内置编辑器打开文本文件',
|
||||
'settings.sftp.defaultOpener.systemApp': '选择应用程序...',
|
||||
'settings.sftp.defaultOpener.systemAppDesc': '默认使用指定的外部应用程序打开文件',
|
||||
'settings.sftpFileAssociations.title': 'SFTP 文件关联',
|
||||
'settings.sftpFileAssociations.desc': '配置按扩展名打开文件的默认应用程序',
|
||||
'settings.sftpFileAssociations.extension': '扩展名',
|
||||
@@ -1099,6 +1159,13 @@ const zhCN: Messages = {
|
||||
'settings.sftp.autoOpenSidebar.enable': '启用自动打开侧栏',
|
||||
'settings.sftp.autoOpenSidebar.enableDesc': '当终端会话连接到远程主机时,SFTP 侧栏将自动打开',
|
||||
|
||||
'settings.sftp.defaultViewMode': '默认视图模式',
|
||||
'settings.sftp.defaultViewMode.desc': '选择打开新 SFTP 标签页时的默认视图模式。每个主机的偏好设置会覆盖此全局设置。',
|
||||
'settings.sftp.defaultViewMode.list': '列表视图',
|
||||
'settings.sftp.defaultViewMode.listDesc': '以平面列表显示当前目录的文件',
|
||||
'settings.sftp.defaultViewMode.tree': '树形视图',
|
||||
'settings.sftp.defaultViewMode.treeDesc': '以层级树形结构显示文件',
|
||||
|
||||
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
|
||||
'sftp.autoSync.error': '同步文件失败:{error}',
|
||||
|
||||
@@ -1204,6 +1271,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': '无(直接点击)',
|
||||
@@ -1242,6 +1311,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': '键盘快捷键',
|
||||
@@ -1512,6 +1590,7 @@ const zhCN: Messages = {
|
||||
'serial.field.localEchoDesc': '本地回显输入字符(用于没有远程回显的设备)',
|
||||
'serial.field.lineMode': '行模式',
|
||||
'serial.field.lineModeDesc': '缓冲输入,按回车后发送(而不是逐字符发送)',
|
||||
'serial.field.charset': '字符编码',
|
||||
'serial.connectionError': '连接串口失败',
|
||||
'serial.field.baudRatePlaceholder': '选择或输入波特率...',
|
||||
'serial.field.baudRateEmpty': '输入自定义波特率',
|
||||
@@ -1578,6 +1657,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',
|
||||
@@ -1611,6 +1694,17 @@ const zhCN: Messages = {
|
||||
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
|
||||
'ai.claude.check': '检查',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
'ai.copilot.description': '通过 ACP over stdio(`copilot --acp --stdio`)接入 GitHub Copilot CLI。检测到后即可作为外部编程 Agent 使用。',
|
||||
'ai.copilot.detecting': '检测中...',
|
||||
'ai.copilot.detected': '已检测到',
|
||||
'ai.copilot.notFound': '未找到',
|
||||
'ai.copilot.path': '路径:',
|
||||
'ai.copilot.notFoundHint': '在 PATH 中未找到 copilot。请安装或在下方指定可执行文件路径。',
|
||||
'ai.copilot.customPathPlaceholder': '例如 /usr/local/bin/copilot',
|
||||
'ai.copilot.check': '检查',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': '默认 Agent',
|
||||
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
|
||||
|
||||
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,10 +7,12 @@ export interface SftpPane {
|
||||
loading: boolean;
|
||||
reconnecting: boolean;
|
||||
error: string | null;
|
||||
connectionLogs: string[];
|
||||
selectedFiles: Set<string>;
|
||||
filter: string;
|
||||
filenameEncoding: SftpFilenameEncoding;
|
||||
showHiddenFiles: boolean;
|
||||
transferMutationToken: number;
|
||||
}
|
||||
|
||||
// Multi-tab state for left and right sides
|
||||
@@ -33,10 +35,12 @@ export const createEmptyPane = (
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
filenameEncoding: "auto",
|
||||
showHiddenFiles,
|
||||
transferMutationToken: 0,
|
||||
});
|
||||
|
||||
// File watch event types
|
||||
|
||||
@@ -88,6 +88,8 @@ export const useSftpConnections = ({
|
||||
|
||||
if (!activeTabId) return;
|
||||
|
||||
const isReconnectAttempt = reconnectingRef.current[side];
|
||||
|
||||
// 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.
|
||||
@@ -159,6 +161,7 @@ export const useSftpConnections = ({
|
||||
loading: true,
|
||||
reconnecting: false,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
filenameEncoding, // Reset encoding for new connection
|
||||
}));
|
||||
|
||||
@@ -213,13 +216,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");
|
||||
|
||||
@@ -278,8 +325,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");
|
||||
@@ -289,63 +352,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -421,6 +454,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;
|
||||
@@ -434,10 +468,16 @@ export const useSftpConnections = ({
|
||||
error: err instanceof Error ? err.message : "Connection failed",
|
||||
}
|
||||
: null,
|
||||
error: err instanceof Error ? err.message : "Connection failed",
|
||||
files: isReconnectAttempt ? [] : prev.files,
|
||||
selectedFiles: isReconnectAttempt ? new Set<string>() : prev.selectedFiles,
|
||||
error: isReconnectAttempt
|
||||
? "sftp.error.reconnectFailed"
|
||||
: (err instanceof Error ? err.message : "Connection failed"),
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
} finally {
|
||||
unsubSftpProgress?.();
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -462,32 +502,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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -45,7 +45,8 @@ interface SftpExternalOperationsResult {
|
||||
activeFileWatchCountRef: React.MutableRefObject<number>;
|
||||
uploadExternalFiles: (
|
||||
side: "left" | "right",
|
||||
dataTransfer: DataTransfer
|
||||
dataTransfer: DataTransfer,
|
||||
targetPath?: string
|
||||
) => Promise<UploadResult[]>;
|
||||
uploadExternalEntries: (
|
||||
side: "left" | "right",
|
||||
@@ -377,6 +378,7 @@ export const useSftpExternalOperations = (
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: true,
|
||||
progressMode: "bytes",
|
||||
};
|
||||
addExternalUpload(scanningTask);
|
||||
}
|
||||
@@ -404,6 +406,8 @@ export const useSftpExternalOperations = (
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: task.isDirectory,
|
||||
progressMode: task.progressMode ?? "bytes",
|
||||
parentTaskId: task.parentTaskId,
|
||||
};
|
||||
addExternalUpload(transferTask);
|
||||
}
|
||||
@@ -505,7 +509,7 @@ export const useSftpExternalOperations = (
|
||||
}, []);
|
||||
|
||||
const uploadExternalFiles = useCallback(
|
||||
async (side: "left" | "right", dataTransfer: DataTransfer): Promise<UploadResult[]> => {
|
||||
async (side: "left" | "right", dataTransfer: DataTransfer, targetPath?: string): Promise<UploadResult[]> => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("No active connection");
|
||||
@@ -525,13 +529,14 @@ export const useSftpExternalOperations = (
|
||||
}
|
||||
|
||||
const uploadPaneId = pane.id;
|
||||
const uploadTargetPath = targetPath || pane.connection.currentPath;
|
||||
// Create a new upload controller for this upload
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
|
||||
const callbacks = createUploadCallbacks(
|
||||
pane.connection.id,
|
||||
pane.connection.currentPath,
|
||||
uploadTargetPath,
|
||||
pane.connection.isLocal ? undefined : pane.connection.hostId,
|
||||
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
|
||||
);
|
||||
@@ -540,7 +545,7 @@ export const useSftpExternalOperations = (
|
||||
const results = await uploadFromDataTransfer(
|
||||
dataTransfer,
|
||||
{
|
||||
targetPath: pane.connection.currentPath,
|
||||
targetPath: uploadTargetPath,
|
||||
sftpId,
|
||||
isLocal: pane.connection.isLocal,
|
||||
bridge: createUploadBridge,
|
||||
@@ -551,7 +556,14 @@ export const useSftpExternalOperations = (
|
||||
controller
|
||||
);
|
||||
|
||||
await refresh(side, { tabId: uploadPaneId });
|
||||
// Invalidate cache for the upload target so returning to that path
|
||||
// triggers a fresh listing.
|
||||
if (clearDirCacheEntry && targetPath) {
|
||||
clearDirCacheEntry(pane.connection.id, uploadTargetPath);
|
||||
}
|
||||
if (uploadTargetPath === pane.connection.currentPath) {
|
||||
await refresh(side, { tabId: uploadPaneId });
|
||||
}
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error("[SFTP] Upload failed:", error);
|
||||
@@ -561,6 +573,7 @@ export const useSftpExternalOperations = (
|
||||
}
|
||||
},
|
||||
[
|
||||
clearDirCacheEntry,
|
||||
connectionCacheKeyMapRef,
|
||||
getActivePane,
|
||||
refresh,
|
||||
@@ -634,7 +647,9 @@ export const useSftpExternalOperations = (
|
||||
if (clearDirCacheEntry) {
|
||||
clearDirCacheEntry(pane.connection.id, uploadTargetPath);
|
||||
}
|
||||
await refresh(side, { tabId: uploadPaneId });
|
||||
if (uploadTargetPath === pane.connection.currentPath) {
|
||||
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],
|
||||
|
||||
@@ -3,9 +3,12 @@ import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { SftpPane } from "./types";
|
||||
import { getParentPath, isNavigableDirectory, isWindowsRoot, joinPath } from "./utils";
|
||||
import { getFileName, getParentPath, isNavigableDirectory, isWindowsRoot, joinPath } from "./utils";
|
||||
import { buildCacheKey, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
|
||||
|
||||
/** Shared empty set for navigation resets — never mutate this. */
|
||||
const EMPTY_SET = new Set<string>();
|
||||
|
||||
interface UseSftpPaneActionsParams {
|
||||
hosts: Host[];
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
@@ -25,6 +28,7 @@ interface UseSftpPaneActionsParams {
|
||||
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||||
handleSessionError: (side: "left" | "right", error: Error) => void;
|
||||
isSessionError: (err: unknown) => boolean;
|
||||
clearSelectionsExcept: (target: { side: "left" | "right"; tabId: string } | null) => void;
|
||||
dirCacheTtlMs: number;
|
||||
}
|
||||
|
||||
@@ -40,7 +44,9 @@ interface UseSftpPaneActionsResult {
|
||||
setFilter: (side: "left" | "right", filter: string) => void;
|
||||
getFilteredFiles: (pane: SftpPane) => SftpFileEntry[];
|
||||
createDirectory: (side: "left" | "right", name: string) => Promise<void>;
|
||||
createDirectoryAtPath: (side: "left" | "right", path: string, name: string) => Promise<void>;
|
||||
createFile: (side: "left" | "right", name: string) => Promise<void>;
|
||||
createFileAtPath: (side: "left" | "right", path: string, name: string) => Promise<void>;
|
||||
deleteFiles: (side: "left" | "right", fileNames: string[]) => Promise<void>;
|
||||
deleteFilesAtPath: (
|
||||
side: "left" | "right",
|
||||
@@ -49,6 +55,8 @@ interface UseSftpPaneActionsResult {
|
||||
fileNames: string[],
|
||||
) => Promise<void>;
|
||||
renameFile: (side: "left" | "right", oldName: string, newName: string) => Promise<void>;
|
||||
renameFileAtPath: (side: "left" | "right", oldPath: string, newName: string) => Promise<void>;
|
||||
moveEntriesToPath: (side: "left" | "right", sourcePaths: string[], targetPath: string) => Promise<void>;
|
||||
changePermissions: (side: "left" | "right", filePath: string, mode: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -71,8 +79,39 @@ export const useSftpPaneActions = ({
|
||||
listRemoteFiles,
|
||||
handleSessionError,
|
||||
isSessionError,
|
||||
clearSelectionsExcept,
|
||||
dirCacheTtlMs,
|
||||
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
|
||||
const normalizePathForCompare = useCallback((path: string): string => {
|
||||
if (isWindowsRoot(path)) return path.replace(/\//g, "\\").toLowerCase();
|
||||
if (/^[A-Za-z]:/.test(path)) {
|
||||
return path.replace(/\//g, "\\").replace(/[\\]+$/, "").toLowerCase();
|
||||
}
|
||||
if (path === "/") return "/";
|
||||
return path.replace(/\/+$/, "");
|
||||
}, []);
|
||||
|
||||
const isSamePath = useCallback((a: string, b: string): boolean => {
|
||||
return normalizePathForCompare(a) === normalizePathForCompare(b);
|
||||
}, [normalizePathForCompare]);
|
||||
|
||||
const isDescendantPath = useCallback((candidate: string, parent: string): boolean => {
|
||||
const normalizedCandidate = normalizePathForCompare(candidate);
|
||||
const normalizedParent = normalizePathForCompare(parent);
|
||||
if (normalizedCandidate === normalizedParent) return false;
|
||||
|
||||
if (/^[a-z]:\\$/.test(normalizedParent)) {
|
||||
return normalizedCandidate.startsWith(normalizedParent);
|
||||
}
|
||||
|
||||
if (normalizedParent === "/") {
|
||||
return normalizedCandidate.startsWith("/");
|
||||
}
|
||||
|
||||
const separator = normalizedParent.includes("\\") ? "\\" : "/";
|
||||
return normalizedCandidate.startsWith(`${normalizedParent}${separator}`);
|
||||
}, [normalizePathForCompare]);
|
||||
|
||||
// Build the shared cache key for the active pane. Prefer the last connected
|
||||
// host (which includes session-time overrides), fall back to the vault hosts list.
|
||||
const hostsRef = useRef(hosts);
|
||||
@@ -146,7 +185,7 @@ export const useSftpPaneActions = ({
|
||||
connectionId,
|
||||
path,
|
||||
files: cached.files,
|
||||
selectedFiles: new Set(),
|
||||
selectedFiles: EMPTY_SET,
|
||||
});
|
||||
updateTab(side, targetTabId, (prev) => ({
|
||||
...prev,
|
||||
@@ -156,7 +195,7 @@ export const useSftpPaneActions = ({
|
||||
files: cached.files,
|
||||
loading: false,
|
||||
error: null,
|
||||
selectedFiles: new Set(),
|
||||
selectedFiles: EMPTY_SET,
|
||||
}));
|
||||
if (!pane.connection.isLocal) {
|
||||
// Use hostId as the shared cache key — this is safe because the
|
||||
@@ -200,7 +239,7 @@ export const useSftpPaneActions = ({
|
||||
connection: prev.connection
|
||||
? { ...prev.connection, currentPath: path }
|
||||
: null,
|
||||
selectedFiles: new Set(),
|
||||
selectedFiles: EMPTY_SET,
|
||||
loading: true,
|
||||
error: null,
|
||||
}));
|
||||
@@ -270,7 +309,7 @@ export const useSftpPaneActions = ({
|
||||
connectionId,
|
||||
path,
|
||||
files,
|
||||
selectedFiles: new Set(),
|
||||
selectedFiles: EMPTY_SET,
|
||||
});
|
||||
|
||||
updateTab(side, targetTabId, (prev) => ({
|
||||
@@ -280,7 +319,7 @@ export const useSftpPaneActions = ({
|
||||
: null,
|
||||
files,
|
||||
loading: false,
|
||||
selectedFiles: new Set(),
|
||||
selectedFiles: EMPTY_SET,
|
||||
}));
|
||||
if (!pane.connection.isLocal) {
|
||||
setSharedRemoteHostCache(getActivePaneCacheKey(side, pane.connection.hostId, pane.connection.id), {
|
||||
@@ -340,6 +379,25 @@ export const useSftpPaneActions = ({
|
||||
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
|
||||
: getActivePane(side);
|
||||
if (pane?.connection) {
|
||||
const hasRemoteSession = pane.connection.isLocal || sftpSessionsRef.current.has(pane.connection.id);
|
||||
if (!hasRemoteSession) {
|
||||
if (options?.tabId) return;
|
||||
const lastHost = lastConnectedHostRef.current[side];
|
||||
if (lastHost && !reconnectingRef.current[side]) {
|
||||
reconnectingRef.current[side] = true;
|
||||
updateActiveTab(side, (prev) => ({
|
||||
...prev,
|
||||
reconnecting: true,
|
||||
error: "sftp.reconnecting.title",
|
||||
}));
|
||||
} else if (!lastHost) {
|
||||
updateActiveTab(side, (prev) => ({
|
||||
...prev,
|
||||
error: "sftp.error.connectionLostManual",
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
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
|
||||
@@ -362,7 +420,7 @@ export const useSftpPaneActions = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[getActivePane, leftTabsRef, rightTabsRef, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef],
|
||||
[getActivePane, leftTabsRef, rightTabsRef, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef, sftpSessionsRef],
|
||||
);
|
||||
|
||||
const navigateUp = useCallback(
|
||||
@@ -409,6 +467,10 @@ export const useSftpPaneActions = ({
|
||||
|
||||
const toggleSelection = useCallback(
|
||||
(side: "left" | "right", fileName: string, multiSelect: boolean) => {
|
||||
const activeTabId = (side === "left" ? leftTabsRef : rightTabsRef).current.activeTabId;
|
||||
if (activeTabId) {
|
||||
clearSelectionsExcept({ side, tabId: activeTabId });
|
||||
}
|
||||
updateActiveTab(side, (prev) => {
|
||||
const newSelection = new Set(multiSelect ? prev.selectedFiles : []);
|
||||
if (newSelection.has(fileName)) {
|
||||
@@ -419,11 +481,15 @@ export const useSftpPaneActions = ({
|
||||
return { ...prev, selectedFiles: newSelection };
|
||||
});
|
||||
},
|
||||
[updateActiveTab],
|
||||
[updateActiveTab, clearSelectionsExcept, leftTabsRef, rightTabsRef],
|
||||
);
|
||||
|
||||
const rangeSelect = useCallback(
|
||||
(side: "left" | "right", fileNames: string[]) => {
|
||||
const activeTabId = (side === "left" ? leftTabsRef : rightTabsRef).current.activeTabId;
|
||||
if (activeTabId) {
|
||||
clearSelectionsExcept({ side, tabId: activeTabId });
|
||||
}
|
||||
const newSelection = new Set<string>();
|
||||
for (const name of fileNames) {
|
||||
if (name && name !== "..") {
|
||||
@@ -433,11 +499,11 @@ export const useSftpPaneActions = ({
|
||||
|
||||
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: newSelection }));
|
||||
},
|
||||
[updateActiveTab],
|
||||
[updateActiveTab, clearSelectionsExcept, leftTabsRef, rightTabsRef],
|
||||
);
|
||||
|
||||
const clearSelection = useCallback((side: "left" | "right") => {
|
||||
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: new Set() }));
|
||||
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: EMPTY_SET }));
|
||||
}, [updateActiveTab]);
|
||||
|
||||
const selectAll = useCallback(
|
||||
@@ -467,12 +533,12 @@ export const useSftpPaneActions = ({
|
||||
);
|
||||
}, []);
|
||||
|
||||
const createDirectory = useCallback(
|
||||
async (side: "left" | "right", name: string) => {
|
||||
const createDirectoryAtPath = useCallback(
|
||||
async (side: "left" | "right", path: string, name: string) => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) return;
|
||||
|
||||
const fullPath = joinPath(pane.connection.currentPath, name);
|
||||
const fullPath = joinPath(path, name);
|
||||
|
||||
try {
|
||||
if (pane.connection.isLocal) {
|
||||
@@ -485,7 +551,9 @@ export const useSftpPaneActions = ({
|
||||
}
|
||||
await netcattyBridge.get()?.mkdirSftp(sftpId, fullPath, pane.filenameEncoding);
|
||||
}
|
||||
await refresh(side);
|
||||
if (pane.connection.currentPath === path) {
|
||||
await refresh(side);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
handleSessionError(side, err as Error);
|
||||
@@ -497,12 +565,21 @@ export const useSftpPaneActions = ({
|
||||
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||||
);
|
||||
|
||||
const createFile = useCallback(
|
||||
const createDirectory = useCallback(
|
||||
async (side: "left" | "right", name: string) => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) return;
|
||||
await createDirectoryAtPath(side, pane.connection.currentPath, name);
|
||||
},
|
||||
[createDirectoryAtPath, getActivePane],
|
||||
);
|
||||
|
||||
const fullPath = joinPath(pane.connection.currentPath, name);
|
||||
const createFileAtPath = useCallback(
|
||||
async (side: "left" | "right", path: string, name: string) => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) return;
|
||||
|
||||
const fullPath = joinPath(path, name);
|
||||
|
||||
try {
|
||||
if (pane.connection.isLocal) {
|
||||
@@ -529,7 +606,9 @@ export const useSftpPaneActions = ({
|
||||
throw new Error("No write method available");
|
||||
}
|
||||
}
|
||||
await refresh(side);
|
||||
if (pane.connection.currentPath === path) {
|
||||
await refresh(side);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
handleSessionError(side, err as Error);
|
||||
@@ -541,6 +620,15 @@ export const useSftpPaneActions = ({
|
||||
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||||
);
|
||||
|
||||
const createFile = useCallback(
|
||||
async (side: "left" | "right", name: string) => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) return;
|
||||
await createFileAtPath(side, pane.connection.currentPath, name);
|
||||
},
|
||||
[createFileAtPath, getActivePane],
|
||||
);
|
||||
|
||||
const deleteFiles = useCallback(
|
||||
async (side: "left" | "right", fileNames: string[]) => {
|
||||
const pane = getActivePane(side);
|
||||
@@ -686,6 +774,139 @@ export const useSftpPaneActions = ({
|
||||
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||||
);
|
||||
|
||||
// Rename using a full source path (for tree view where entryPath is already absolute).
|
||||
// newName is still a basename; the new path is built as joinPath(parent, newName).
|
||||
const renameFileAtPath = useCallback(
|
||||
async (side: "left" | "right", oldPath: string, newName: string) => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) return;
|
||||
|
||||
const parentPath = getParentPath(oldPath);
|
||||
const newPath = joinPath(parentPath, newName);
|
||||
|
||||
try {
|
||||
if (pane.connection.isLocal) {
|
||||
await netcattyBridge.get()?.renameLocalFile?.(oldPath, newPath);
|
||||
} else {
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
handleSessionError(side, new Error("SFTP session not found"));
|
||||
return;
|
||||
}
|
||||
await netcattyBridge.get()?.renameSftp?.(sftpId, oldPath, newPath, pane.filenameEncoding);
|
||||
}
|
||||
if (pane.connection.currentPath === parentPath) {
|
||||
await refresh(side);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
handleSessionError(side, err as Error);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||||
);
|
||||
|
||||
const moveEntriesToPath = useCallback(
|
||||
async (side: "left" | "right", sourcePaths: string[], targetPath: string) => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection || sourcePaths.length === 0) return;
|
||||
|
||||
const uniqueSources = Array.from(new Set(sourcePaths.filter(Boolean)));
|
||||
const filteredSources = uniqueSources
|
||||
.sort((a, b) => a.length - b.length)
|
||||
.filter((path, index, arr) =>
|
||||
!arr.slice(0, index).some((otherPath) => isSamePath(path, otherPath) || isDescendantPath(path, otherPath)),
|
||||
);
|
||||
|
||||
const movableSources = filteredSources.filter((sourcePath) => {
|
||||
if (isSamePath(sourcePath, targetPath)) return false;
|
||||
if (isDescendantPath(targetPath, sourcePath)) return false;
|
||||
const destinationPath = joinPath(targetPath, getFileName(sourcePath));
|
||||
return !isSamePath(destinationPath, sourcePath);
|
||||
});
|
||||
|
||||
if (movableSources.length === 0) return;
|
||||
|
||||
const sourceParentNames = new Map<string, string[]>();
|
||||
for (const sourcePath of movableSources) {
|
||||
const parentPath = getParentPath(sourcePath);
|
||||
const names = sourceParentNames.get(parentPath) ?? [];
|
||||
names.push(getFileName(sourcePath));
|
||||
sourceParentNames.set(parentPath, names);
|
||||
}
|
||||
|
||||
try {
|
||||
if (pane.connection.isLocal) {
|
||||
const renameLocalFile = netcattyBridge.get()?.renameLocalFile;
|
||||
if (!renameLocalFile) {
|
||||
throw new Error("Local rename unavailable");
|
||||
}
|
||||
for (const sourcePath of movableSources) {
|
||||
const destinationPath = joinPath(targetPath, getFileName(sourcePath));
|
||||
await renameLocalFile(sourcePath, destinationPath);
|
||||
}
|
||||
} else {
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
handleSessionError(side, new Error("SFTP session not found"));
|
||||
return;
|
||||
}
|
||||
const renameSftp = netcattyBridge.get()?.renameSftp;
|
||||
if (!renameSftp) {
|
||||
throw new Error("SFTP rename unavailable");
|
||||
}
|
||||
for (const sourcePath of movableSources) {
|
||||
const destinationPath = joinPath(targetPath, getFileName(sourcePath));
|
||||
await renameSftp(sftpId, sourcePath, destinationPath, pane.filenameEncoding);
|
||||
}
|
||||
}
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
const currentPath = pane.connection.currentPath;
|
||||
const sourceParents = Array.from(sourceParentNames.keys());
|
||||
const currentPathAffected =
|
||||
sourceParents.some((path) => isSamePath(path, currentPath)) ||
|
||||
isSamePath(targetPath, currentPath);
|
||||
|
||||
if (currentPathAffected) {
|
||||
await refresh(side);
|
||||
} else {
|
||||
updateActiveTab(side, (prev) => {
|
||||
if (!prev.connection || prev.connection.id !== pane.connection?.id) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const namesInCurrentPath = sourceParentNames.get(prev.connection.currentPath);
|
||||
if (!namesInCurrentPath || namesInCurrentPath.length === 0) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const removeSet = new Set(namesInCurrentPath);
|
||||
const nextSelection = new Set(prev.selectedFiles);
|
||||
for (const name of removeSet) {
|
||||
nextSelection.delete(name);
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
files: prev.files.filter((file) => !removeSet.has(file.name)),
|
||||
selectedFiles: nextSelection,
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
handleSessionError(side, err as Error);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[clearCacheForConnection, getActivePane, handleSessionError, isDescendantPath, isSamePath, isSessionError, refresh, sftpSessionsRef, updateActiveTab],
|
||||
);
|
||||
|
||||
const changePermissions = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
@@ -730,10 +951,14 @@ export const useSftpPaneActions = ({
|
||||
setFilter,
|
||||
getFilteredFiles,
|
||||
createDirectory,
|
||||
createDirectoryAtPath,
|
||||
createFile,
|
||||
createFileAtPath,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
renameFile,
|
||||
renameFileAtPath,
|
||||
moveEntriesToPath,
|
||||
changePermissions,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ interface SftpTabsState {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
|
||||
updateActiveTab: (side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => void;
|
||||
clearSelectionsExcept: (target: { side: "left" | "right"; tabId: string } | null) => void;
|
||||
setTabShowHiddenFiles: (side: "left" | "right", tabId: string, showHiddenFiles: boolean) => void;
|
||||
addTab: (side: "left" | "right") => string;
|
||||
closeTab: (side: "left" | "right", tabId: string) => void;
|
||||
@@ -34,6 +35,8 @@ interface SftpTabsState {
|
||||
getActiveTabId: (side: "left" | "right") => string | null;
|
||||
}
|
||||
|
||||
const EMPTY_SELECTION = new Set<string>();
|
||||
|
||||
export const useSftpTabsState = ({
|
||||
defaultShowHiddenFiles = false,
|
||||
}: {
|
||||
@@ -95,6 +98,31 @@ export const useSftpTabsState = ({
|
||||
[updateTab],
|
||||
);
|
||||
|
||||
const clearSelectionsExcept = useCallback(
|
||||
(target: { side: "left" | "right"; tabId: string } | null) => {
|
||||
const clearSideSelections = (
|
||||
prev: SftpSideTabs,
|
||||
side: "left" | "right",
|
||||
): SftpSideTabs => {
|
||||
let changed = false;
|
||||
const tabs = prev.tabs.map((tab) => {
|
||||
const shouldKeepSelection =
|
||||
target?.side === side && target.tabId === tab.id;
|
||||
if (shouldKeepSelection || tab.selectedFiles.size === 0) {
|
||||
return tab;
|
||||
}
|
||||
changed = true;
|
||||
return { ...tab, selectedFiles: EMPTY_SELECTION };
|
||||
});
|
||||
return changed ? { ...prev, tabs } : prev;
|
||||
};
|
||||
|
||||
setLeftTabs((prev) => clearSideSelections(prev, "left"));
|
||||
setRightTabs((prev) => clearSideSelections(prev, "right"));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const setTabShowHiddenFiles = useCallback(
|
||||
(side: "left" | "right", tabId: string, showHiddenFiles: boolean) => {
|
||||
updateTab(side, tabId, (prev) => {
|
||||
@@ -258,6 +286,7 @@ export const useSftpTabsState = ({
|
||||
getActivePane,
|
||||
updateTab,
|
||||
updateActiveTab,
|
||||
clearSelectionsExcept,
|
||||
setTabShowHiddenFiles,
|
||||
addTab,
|
||||
closeTab,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -48,7 +48,7 @@ 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 => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - Debounced sync to avoid too frequent API calls
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCloudSync } from './useCloudSync';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import { getCloudSyncManager } from '../../infrastructure/services/CloudSyncManager';
|
||||
@@ -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
|
||||
@@ -60,6 +60,14 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const isSyncRunningRef = useRef(false);
|
||||
const skipNextSyncRef = useRef(false);
|
||||
|
||||
// Listen for SFTP bookmark changes to trigger auto-sync
|
||||
const [bookmarksVersion, setBookmarksVersion] = useState(0);
|
||||
useEffect(() => {
|
||||
const handler = () => setBookmarksVersion((v) => v + 1);
|
||||
window.addEventListener('sftp-bookmarks-changed', handler);
|
||||
return () => window.removeEventListener('sftp-bookmarks-changed', handler);
|
||||
}, []);
|
||||
|
||||
const getSyncSnapshot = useCallback(() => {
|
||||
let effectivePFRules = config.portForwardingRules;
|
||||
if (!effectivePFRules || effectivePFRules.length === 0) {
|
||||
@@ -189,7 +197,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'),
|
||||
);
|
||||
@@ -231,7 +239,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// 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);
|
||||
@@ -288,7 +296,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion]);
|
||||
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion, bookmarksVersion]);
|
||||
|
||||
// Check remote version on startup/unlock
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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';
|
||||
@@ -82,8 +81,10 @@ export interface CloudSyncHook {
|
||||
code: string,
|
||||
redirectUri: string
|
||||
) => Promise<void>;
|
||||
cancelOAuthConnect: () => 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 +104,6 @@ export interface CloudSyncHook {
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export interface GitHubAuthState {
|
||||
isAuthenticating: boolean;
|
||||
deviceFlowState: DeviceFlowState | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook Implementation
|
||||
// ============================================================================
|
||||
@@ -127,17 +122,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 +257,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 +265,44 @@ 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
|
||||
|
||||
// Start callback server and open system browser
|
||||
const callbackPromise = startCallback(expectedState);
|
||||
|
||||
// Open browser after starting server
|
||||
setTimeout(() => {
|
||||
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
|
||||
}, 100);
|
||||
|
||||
// Wait for callback
|
||||
const { code } = await callbackPromise;
|
||||
|
||||
// Complete auth with the received code
|
||||
await manager.completePKCEAuth('google', code, data.redirectUri);
|
||||
|
||||
// Use system browser to avoid white-screen issues in popup windows (#563)
|
||||
// Race: if browser launch fails, surface the error immediately
|
||||
let openTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const browserPromise = new Promise<never>((_resolve, reject) => {
|
||||
openTimer = setTimeout(async () => {
|
||||
try {
|
||||
await bridge?.openExternal(data.url);
|
||||
} catch (err) {
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
try {
|
||||
const { code } = await Promise.race([callbackPromise, browserPromise]);
|
||||
|
||||
// Complete auth with the received code
|
||||
await manager.completePKCEAuth('google', code, data.redirectUri);
|
||||
} finally {
|
||||
if (openTimer) clearTimeout(openTimer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 +310,33 @@ 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
|
||||
|
||||
// Start callback server and open system browser
|
||||
const callbackPromise = startCallback(expectedState);
|
||||
|
||||
// Open browser after starting server
|
||||
setTimeout(() => {
|
||||
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
|
||||
}, 100);
|
||||
|
||||
// Wait for callback
|
||||
const { code } = await callbackPromise;
|
||||
|
||||
// Complete auth with the received code
|
||||
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
|
||||
|
||||
// Use system browser to avoid white-screen issues in popup windows (#563)
|
||||
let openTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const browserPromise = new Promise<never>((_resolve, reject) => {
|
||||
openTimer = setTimeout(async () => {
|
||||
try {
|
||||
await bridge?.openExternal(data.url);
|
||||
} catch (err) {
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
try {
|
||||
const { code } = await Promise.race([callbackPromise, browserPromise]);
|
||||
|
||||
// Complete auth with the received code
|
||||
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
|
||||
} finally {
|
||||
if (openTimer) clearTimeout(openTimer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return data.url;
|
||||
}, []);
|
||||
|
||||
@@ -345,6 +352,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);
|
||||
}, []);
|
||||
@@ -353,6 +364,11 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
await manager.connectConfigProvider('s3', config);
|
||||
}, []);
|
||||
|
||||
const cancelOAuthConnect = useCallback(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
}, []);
|
||||
|
||||
// ========== Settings ==========
|
||||
|
||||
const setAutoSync = useCallback((enabled: boolean, intervalMinutes?: number) => {
|
||||
@@ -450,8 +466,10 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
connectWebDAV,
|
||||
connectS3,
|
||||
completePKCEAuth,
|
||||
cancelOAuthConnect,
|
||||
disconnectProvider,
|
||||
|
||||
resetProviderStatus,
|
||||
|
||||
// Sync Actions
|
||||
syncNow: syncNowWithUnlock,
|
||||
syncToProvider: syncToProviderWithUnlock,
|
||||
@@ -472,60 +490,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -58,7 +58,7 @@ export const useSessionState = () => {
|
||||
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;
|
||||
@@ -71,6 +71,7 @@ export const useSessionState = () => {
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig: config,
|
||||
charset: options?.charset,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
@@ -103,6 +104,7 @@ export const useSessionState = () => {
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig: serialConfig,
|
||||
charset: host.charset,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
@@ -120,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);
|
||||
@@ -321,6 +324,7 @@ export const useSessionState = () => {
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig: serialConfig,
|
||||
charset: host.charset,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -334,6 +338,7 @@ export const useSessionState = () => {
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
charset: host.charset,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -445,8 +450,9 @@ export const useSessionState = () => {
|
||||
port: session.port,
|
||||
moshEnabled: session.moshEnabled,
|
||||
shellType: nextShellType,
|
||||
charset: session.charset,
|
||||
};
|
||||
|
||||
|
||||
// Add pane to existing workspace
|
||||
const hint: SplitHint = {
|
||||
direction,
|
||||
@@ -476,13 +482,14 @@ export const useSessionState = () => {
|
||||
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);
|
||||
@@ -563,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
|
||||
}));
|
||||
|
||||
@@ -649,6 +657,7 @@ export const useSessionState = () => {
|
||||
port: session.port,
|
||||
moshEnabled: session.moshEnabled,
|
||||
shellType: nextShellType,
|
||||
charset: session.charset,
|
||||
serialConfig: session.serialConfig,
|
||||
};
|
||||
|
||||
@@ -682,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]);
|
||||
|
||||
@@ -698,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);
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
STORAGE_KEY_EDITOR_WORD_WRAP,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
@@ -30,6 +32,8 @@ import {
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_IMMERSIVE_MODE,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
@@ -38,7 +42,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';
|
||||
|
||||
@@ -65,6 +68,7 @@ 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;
|
||||
const DEFAULT_SFTP_DEFAULT_VIEW_MODE: 'list' | 'tree' = 'list';
|
||||
|
||||
// Editor defaults
|
||||
const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
@@ -121,8 +125,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);
|
||||
@@ -155,7 +162,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);
|
||||
@@ -237,6 +243,14 @@ export const useSettingsState = () => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_OPEN_SIDEBAR;
|
||||
});
|
||||
const [sftpDefaultViewMode, setSftpDefaultViewMode] = useState<'list' | 'tree'>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
|
||||
return (stored === 'list' || stored === 'tree') ? stored : DEFAULT_SFTP_DEFAULT_VIEW_MODE;
|
||||
});
|
||||
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
|
||||
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
|
||||
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
|
||||
});
|
||||
|
||||
// Editor Settings
|
||||
const [editorWordWrap, setEditorWordWrapState] = useState<boolean>(() => {
|
||||
@@ -287,6 +301,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'
|
||||
@@ -322,6 +340,38 @@ 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 setSftpTransferConcurrency = useCallback((value: number) => {
|
||||
const clamped = Math.max(1, Math.min(16, Math.round(value)));
|
||||
setSftpTransferConcurrencyState(clamped);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY, String(clamped));
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY, clamped);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const [workspaceFocusStyle, setWorkspaceFocusStyleState] = useState<'dim' | 'border'>(() => {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
return stored === 'border' ? 'border' : 'dim';
|
||||
});
|
||||
const setWorkspaceFocusStyle = useCallback((style: 'dim' | 'border') => {
|
||||
setWorkspaceFocusStyleState(style);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, style);
|
||||
notifySettingsChanged(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, style);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const syncAppearanceFromStorage = useCallback(() => {
|
||||
const storedTheme = readStoredString(STORAGE_KEY_THEME);
|
||||
const nextTheme = storedTheme && isValidTheme(storedTheme) ? storedTheme : theme;
|
||||
@@ -334,6 +384,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);
|
||||
@@ -401,10 +462,24 @@ export const useSettingsState = () => {
|
||||
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');
|
||||
const storedDefaultViewMode = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
|
||||
if (storedDefaultViewMode === 'list' || storedDefaultViewMode === 'tree') setSftpDefaultViewMode(storedDefaultViewMode);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Workspace focus style
|
||||
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
if (storedFocusStyle === 'dim' || storedFocusStyle === 'border') setWorkspaceFocusStyleState(storedFocusStyle);
|
||||
|
||||
// Custom terminal themes
|
||||
customThemeStore.loadFromStorage();
|
||||
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
|
||||
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings, notifySettingsChanged]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
@@ -414,12 +489,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
|
||||
@@ -437,7 +511,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
|
||||
@@ -446,7 +523,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
|
||||
@@ -540,6 +620,20 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
|
||||
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && typeof value === 'string') {
|
||||
if (value === 'list' || value === 'tree') {
|
||||
setSftpDefaultViewMode((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
}
|
||||
if (key === STORAGE_KEY_IMMERSIVE_MODE && typeof value === 'boolean') {
|
||||
setImmersiveModeState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
|
||||
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
|
||||
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
try {
|
||||
@@ -567,53 +661,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, sftpDefaultViewMode,
|
||||
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, sftpDefaultViewMode,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -636,64 +753,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);
|
||||
}
|
||||
@@ -701,54 +818,84 @@ 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 !== sftpAutoOpenSidebar) {
|
||||
if (newValue !== s.sftpAutoOpenSidebar) {
|
||||
setSftpAutoOpenSidebar(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP default view mode from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && e.newValue) {
|
||||
if ((e.newValue === 'list' || e.newValue === 'tree') && e.newValue !== s.sftpDefaultViewMode) {
|
||||
setSftpDefaultViewMode(e.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);
|
||||
}
|
||||
}
|
||||
// Sync workspace focus style from other windows
|
||||
if (e.key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && e.newValue !== null) {
|
||||
if (e.newValue === 'dim' || e.newValue === 'border') {
|
||||
setWorkspaceFocusStyleState(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync transfer concurrency from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && e.newValue !== null) {
|
||||
const num = Number(e.newValue);
|
||||
if (num >= 1 && num <= 16) {
|
||||
setSftpTransferConcurrencyState(num);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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, sftpAutoOpenSidebar, 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;
|
||||
@@ -763,11 +910,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]);
|
||||
|
||||
@@ -778,10 +927,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');
|
||||
@@ -789,59 +935,76 @@ 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 SFTP default view mode
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, sftpDefaultViewMode);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, sftpDefaultViewMode);
|
||||
}, [sftpDefaultViewMode, 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) {
|
||||
@@ -865,25 +1028,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.
|
||||
@@ -904,16 +1074,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) => {
|
||||
@@ -921,6 +1086,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 => {
|
||||
@@ -983,11 +1155,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]
|
||||
@@ -995,6 +1162,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,
|
||||
@@ -1018,7 +1191,6 @@ export const useSettingsState = () => {
|
||||
currentTerminalTheme,
|
||||
terminalFontFamilyId,
|
||||
setTerminalFontFamilyId,
|
||||
currentTerminalFont,
|
||||
terminalFontSize,
|
||||
setTerminalFontSize,
|
||||
terminalSettings,
|
||||
@@ -1045,6 +1217,10 @@ export const useSettingsState = () => {
|
||||
setSftpUseCompressedUpload,
|
||||
sftpAutoOpenSidebar,
|
||||
setSftpAutoOpenSidebar,
|
||||
sftpDefaultViewMode,
|
||||
setSftpDefaultViewMode,
|
||||
sftpTransferConcurrency,
|
||||
setSftpTransferConcurrency,
|
||||
// Editor Settings
|
||||
editorWordWrap,
|
||||
setEditorWordWrap: useCallback((enabled: boolean) => {
|
||||
@@ -1052,7 +1228,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,
|
||||
@@ -1071,6 +1246,11 @@ export const useSettingsState = () => {
|
||||
globalHotkeyEnabled,
|
||||
setGlobalHotkeyEnabled,
|
||||
rehydrateAllFromStorage,
|
||||
reapplyCurrentTheme,
|
||||
immersiveMode,
|
||||
setImmersiveMode,
|
||||
workspaceFocusStyle,
|
||||
setWorkspaceFocusStyle,
|
||||
// Opaque version that changes when any synced setting changes, used by useAutoSync.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
settingsVersion: useMemo(() => Math.random(), [
|
||||
@@ -1078,8 +1258,8 @@ export const useSettingsState = () => {
|
||||
uiFontFamilyId, uiLanguage, customCSS,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar,
|
||||
customThemes,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
customThemes, immersiveMode, workspaceFocusStyle,
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
* Uses a shared state pattern to sync across components
|
||||
*/
|
||||
import { useCallback, useEffect, useSyncExternalStore } from 'react';
|
||||
import { STORAGE_KEY_SFTP_FILE_ASSOCIATIONS } from '../../infrastructure/config/storageKeys';
|
||||
import { STORAGE_KEY_SFTP_FILE_ASSOCIATIONS, STORAGE_KEY_SFTP_DEFAULT_OPENER } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import type { FileAssociation, FileOpenerType, SystemAppInfo } from '../../lib/sftpFileUtils';
|
||||
import { getFileExtension } from '../../lib/sftpFileUtils';
|
||||
import { getFileExtension, isKnownBinaryFile } from '../../lib/sftpFileUtils';
|
||||
|
||||
export interface FileAssociationEntry {
|
||||
openerType: FileOpenerType;
|
||||
@@ -17,10 +17,12 @@ export interface FileAssociationsMap {
|
||||
[extension: string]: FileAssociationEntry;
|
||||
}
|
||||
|
||||
// Shared state and subscribers for cross-component synchronization
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-extension associations store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const subscribers = new Set<() => void>();
|
||||
|
||||
// Use a wrapper object so we can update the reference for useSyncExternalStore
|
||||
let snapshotRef: { associations: FileAssociationsMap } = { associations: {} };
|
||||
|
||||
function loadFromStorage(): FileAssociationsMap {
|
||||
@@ -39,7 +41,6 @@ function loadFromStorage(): FileAssociationsMap {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Initialize from storage
|
||||
snapshotRef = { associations: loadFromStorage() };
|
||||
|
||||
function saveToStorage(associations: FileAssociationsMap) {
|
||||
@@ -47,7 +48,6 @@ function saveToStorage(associations: FileAssociationsMap) {
|
||||
}
|
||||
|
||||
function updateAssociations(newAssociations: FileAssociationsMap) {
|
||||
// Create new reference so useSyncExternalStore detects change
|
||||
snapshotRef = { associations: newAssociations };
|
||||
saveToStorage(newAssociations);
|
||||
subscribers.forEach(callback => callback());
|
||||
@@ -62,15 +62,54 @@ function getSnapshot() {
|
||||
return snapshotRef;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default opener store (separate from per-extension associations)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const defaultOpenerSubscribers = new Set<() => void>();
|
||||
|
||||
let defaultOpenerSnapshot: { entry: FileAssociationEntry | null } = {
|
||||
entry: localStorageAdapter.read<FileAssociationEntry>(STORAGE_KEY_SFTP_DEFAULT_OPENER) ?? null,
|
||||
};
|
||||
|
||||
function subscribeDefaultOpener(callback: () => void) {
|
||||
defaultOpenerSubscribers.add(callback);
|
||||
return () => defaultOpenerSubscribers.delete(callback);
|
||||
}
|
||||
|
||||
function getDefaultOpenerSnapshot() {
|
||||
return defaultOpenerSnapshot;
|
||||
}
|
||||
|
||||
function updateDefaultOpener(entry: FileAssociationEntry | null) {
|
||||
defaultOpenerSnapshot = { entry };
|
||||
if (entry) {
|
||||
localStorageAdapter.write(STORAGE_KEY_SFTP_DEFAULT_OPENER, entry);
|
||||
} else {
|
||||
localStorageAdapter.remove(STORAGE_KEY_SFTP_DEFAULT_OPENER);
|
||||
}
|
||||
defaultOpenerSubscribers.forEach(callback => callback());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useSftpFileAssociations() {
|
||||
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
const associations = snapshot.associations;
|
||||
|
||||
const defaultOpenerState = useSyncExternalStore(subscribeDefaultOpener, getDefaultOpenerSnapshot, getDefaultOpenerSnapshot);
|
||||
|
||||
// Listen for storage events from other tabs/windows
|
||||
useEffect(() => {
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (e.key === STORAGE_KEY_SFTP_FILE_ASSOCIATIONS) {
|
||||
updateAssociations(loadFromStorage());
|
||||
} else if (e.key === STORAGE_KEY_SFTP_DEFAULT_OPENER) {
|
||||
updateDefaultOpener(
|
||||
localStorageAdapter.read<FileAssociationEntry>(STORAGE_KEY_SFTP_DEFAULT_OPENER) ?? null,
|
||||
);
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', handleStorage);
|
||||
@@ -78,18 +117,46 @@ export function useSftpFileAssociations() {
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get the opener entry for a file based on its extension
|
||||
* Get the opener entry for a file based on its extension.
|
||||
* Falls back to the default opener when no per-extension association exists.
|
||||
*/
|
||||
const getOpenerForFile = useCallback((fileName: string): FileAssociationEntry | null => {
|
||||
const ext = getFileExtension(fileName);
|
||||
return associations[ext] || null;
|
||||
}, [associations]);
|
||||
if (associations[ext]) return associations[ext];
|
||||
// Fall back to default opener, but skip built-in editor for binary files
|
||||
const fallback = defaultOpenerState.entry;
|
||||
if (fallback && fallback.openerType === 'builtin-editor' && isKnownBinaryFile(fileName)) {
|
||||
return null;
|
||||
}
|
||||
return fallback;
|
||||
}, [associations, defaultOpenerState]);
|
||||
|
||||
/**
|
||||
* Get the default (fallback) opener, if set.
|
||||
*/
|
||||
const getDefaultOpener = useCallback((): FileAssociationEntry | null => {
|
||||
return defaultOpenerState.entry;
|
||||
}, [defaultOpenerState]);
|
||||
|
||||
/**
|
||||
* Set the default opener used when no per-extension association exists.
|
||||
*/
|
||||
const setDefaultOpener = useCallback((openerType: FileOpenerType, systemApp?: SystemAppInfo) => {
|
||||
updateDefaultOpener({ openerType, systemApp });
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Remove the default opener.
|
||||
*/
|
||||
const removeDefaultOpener = useCallback(() => {
|
||||
updateDefaultOpener(null);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Set the opener type for a specific extension
|
||||
*/
|
||||
const setOpenerForExtension = useCallback((
|
||||
extension: string,
|
||||
extension: string,
|
||||
openerType: FileOpenerType,
|
||||
systemApp?: SystemAppInfo
|
||||
) => {
|
||||
@@ -109,7 +176,7 @@ export function useSftpFileAssociations() {
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get all associations as an array
|
||||
* Get all per-extension associations as an array.
|
||||
*/
|
||||
const getAllAssociations = useCallback((): FileAssociation[] => {
|
||||
return Object.entries(associations).map(([extension, entry]: [string, FileAssociationEntry]) => ({
|
||||
@@ -129,6 +196,9 @@ export function useSftpFileAssociations() {
|
||||
return {
|
||||
associations,
|
||||
getOpenerForFile,
|
||||
getDefaultOpener,
|
||||
setDefaultOpener,
|
||||
removeDefaultOpener,
|
||||
setOpenerForExtension,
|
||||
removeAssociation,
|
||||
getAllAssociations,
|
||||
|
||||
@@ -57,6 +57,7 @@ export const useSftpState = (
|
||||
getActivePane,
|
||||
updateTab,
|
||||
updateActiveTab,
|
||||
clearSelectionsExcept,
|
||||
setTabShowHiddenFiles,
|
||||
addTab,
|
||||
closeTab,
|
||||
@@ -110,6 +111,30 @@ export const useSftpState = (
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getPaneByConnectionId = useCallback((connectionId: string) => {
|
||||
for (const tab of leftTabsRef.current.tabs) {
|
||||
if (tab.connection?.id === connectionId) return tab;
|
||||
}
|
||||
for (const tab of rightTabsRef.current.tabs) {
|
||||
if (tab.connection?.id === connectionId) return tab;
|
||||
}
|
||||
return null;
|
||||
}, [leftTabsRef, rightTabsRef]);
|
||||
|
||||
const getTabByConnectionId = useCallback((connectionId: string) => {
|
||||
for (const tab of leftTabsRef.current.tabs) {
|
||||
if (tab.connection?.id === connectionId) {
|
||||
return { side: "left" as const, tabId: tab.id, pane: tab };
|
||||
}
|
||||
}
|
||||
for (const tab of rightTabsRef.current.tabs) {
|
||||
if (tab.connection?.id === connectionId) {
|
||||
return { side: "right" as const, tabId: tab.id, pane: tab };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [leftTabsRef, rightTabsRef]);
|
||||
|
||||
// Ref to track pending reconnections to avoid multiple reconnect attempts
|
||||
const reconnectingRef = useRef<{ left: boolean; right: boolean }>({
|
||||
left: false,
|
||||
@@ -183,10 +208,14 @@ export const useSftpState = (
|
||||
selectAll,
|
||||
getFilteredFiles,
|
||||
createDirectory,
|
||||
createDirectoryAtPath,
|
||||
createFile,
|
||||
createFileAtPath,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
renameFile,
|
||||
renameFileAtPath,
|
||||
moveEntriesToPath,
|
||||
changePermissions,
|
||||
} = useSftpPaneActions({
|
||||
hosts,
|
||||
@@ -207,6 +236,7 @@ export const useSftpState = (
|
||||
listRemoteFiles,
|
||||
handleSessionError,
|
||||
isSessionError,
|
||||
clearSelectionsExcept,
|
||||
dirCacheTtlMs: DIR_CACHE_TTL_MS,
|
||||
});
|
||||
|
||||
@@ -244,6 +274,7 @@ export const useSftpState = (
|
||||
conflicts,
|
||||
activeTransfersCount,
|
||||
startTransfer,
|
||||
downloadToLocal,
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
cancelTransfer,
|
||||
@@ -254,8 +285,13 @@ export const useSftpState = (
|
||||
resolveConflict,
|
||||
} = useSftpTransfers({
|
||||
getActivePane,
|
||||
getPaneByConnectionId,
|
||||
getTabByConnectionId,
|
||||
updateTab,
|
||||
refresh,
|
||||
clearCacheForConnection,
|
||||
sftpSessionsRef,
|
||||
connectionCacheKeyMapRef,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
handleSessionError,
|
||||
@@ -305,15 +341,20 @@ export const useSftpState = (
|
||||
toggleSelection,
|
||||
rangeSelect,
|
||||
clearSelection,
|
||||
clearSelectionsExcept,
|
||||
selectAll,
|
||||
setFilter,
|
||||
setFilenameEncoding,
|
||||
setShowHiddenFiles,
|
||||
createDirectory,
|
||||
createDirectoryAtPath,
|
||||
createFile,
|
||||
createFileAtPath,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
renameFile,
|
||||
renameFileAtPath,
|
||||
moveEntriesToPath,
|
||||
changePermissions,
|
||||
readTextFile,
|
||||
readBinaryFile,
|
||||
@@ -324,6 +365,7 @@ export const useSftpState = (
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
startTransfer,
|
||||
downloadToLocal,
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
cancelTransfer,
|
||||
@@ -332,6 +374,7 @@ export const useSftpState = (
|
||||
dismissTransfer,
|
||||
resolveConflict,
|
||||
getSftpIdForConnection,
|
||||
reportSessionError: handleSessionError,
|
||||
});
|
||||
methodsRef.current = {
|
||||
getFilteredFiles,
|
||||
@@ -352,15 +395,20 @@ export const useSftpState = (
|
||||
toggleSelection,
|
||||
rangeSelect,
|
||||
clearSelection,
|
||||
clearSelectionsExcept,
|
||||
selectAll,
|
||||
setFilter,
|
||||
setFilenameEncoding,
|
||||
setShowHiddenFiles,
|
||||
createDirectory,
|
||||
createDirectoryAtPath,
|
||||
createFile,
|
||||
createFileAtPath,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
renameFile,
|
||||
renameFileAtPath,
|
||||
moveEntriesToPath,
|
||||
changePermissions,
|
||||
readTextFile,
|
||||
readBinaryFile,
|
||||
@@ -371,6 +419,7 @@ export const useSftpState = (
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
startTransfer,
|
||||
downloadToLocal,
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
cancelTransfer,
|
||||
@@ -379,6 +428,7 @@ export const useSftpState = (
|
||||
dismissTransfer,
|
||||
resolveConflict,
|
||||
getSftpIdForConnection,
|
||||
reportSessionError: handleSessionError,
|
||||
};
|
||||
|
||||
// Create stable method wrappers that call through methodsRef
|
||||
@@ -402,6 +452,8 @@ export const useSftpState = (
|
||||
toggleSelection: (...args: Parameters<typeof toggleSelection>) => methodsRef.current.toggleSelection(...args),
|
||||
rangeSelect: (...args: Parameters<typeof rangeSelect>) => methodsRef.current.rangeSelect(...args),
|
||||
clearSelection: (...args: Parameters<typeof clearSelection>) => methodsRef.current.clearSelection(...args),
|
||||
clearSelectionsExcept: (...args: Parameters<typeof clearSelectionsExcept>) =>
|
||||
methodsRef.current.clearSelectionsExcept(...args),
|
||||
selectAll: (...args: Parameters<typeof selectAll>) => methodsRef.current.selectAll(...args),
|
||||
setFilter: (...args: Parameters<typeof setFilter>) => methodsRef.current.setFilter(...args),
|
||||
setFilenameEncoding: (...args: Parameters<typeof setFilenameEncoding>) =>
|
||||
@@ -409,11 +461,17 @@ export const useSftpState = (
|
||||
setShowHiddenFiles: (...args: Parameters<typeof setShowHiddenFiles>) =>
|
||||
methodsRef.current.setShowHiddenFiles(...args),
|
||||
createDirectory: (...args: Parameters<typeof createDirectory>) => methodsRef.current.createDirectory(...args),
|
||||
createDirectoryAtPath: (...args: Parameters<typeof createDirectoryAtPath>) =>
|
||||
methodsRef.current.createDirectoryAtPath(...args),
|
||||
createFile: (...args: Parameters<typeof createFile>) => methodsRef.current.createFile(...args),
|
||||
createFileAtPath: (...args: Parameters<typeof createFileAtPath>) =>
|
||||
methodsRef.current.createFileAtPath(...args),
|
||||
deleteFiles: (...args: Parameters<typeof deleteFiles>) => methodsRef.current.deleteFiles(...args),
|
||||
deleteFilesAtPath: (...args: Parameters<typeof deleteFilesAtPath>) =>
|
||||
methodsRef.current.deleteFilesAtPath(...args),
|
||||
renameFile: (...args: Parameters<typeof renameFile>) => methodsRef.current.renameFile(...args),
|
||||
renameFileAtPath: (...args: Parameters<typeof renameFileAtPath>) => methodsRef.current.renameFileAtPath(...args),
|
||||
moveEntriesToPath: (...args: Parameters<typeof moveEntriesToPath>) => methodsRef.current.moveEntriesToPath(...args),
|
||||
changePermissions: (...args: Parameters<typeof changePermissions>) => methodsRef.current.changePermissions(...args),
|
||||
readTextFile: (...args: Parameters<typeof readTextFile>) => methodsRef.current.readTextFile(...args),
|
||||
readBinaryFile: (...args: Parameters<typeof readBinaryFile>) => methodsRef.current.readBinaryFile(...args),
|
||||
@@ -425,6 +483,7 @@ export const useSftpState = (
|
||||
cancelExternalUpload: () => methodsRef.current.cancelExternalUpload(),
|
||||
selectApplication: () => methodsRef.current.selectApplication(),
|
||||
startTransfer: (...args: Parameters<typeof startTransfer>) => methodsRef.current.startTransfer(...args),
|
||||
downloadToLocal: (...args: Parameters<typeof downloadToLocal>) => methodsRef.current.downloadToLocal(...args),
|
||||
addExternalUpload: (...args: Parameters<typeof addExternalUpload>) => methodsRef.current.addExternalUpload(...args),
|
||||
updateExternalUpload: (...args: Parameters<typeof updateExternalUpload>) => methodsRef.current.updateExternalUpload(...args),
|
||||
cancelTransfer: (...args: Parameters<typeof cancelTransfer>) => methodsRef.current.cancelTransfer(...args),
|
||||
@@ -433,6 +492,7 @@ export const useSftpState = (
|
||||
dismissTransfer: (...args: Parameters<typeof dismissTransfer>) => methodsRef.current.dismissTransfer(...args),
|
||||
resolveConflict: (...args: Parameters<typeof resolveConflict>) => methodsRef.current.resolveConflict(...args),
|
||||
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
|
||||
reportSessionError: (...args: Parameters<typeof handleSessionError>) => methodsRef.current.reportSessionError(...args),
|
||||
activeFileWatchCountRef,
|
||||
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref
|
||||
|
||||
|
||||
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,8 +13,7 @@ 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 (no-op in production)
|
||||
const debugLog = (..._args: unknown[]) => {};
|
||||
@@ -44,6 +43,8 @@ export interface UseUpdateCheckResult {
|
||||
dismissUpdate: () => void;
|
||||
openReleasePage: () => void;
|
||||
installUpdate: () => void;
|
||||
startDownload: () => void;
|
||||
isUpdateDemoMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -514,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);
|
||||
@@ -653,5 +694,7 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
|
||||
dismissUpdate,
|
||||
openReleasePage,
|
||||
installUpdate,
|
||||
startDownload,
|
||||
isUpdateDemoMode: IS_UPDATE_DEMO_MODE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,11 +12,13 @@ import type {
|
||||
Identity,
|
||||
KnownHost,
|
||||
PortForwardingRule,
|
||||
SftpBookmark,
|
||||
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 { rehydrateGlobalBookmarks } from '../components/sftp/hooks/useGlobalSftpBookmarks';
|
||||
import {
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
@@ -37,7 +39,9 @@ import {
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
|
||||
STORAGE_KEY_CUSTOM_THEMES,
|
||||
STORAGE_KEY_IMMERSIVE_MODE,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -74,9 +78,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;
|
||||
|
||||
/**
|
||||
@@ -157,6 +164,14 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
|
||||
|
||||
// SFTP Bookmarks (global only — local bookmarks are device-specific)
|
||||
const globalBookmarks = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS);
|
||||
if (globalBookmarks && Array.isArray(globalBookmarks)) settings.sftpGlobalBookmarks = globalBookmarks;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -215,6 +230,12 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
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));
|
||||
|
||||
// SFTP Bookmarks (global only)
|
||||
if (settings.sftpGlobalBookmarks != null) localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, settings.sftpGlobalBookmarks);
|
||||
|
||||
// Immersive mode
|
||||
if (settings.immersiveMode != null) localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, String(settings.immersiveMode));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -287,6 +308,8 @@ export function applySyncPayload(
|
||||
// Apply synced settings
|
||||
if (payload.settings) {
|
||||
applySyncableSettings(payload.settings);
|
||||
// Rehydrate in-memory bookmark snapshot after localStorage was updated
|
||||
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalBookmarks();
|
||||
importers.onSettingsApplied?.();
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import { useFileUpload } from '../application/state/useFileUpload';
|
||||
import type {
|
||||
AgentModelPreset,
|
||||
AIPermissionMode,
|
||||
AISession,
|
||||
AISessionScope,
|
||||
@@ -43,6 +44,20 @@ import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGa
|
||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
|
||||
function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
|
||||
if (!agent) return false;
|
||||
const tokens = [
|
||||
agent.id,
|
||||
agent.name,
|
||||
agent.icon,
|
||||
agent.command,
|
||||
agent.acpCommand,
|
||||
]
|
||||
.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
||||
.map((value) => value.split('/').pop()?.toLowerCase() ?? value.toLowerCase());
|
||||
return tokens.some((token) => token.includes('copilot'));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Props
|
||||
// -------------------------------------------------------------------
|
||||
@@ -56,6 +71,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,
|
||||
@@ -103,6 +119,7 @@ interface AIChatSidePanelProps {
|
||||
username?: string;
|
||||
protocol?: string;
|
||||
shellType?: string;
|
||||
deviceType?: string;
|
||||
connected: boolean;
|
||||
}>;
|
||||
resolveExecutorContext?: (scope: {
|
||||
@@ -152,6 +169,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
|
||||
// -------------------------------------------------------------------
|
||||
@@ -164,6 +202,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
deleteSession,
|
||||
updateSessionTitle,
|
||||
updateSessionExternalSessionId,
|
||||
retargetSessionScope,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
@@ -227,21 +266,115 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
|
||||
// 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(() => {
|
||||
@@ -294,12 +427,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 ──
|
||||
@@ -313,15 +440,62 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
const providerDisplayName = activeProvider?.name ?? '';
|
||||
const modelDisplayName = activeModelId || activeProvider?.defaultModel || '';
|
||||
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, AgentModelPreset[]>>({});
|
||||
|
||||
// Agent model presets for the current external agent
|
||||
const currentAgentConfig = useMemo(
|
||||
() => currentAgentId !== 'catty' ? externalAgents.find(a => a.id === currentAgentId) : undefined,
|
||||
[currentAgentId, externalAgents],
|
||||
);
|
||||
const isCopilotExternalAgent = useMemo(
|
||||
() => isCopilotAgentConfig(currentAgentConfig),
|
||||
[currentAgentConfig],
|
||||
);
|
||||
|
||||
// Ref to read agentModelMap inside the effect without re-triggering it
|
||||
// when setAgentModel updates the map (avoids double ACP spawn).
|
||||
const agentModelMapRef = useRef(agentModelMap);
|
||||
agentModelMapRef.current = agentModelMap;
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentAgentConfig?.acpCommand) return;
|
||||
if (!isCopilotExternalAgent) return;
|
||||
|
||||
const bridge = getNetcattyBridge();
|
||||
if (!bridge?.aiAcpListModels) return;
|
||||
|
||||
let cancelled = false;
|
||||
void bridge.aiAcpListModels(
|
||||
currentAgentConfig.acpCommand,
|
||||
currentAgentConfig.acpArgs || [],
|
||||
undefined,
|
||||
undefined,
|
||||
`models_${currentAgentId}`,
|
||||
).then((result) => {
|
||||
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
|
||||
const knownModelIds = new Set(result.models.map((model) => model.id));
|
||||
setRuntimeAgentModelPresets((prev) => ({
|
||||
...prev,
|
||||
[currentAgentId]: result.models ?? [],
|
||||
}));
|
||||
const storedModelId = agentModelMapRef.current[currentAgentId];
|
||||
if (result.currentModelId && (!storedModelId || !knownModelIds.has(storedModelId))) {
|
||||
setAgentModel(currentAgentId, result.currentModelId);
|
||||
}
|
||||
}).catch((err) => {
|
||||
if (!cancelled) {
|
||||
console.warn('[AIChatSidePanel] Failed to load ACP agent models:', err);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, setAgentModel]);
|
||||
|
||||
const agentModelPresets = useMemo(
|
||||
() => getAgentModelPresets(currentAgentConfig?.command),
|
||||
[currentAgentConfig?.command],
|
||||
() => runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command),
|
||||
[currentAgentId, currentAgentConfig?.command, runtimeAgentModelPresets],
|
||||
);
|
||||
|
||||
// Per-agent model: recall last selection or use first preset as default
|
||||
@@ -345,15 +519,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
|
||||
// -------------------------------------------------------------------
|
||||
@@ -420,12 +585,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)
|
||||
@@ -468,7 +655,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const assistantMsgId = generateId();
|
||||
addMessageToSession(sessionId, {
|
||||
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
|
||||
model: isExternalAgent ? (agentConfig?.name || 'external') : (activeModelId || activeProvider?.defaultModel || ''),
|
||||
model: isExternalAgent
|
||||
? (selectedAgentModel || agentConfig?.name || 'external')
|
||||
: (activeModelId || activeProvider?.defaultModel || ''),
|
||||
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
|
||||
});
|
||||
|
||||
@@ -543,6 +732,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}));
|
||||
// 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(
|
||||
@@ -741,9 +934,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',
|
||||
@@ -764,7 +960,7 @@ const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -102,11 +102,14 @@ interface StatusDotProps {
|
||||
}
|
||||
|
||||
const StatusDot: React.FC<StatusDotProps> = ({ status, className }) => {
|
||||
if (status === 'connecting') {
|
||||
return <Loader2 className={cn('w-3.5 h-3.5 animate-spin text-muted-foreground', className)} />;
|
||||
}
|
||||
|
||||
const colors = {
|
||||
connected: 'bg-green-500',
|
||||
syncing: 'bg-blue-500 animate-pulse',
|
||||
error: 'bg-red-500',
|
||||
connecting: 'bg-yellow-500 animate-pulse',
|
||||
disconnected: 'bg-muted-foreground/50',
|
||||
};
|
||||
|
||||
@@ -279,6 +282,7 @@ interface ProviderCardProps {
|
||||
disabled?: boolean; // Disable connect button when another provider is connected
|
||||
onEdit?: () => void;
|
||||
onConnect: () => void;
|
||||
onCancelConnect?: () => void;
|
||||
onDisconnect: () => void;
|
||||
onSync: () => void;
|
||||
}
|
||||
@@ -296,6 +300,7 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
disabled,
|
||||
onEdit,
|
||||
onConnect,
|
||||
onCancelConnect,
|
||||
onDisconnect,
|
||||
onSync,
|
||||
}) => {
|
||||
@@ -367,7 +372,9 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
{error}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('cloudSync.provider.notConnected')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{isConnecting ? t('cloudSync.provider.connecting') : t('cloudSync.provider.notConnected')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -408,6 +415,16 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
<CloudOff size={14} />
|
||||
</Button>
|
||||
</>
|
||||
) : isConnecting && onCancelConnect ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onCancelConnect}
|
||||
className="gap-1"
|
||||
>
|
||||
<X size={14} />
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -611,7 +628,7 @@ interface SyncDashboardProps {
|
||||
onClearLocalData?: () => void;
|
||||
}
|
||||
|
||||
export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
onBuildPayload,
|
||||
onApplyPayload,
|
||||
onClearLocalData,
|
||||
@@ -800,6 +817,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'));
|
||||
}
|
||||
@@ -813,10 +833,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'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -828,10 +851,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'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1079,6 +1105,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
error={sync.providers.google.error}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.google)}
|
||||
onConnect={handleConnectGoogle}
|
||||
onCancelConnect={sync.cancelOAuthConnect}
|
||||
onDisconnect={() => sync.disconnectProvider('google')}
|
||||
onSync={() => handleSync('google')}
|
||||
/>
|
||||
@@ -1095,6 +1122,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
error={sync.providers.onedrive.error}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.onedrive)}
|
||||
onConnect={handleConnectOneDrive}
|
||||
onCancelConnect={sync.cancelOAuthConnect}
|
||||
onDisconnect={() => sync.disconnectProvider('onedrive')}
|
||||
onSync={() => handleSync('onedrive')}
|
||||
/>
|
||||
@@ -1250,6 +1278,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');
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
|
||||
// Size variants - all use rounded corners for consistency
|
||||
const sizeClasses = {
|
||||
sm: "h-6 w-6 rounded-md",
|
||||
md: "h-11 w-11 rounded-xl",
|
||||
sm: "h-6 w-6 rounded",
|
||||
md: "h-11 w-11 rounded-lg",
|
||||
lg: "h-14 w-14 rounded-xl",
|
||||
};
|
||||
const iconSizes = {
|
||||
@@ -98,7 +98,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
<div
|
||||
className={cn(
|
||||
containerClass,
|
||||
"flex items-center justify-center border border-border/40 overflow-hidden",
|
||||
"flex items-center justify-center overflow-hidden",
|
||||
bg,
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -20,14 +20,17 @@ 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 {
|
||||
@@ -69,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"
|
||||
@@ -90,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
|
||||
@@ -105,6 +110,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
allTags = [],
|
||||
allHosts = [],
|
||||
defaultGroup,
|
||||
terminalThemeId,
|
||||
terminalFontSize,
|
||||
onSave,
|
||||
onCancel,
|
||||
onCreateGroup,
|
||||
@@ -112,7 +119,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
const { terminalThemeId, terminalFontSize } = useSettingsState();
|
||||
const [form, setForm] = useState<Host>(
|
||||
() =>
|
||||
initialData ||
|
||||
@@ -147,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("");
|
||||
@@ -469,6 +478,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
authMethod: identity.authMethod,
|
||||
password: undefined,
|
||||
identityFileId: undefined,
|
||||
identityFilePaths: undefined,
|
||||
}));
|
||||
setSelectedCredentialType(null);
|
||||
setCredentialPopoverOpen(false);
|
||||
@@ -615,6 +625,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={onCancel}
|
||||
width="w-[420px]"
|
||||
title={
|
||||
initialData ? t("hostDetails.title.details") : t("hostDetails.title.new")
|
||||
}
|
||||
@@ -728,7 +739,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80 overflow-hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
@@ -969,6 +980,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 overflow-hidden">
|
||||
<FileKey size={14} className="text-primary shrink-0" />
|
||||
<span className="text-xs w-0 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">
|
||||
@@ -1046,6 +1082,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>
|
||||
@@ -1067,6 +1117,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onValueChange={(val) => {
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "key");
|
||||
update("identityFilePaths", undefined);
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.keys.search")}
|
||||
@@ -1102,6 +1153,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onValueChange={(val) => {
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "certificate");
|
||||
update("identityFilePaths", undefined);
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.certs.search")}
|
||||
@@ -1121,6 +1173,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 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 min-w-0 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>
|
||||
|
||||
@@ -1404,7 +1517,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>
|
||||
|
||||
@@ -1437,6 +1558,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">
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
|
||||
@@ -19,6 +19,12 @@ import { Input } from "./ui/input";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { SortDropdown, SortMode } from "./ui/sort-dropdown";
|
||||
import { TagFilterDropdown } from "./ui/tag-filter-dropdown";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "./ui/tooltip";
|
||||
|
||||
interface SelectHostPanelProps {
|
||||
hosts: Host[];
|
||||
@@ -198,6 +204,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
}, [currentPath]);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 top-0 bottom-0 w-[380px] border-l border-border/60 bg-background z-40 flex flex-col app-no-drag",
|
||||
@@ -271,7 +278,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Breadcrumbs */}
|
||||
{currentPath && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
@@ -301,20 +308,20 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
)}
|
||||
{groupsWithCounts.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3">{t("vault.groups.title")}</h4>
|
||||
<h4 className="text-xs font-semibold mb-2 text-muted-foreground">{t("vault.groups.title")}</h4>
|
||||
<div className="space-y-1">
|
||||
{groupsWithCounts.map((group) => (
|
||||
<div
|
||||
key={group.path}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-muted/70 cursor-pointer transition-colors"
|
||||
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg hover:bg-muted/70 cursor-pointer transition-colors"
|
||||
onClick={() => setCurrentPath(group.path)}
|
||||
>
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/15 text-primary flex items-center justify-center">
|
||||
<LayoutGrid size={18} />
|
||||
<div className="h-8 w-8 rounded-lg bg-primary/15 text-primary flex items-center justify-center shrink-0">
|
||||
<LayoutGrid size={15} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{group.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-[13px] font-medium truncate">{group.name}</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t("vault.groups.hostsCount", { count: group.count })}
|
||||
</div>
|
||||
</div>
|
||||
@@ -327,18 +334,19 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
{/* Hosts Section */}
|
||||
{filteredHosts.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3">{t("vault.nav.hosts")}</h4>
|
||||
<h4 className="text-xs font-semibold mb-2 text-muted-foreground">{t("vault.nav.hosts")}</h4>
|
||||
<div className="space-y-1">
|
||||
{filteredHosts.map((host) => {
|
||||
const isSelected = selectedHostIds.includes(host.id);
|
||||
const connectionStr = `${host.username}@${host.hostname}:${host.port || 22}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={host.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors",
|
||||
"flex items-center gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer transition-colors",
|
||||
isSelected
|
||||
? "bg-muted border border-border"
|
||||
? "bg-muted"
|
||||
: "hover:bg-muted/70",
|
||||
)}
|
||||
onClick={() => onSelect(host)}
|
||||
@@ -346,16 +354,32 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.os[0].toUpperCase()}
|
||||
className="h-10 w-10"
|
||||
className="h-8 w-8 rounded-md"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{host.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{host.username}@{host.hostname}:{host.port || 22}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-[13px] font-medium truncate">
|
||||
{host.label}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start">
|
||||
<p>{host.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-[11px] text-muted-foreground truncate">
|
||||
{connectionStr}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start">
|
||||
<p>{connectionStr}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check size={16} className="text-primary" />
|
||||
<Check size={14} className="text-primary shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -413,6 +437,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,65 @@ 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}
|
||||
workspaceFocusStyle={settings.workspaceFocusStyle}
|
||||
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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 +151,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 +262,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
checkNow={checkNow}
|
||||
openReleasePage={openReleasePage}
|
||||
installUpdate={installUpdate}
|
||||
startDownload={startDownload}
|
||||
isUpdateDemoMode={isUpdateDemoMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -227,21 +285,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 +311,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 +341,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
checkNow={checkNow}
|
||||
installUpdate={installUpdate}
|
||||
openReleasePage={openReleasePage}
|
||||
startDownload={startDownload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
* Used in TerminalLayer to provide SFTP alongside terminal sessions.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } 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";
|
||||
@@ -30,12 +31,17 @@ import { SftpTransferQueue } from "./sftp/SftpTransferQueue";
|
||||
import { SftpContextProvider } from "./sftp";
|
||||
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
|
||||
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
|
||||
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
|
||||
import { sftpFocusStore } from "./sftp/hooks/useSftpFocusedPane";
|
||||
import { keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
|
||||
import { KeyBinding, HotkeyScheme } from "../domain/models";
|
||||
|
||||
interface SftpSidePanelProps {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
sftpDefaultViewMode: "list" | "tree";
|
||||
/** The host to connect to (follows focused terminal) */
|
||||
activeHost: Host | null;
|
||||
initialLocation?: { hostId: string; path: string } | null;
|
||||
@@ -54,6 +60,8 @@ interface SftpSidePanelProps {
|
||||
sftpAutoSync: boolean;
|
||||
sftpShowHiddenFiles: boolean;
|
||||
sftpUseCompressedUpload: boolean;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
editorWordWrap: boolean;
|
||||
setEditorWordWrap: (value: boolean) => void;
|
||||
onGetTerminalCwd?: () => Promise<string | null>;
|
||||
@@ -64,6 +72,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
keys,
|
||||
identities,
|
||||
updateHosts,
|
||||
sftpDefaultViewMode,
|
||||
activeHost,
|
||||
initialLocation,
|
||||
showWorkspaceHostHeader = false,
|
||||
@@ -75,6 +84,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
onGetTerminalCwd,
|
||||
@@ -108,6 +119,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
listSftp,
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
listLocalDir,
|
||||
} = useSftpBackend();
|
||||
|
||||
const sftpRef = useRef(sftp);
|
||||
@@ -118,6 +130,17 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
const autoSyncRef = useRef(sftpAutoSync);
|
||||
autoSyncRef.current = sftpAutoSync;
|
||||
const panelRootRef = useRef<HTMLDivElement>(null);
|
||||
const dialogActionScopeIdRef = useRef(`sftp-side-panel:${crypto.randomUUID()}`);
|
||||
const [hasPaneFocus, setHasPaneFocus] = useState(false);
|
||||
|
||||
useSftpKeyboardShortcuts({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
sftpRef,
|
||||
dialogActionScopeId: dialogActionScopeIdRef.current,
|
||||
isActive: isVisible && hasPaneFocus,
|
||||
});
|
||||
|
||||
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
|
||||
const getOpenerForFileRef = useRef(getOpenerForFile);
|
||||
@@ -129,10 +152,60 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
sftpRef.current.setShowHiddenFiles("left", paneId, !pane.showHiddenFiles);
|
||||
}, []);
|
||||
|
||||
const syncFocusedSelection = useCallback((tabId: string | null) => {
|
||||
if (tabId) {
|
||||
keepOnlyPaneSelections(sftpRef.current, { side: "left", tabId });
|
||||
return;
|
||||
}
|
||||
keepOnlyPaneSelections(sftpRef.current, null);
|
||||
}, []);
|
||||
|
||||
const handlePaneFocus = useCallback(() => {
|
||||
sftpFocusStore.setFocusedSide("left");
|
||||
setHasPaneFocus(true);
|
||||
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
|
||||
}, [syncFocusedSelection]);
|
||||
|
||||
// NOTE: We intentionally do NOT sync to activeTabStore here.
|
||||
// activeTabStore is a global singleton shared with SftpView.
|
||||
// Writing to it here would corrupt SftpView's left pane visibility.
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
setHasPaneFocus(false);
|
||||
syncFocusedSelection(null);
|
||||
}
|
||||
}, [isVisible, syncFocusedSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
const elementTarget = target instanceof Element ? target : null;
|
||||
const isPortalInteraction = !!elementTarget?.closest(
|
||||
'#netcatty-context-menu-root, [role="dialog"], [data-radix-popper-content-wrapper]',
|
||||
);
|
||||
if (isPortalInteraction) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (panelRootRef.current?.contains(target)) {
|
||||
sftpFocusStore.setFocusedSide("left");
|
||||
setHasPaneFocus(true);
|
||||
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
|
||||
} else {
|
||||
setHasPaneFocus(false);
|
||||
syncFocusedSelection(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
};
|
||||
}, [isVisible, syncFocusedSelection]);
|
||||
|
||||
const {
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
@@ -167,6 +240,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
selectDirectory,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
||||
listLocalFiles: listLocalDir,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -431,6 +505,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
// 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 (t.parentTaskId) return false; // Child tasks rendered by SftpTransferQueue
|
||||
if (connection.isLocal) {
|
||||
return t.sourceConnectionId === connection.id || t.targetConnectionId === connection.id;
|
||||
}
|
||||
@@ -503,9 +578,11 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
rightCallbacks={rightCallbacks}
|
||||
>
|
||||
<div
|
||||
ref={panelRootRef}
|
||||
className="h-full flex flex-col bg-background overflow-hidden"
|
||||
style={isVisible ? undefined : { display: "none" }}
|
||||
aria-hidden={!isVisible}
|
||||
onClick={handlePaneFocus}
|
||||
>
|
||||
{showWorkspaceHostHeader && displayHost && (
|
||||
<div className="shrink-0 border-b border-border/50 bg-muted/20 px-3 py-1.5">
|
||||
@@ -518,7 +595,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}
|
||||
@@ -545,8 +622,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
<SftpPaneView
|
||||
side="left"
|
||||
pane={pane}
|
||||
dialogActionScopeId={dialogActionScopeIdRef.current}
|
||||
isPaneFocused={isVisible && hasPaneFocus}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
showHeader
|
||||
showEmptyHeader
|
||||
forceActive
|
||||
onToggleShowHiddenFiles={() => handleToggleHiddenFiles(pane.id)}
|
||||
onGoToTerminalCwd={onGetTerminalCwd ? handleGoToTerminalCwd : undefined}
|
||||
/>
|
||||
@@ -557,6 +638,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
<SftpTransferQueue
|
||||
sftp={sftp}
|
||||
visibleTransfers={visibleTransfers}
|
||||
allTransfers={sftp.transfers}
|
||||
canRevealTransferTarget={canRevealTransferTarget}
|
||||
onRevealTransferTarget={handleRevealTransferTarget}
|
||||
/>
|
||||
@@ -607,6 +689,7 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.updateHosts === next.updateHosts &&
|
||||
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
|
||||
prev.activeHost === next.activeHost &&
|
||||
prev.showWorkspaceHostHeader === next.showWorkspaceHostHeader &&
|
||||
prev.isVisible === next.isVisible &&
|
||||
|
||||
@@ -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";
|
||||
@@ -39,6 +40,8 @@ import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks"
|
||||
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
|
||||
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
|
||||
import { sftpFocusStore, SftpFocusedSide, useSftpFocusedSide } from "./sftp/hooks/useSftpFocusedPane";
|
||||
import { keepOnlyActivePaneSelections, keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
|
||||
|
||||
|
||||
// Wrapper component that subscribes to activeTabId for CSS visibility
|
||||
// This isolates the activeTabId subscription - only this component re-renders on tab switch
|
||||
@@ -49,21 +52,38 @@ interface SftpViewProps {
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
sftpDefaultViewMode: "list" | "tree";
|
||||
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,
|
||||
sftpDefaultViewMode,
|
||||
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);
|
||||
const dialogActionScopeIdRef = useRef("sftp-main-view");
|
||||
|
||||
useInstantThemeSwitch(rootRef);
|
||||
|
||||
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
|
||||
const fileWatchHandlers = useMemo(() => ({
|
||||
@@ -94,6 +114,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
listSftp,
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
listLocalDir,
|
||||
} = useSftpBackend();
|
||||
|
||||
// Store sftp in a ref so callbacks can access the latest instance
|
||||
@@ -114,6 +135,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
sftpRef,
|
||||
dialogActionScopeId: dialogActionScopeIdRef.current,
|
||||
isActive,
|
||||
});
|
||||
|
||||
@@ -121,8 +143,18 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
const focusedSide = useSftpFocusedSide();
|
||||
|
||||
// Handle pane focus when clicking on a pane container
|
||||
const handlePaneFocus = useCallback((side: SftpFocusedSide) => {
|
||||
// Clear the opposite side's selection so file operations only affect the focused pane
|
||||
const handlePaneFocus = useCallback((side: SftpFocusedSide, targetTabId?: string) => {
|
||||
const prevSide = sftpFocusStore.getFocusedSide();
|
||||
sftpFocusStore.setFocusedSide(side);
|
||||
if (prevSide !== side) {
|
||||
if (targetTabId) {
|
||||
keepOnlyPaneSelections(sftpRef.current, { side, tabId: targetTabId });
|
||||
} else {
|
||||
// Focus side changed — clear other panes but keep the newly focused pane intact.
|
||||
keepOnlyActivePaneSelections(sftpRef.current, side);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleHiddenFiles = useCallback((side: "left" | "right", paneId: string) => {
|
||||
@@ -190,10 +222,11 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
selectDirectory,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
||||
listLocalFiles: listLocalDir,
|
||||
});
|
||||
|
||||
const visibleTransfers = useMemo(
|
||||
() => [...sftp.transfers].reverse().slice(0, 5),
|
||||
() => [...sftp.transfers].filter((t) => !t.parentTaskId).reverse().slice(0, 5),
|
||||
[sftp.transfers],
|
||||
);
|
||||
|
||||
@@ -236,6 +269,26 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
handleHostSelectRight,
|
||||
} = useSftpViewTabs({ sftp, sftpRef });
|
||||
|
||||
const handleAddTabLeftWithFocus = useCallback(() => {
|
||||
const tabId = handleAddTabLeft();
|
||||
handlePaneFocus("left", tabId);
|
||||
}, [handleAddTabLeft, handlePaneFocus]);
|
||||
|
||||
const handleAddTabRightWithFocus = useCallback(() => {
|
||||
const tabId = handleAddTabRight();
|
||||
handlePaneFocus("right", tabId);
|
||||
}, [handleAddTabRight, handlePaneFocus]);
|
||||
|
||||
const handleSelectTabLeftWithFocus = useCallback((tabId: string) => {
|
||||
handleSelectTabLeft(tabId);
|
||||
handlePaneFocus("left", tabId);
|
||||
}, [handlePaneFocus, handleSelectTabLeft]);
|
||||
|
||||
const handleSelectTabRightWithFocus = useCallback((tabId: string) => {
|
||||
handleSelectTabRight(tabId);
|
||||
handlePaneFocus("right", tabId);
|
||||
}, [handlePaneFocus, handleSelectTabRight]);
|
||||
|
||||
return (
|
||||
<SftpContextProvider
|
||||
hosts={hosts}
|
||||
@@ -246,6 +299,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" : "",
|
||||
@@ -275,9 +329,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
<SftpTabBar
|
||||
tabs={leftTabsInfo}
|
||||
side="left"
|
||||
onSelectTab={handleSelectTabLeft}
|
||||
onSelectTab={handleSelectTabLeftWithFocus}
|
||||
onCloseTab={handleCloseTabLeft}
|
||||
onAddTab={handleAddTabLeft}
|
||||
onAddTab={handleAddTabLeftWithFocus}
|
||||
onReorderTabs={handleReorderTabsLeft}
|
||||
onMoveTabToOtherSide={handleMoveTabFromRightToLeft}
|
||||
/>
|
||||
@@ -293,6 +347,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
<SftpPaneView
|
||||
side="left"
|
||||
pane={pane}
|
||||
dialogActionScopeId={dialogActionScopeIdRef.current}
|
||||
isPaneFocused={focusedSide === "left"}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
showHeader
|
||||
showEmptyHeader={false}
|
||||
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("left", pane.id)}
|
||||
@@ -332,9 +389,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
<SftpTabBar
|
||||
tabs={rightTabsInfo}
|
||||
side="right"
|
||||
onSelectTab={handleSelectTabRight}
|
||||
onSelectTab={handleSelectTabRightWithFocus}
|
||||
onCloseTab={handleCloseTabRight}
|
||||
onAddTab={handleAddTabRight}
|
||||
onAddTab={handleAddTabRightWithFocus}
|
||||
onReorderTabs={handleReorderTabsRight}
|
||||
onMoveTabToOtherSide={handleMoveTabFromLeftToRight}
|
||||
/>
|
||||
@@ -350,6 +407,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
<SftpPaneView
|
||||
side="right"
|
||||
pane={pane}
|
||||
dialogActionScopeId={dialogActionScopeIdRef.current}
|
||||
isPaneFocused={focusedSide === "right"}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
showHeader
|
||||
showEmptyHeader={false}
|
||||
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("right", pane.id)}
|
||||
@@ -408,7 +468,18 @@ 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.sftpDefaultViewMode === next.sftpDefaultViewMode &&
|
||||
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";
|
||||
|
||||
@@ -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,
|
||||
@@ -26,8 +26,6 @@ import {
|
||||
shouldScrollOnTerminalInput,
|
||||
} from "../domain/terminalScroll";
|
||||
import {
|
||||
resolveHostTerminalFontFamilyId,
|
||||
resolveHostTerminalFontSize,
|
||||
resolveHostTerminalThemeId,
|
||||
} from "../domain/terminalAppearance";
|
||||
import { resolveHostAuth } from "../domain/sshAuth";
|
||||
@@ -54,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.
|
||||
@@ -110,7 +109,8 @@ interface TerminalProps {
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
snippets: Snippet[];
|
||||
allHosts?: Host[];
|
||||
chainHosts?: Host[];
|
||||
themePreviewId?: string;
|
||||
knownHosts?: KnownHost[];
|
||||
isVisible: boolean;
|
||||
inWorkspace?: boolean;
|
||||
@@ -157,6 +157,10 @@ 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 };
|
||||
}
|
||||
@@ -179,7 +183,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
keys,
|
||||
identities,
|
||||
snippets,
|
||||
allHosts = [],
|
||||
chainHosts = [],
|
||||
themePreviewId,
|
||||
knownHosts: _knownHosts = [],
|
||||
isVisible,
|
||||
inWorkspace,
|
||||
@@ -216,6 +221,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onToggleComposeBar,
|
||||
isWorkspaceComposeBarOpen,
|
||||
onBroadcastInput,
|
||||
onSnippetExecutorChange,
|
||||
sessionLog,
|
||||
}) => {
|
||||
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
|
||||
@@ -228,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);
|
||||
@@ -240,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);
|
||||
@@ -291,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;
|
||||
|
||||
@@ -342,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(() => {
|
||||
@@ -406,27 +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(() => {
|
||||
const themeId = resolveHostTerminalThemeId(host, terminalTheme.id);
|
||||
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, 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?.();
|
||||
@@ -494,7 +659,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
},
|
||||
onSessionExit,
|
||||
onTerminalDataCapture,
|
||||
onTerminalDataCapture: handleTerminalDataCaptureOnce,
|
||||
onOsDetected,
|
||||
onCommandExecuted,
|
||||
sessionLog,
|
||||
@@ -503,6 +668,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
terminalDataCapturedRef.current = false;
|
||||
setError(null);
|
||||
hasConnectedRef.current = false;
|
||||
pendingOutputScrollRef.current = false;
|
||||
@@ -539,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;
|
||||
@@ -614,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);
|
||||
}
|
||||
@@ -626,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(() => {
|
||||
@@ -635,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));
|
||||
@@ -679,7 +829,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, 200);
|
||||
|
||||
return () => {
|
||||
if (stepTimer) clearInterval(stepTimer);
|
||||
clearInterval(countdown);
|
||||
clearTimeout(timeout);
|
||||
clearInterval(prog);
|
||||
@@ -714,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;
|
||||
}
|
||||
}
|
||||
@@ -722,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);
|
||||
}
|
||||
@@ -737,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 = resolveHostTerminalFontSize(host, 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;
|
||||
@@ -787,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;
|
||||
@@ -794,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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (termRef.current) {
|
||||
const effectiveFontSize = resolveHostTerminalFontSize(host, fontSize);
|
||||
termRef.current.options.fontSize = effectiveFontSize;
|
||||
|
||||
const hostFontId = resolveHostTerminalFontFamilyId(host, 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, fontFamilyId, fontSize, effectiveTheme, availableFonts]);
|
||||
}, [effectiveFontSize, resolvedFontFamily, terminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
@@ -862,7 +1014,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
if (terminalSettings && termRef.current) {
|
||||
const fontFamily = termRef.current.options?.fontFamily || "";
|
||||
const effectiveFontSize = resolveHostTerminalFontSize(host, fontSize);
|
||||
if (typeof document !== "undefined" && document.fonts?.check) {
|
||||
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
|
||||
const resolvedBold = document.fonts.check(weightSpec)
|
||||
@@ -898,7 +1049,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [host, fontFamilyId, fontSize, resizeSession, sessionId, terminalSettings]);
|
||||
}, [effectiveFontSize, resizeSession, terminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !containerRef.current || !fitAddonRef.current) return;
|
||||
@@ -1036,6 +1187,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const handler = () => {
|
||||
@@ -1053,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;
|
||||
@@ -1061,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,
|
||||
@@ -1171,6 +1356,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (!termRef.current) return;
|
||||
cleanupSession();
|
||||
auth.resetForRetry();
|
||||
terminalDataCapturedRef.current = false;
|
||||
hasRunStartupCommandRef.current = false;
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
setStatus("connecting");
|
||||
@@ -1304,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
|
||||
@@ -1326,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}
|
||||
@@ -1356,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">
|
||||
@@ -1375,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}>
|
||||
@@ -1423,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>
|
||||
)}
|
||||
@@ -1720,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}
|
||||
@@ -1728,10 +1941,34 @@ 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}
|
||||
onDismiss={autocompleteClosePopup}
|
||||
/>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
{needsHostKeyVerification && pendingHostKeyInfo && (
|
||||
<div className="absolute inset-0 z-30 bg-background">
|
||||
<KnownHostConfirmDialog
|
||||
|
||||
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,6 +1,8 @@
|
||||
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';
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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-accent, hsl(var(--accent)))' }}
|
||||
/>
|
||||
)}
|
||||
<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';
|
||||
@@ -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-2.5 group-[.is-user]:py-2',
|
||||
'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;
|
||||
|
||||
@@ -5,6 +5,39 @@ 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;
|
||||
args?: Record<string, unknown>;
|
||||
@@ -75,17 +108,18 @@ export const ToolCall = ({
|
||||
: 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="text-yellow-500/70 shrink-0" />
|
||||
<ShieldAlert size={12} className={cn('text-yellow-500/70', statusIconClass)} />
|
||||
) : isLoading ? (
|
||||
<Loader2 size={12} className="animate-spin text-blue-400/70" />
|
||||
<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 (
|
||||
@@ -105,7 +139,13 @@ export const ToolCall = ({
|
||||
? <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' && (
|
||||
@@ -126,7 +166,7 @@ export const ToolCall = ({
|
||||
{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>
|
||||
@@ -167,10 +207,10 @@ export const ToolCall = ({
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ type AgentLike = {
|
||||
|
||||
type AgentIconKey =
|
||||
| 'catty'
|
||||
| 'copilot'
|
||||
| 'openai'
|
||||
| 'claude'
|
||||
| 'anthropic'
|
||||
@@ -20,7 +21,7 @@ type AgentIconKey =
|
||||
| 'openrouter'
|
||||
| 'zed'
|
||||
| 'atom'
|
||||
| 'terminal'
|
||||
| 'terminal'
|
||||
| 'plus';
|
||||
|
||||
type AgentIconVisual = {
|
||||
@@ -35,6 +36,11 @@ const AGENT_ICON_VISUALS: Record<AgentIconKey, AgentIconVisual> = {
|
||||
badgeClassName: 'border-violet-500/20 bg-violet-500/10',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
|
||||
},
|
||||
copilot: {
|
||||
src: '/ai/agents/copilot.svg',
|
||||
badgeClassName: 'border-zinc-300 bg-white',
|
||||
imageClassName: 'object-contain brightness-0',
|
||||
},
|
||||
openai: {
|
||||
src: '/ai/providers/openai.svg',
|
||||
badgeClassName: 'border-emerald-500/22 bg-emerald-500/12',
|
||||
@@ -115,6 +121,9 @@ function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
|
||||
if (tokens.some((token) => token.includes('claude'))) {
|
||||
return 'claude';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('copilot'))) {
|
||||
return 'copilot';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('anthropic'))) {
|
||||
return 'anthropic';
|
||||
}
|
||||
@@ -154,20 +163,14 @@ 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';
|
||||
variant?: 'plain' | 'badge';
|
||||
className?: string;
|
||||
}> = ({ agent, size = 'md', variant = 'badge', className }) => {
|
||||
const visual = AGENT_ICON_VISUALS[getAgentIconKey(agent)];
|
||||
const iconKey = getAgentIconKey(agent);
|
||||
const visual = AGENT_ICON_VISUALS[iconKey];
|
||||
const badgeSize =
|
||||
size === 'xs'
|
||||
? 'h-4 w-4 rounded-sm'
|
||||
@@ -187,18 +190,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,
|
||||
|
||||
@@ -9,6 +9,10 @@ import { ChevronDown, RefreshCw, Plus, Settings } from 'lucide-react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import {
|
||||
isSettingsManagedDiscoveredAgent,
|
||||
matchesManagedAgentConfig,
|
||||
} from '../../infrastructure/ai/managedAgents';
|
||||
import type { AgentInfo, ExternalAgentConfig, DiscoveredAgent } from '../../infrastructure/ai/types';
|
||||
import AgentIconBadge from './AgentIconBadge';
|
||||
import {
|
||||
@@ -140,7 +144,12 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
const unconfiguredDiscovered = useMemo(
|
||||
() =>
|
||||
discoveredAgents.filter(
|
||||
(da) => !externalAgents.some((ea) => ea.command === da.command || ea.command === da.path),
|
||||
(da) => {
|
||||
if (isSettingsManagedDiscoveredAgent(da)) {
|
||||
return !externalAgents.some((ea) => matchesManagedAgentConfig(ea, da.command));
|
||||
}
|
||||
return !externalAgents.some((ea) => ea.command === da.command || ea.command === da.path);
|
||||
},
|
||||
),
|
||||
[discoveredAgents, externalAgents],
|
||||
);
|
||||
@@ -208,7 +217,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
|
||||
|
||||
@@ -229,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
|
||||
|
||||
@@ -144,12 +144,14 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
.flatMap((m) => m.toolResults?.map((tr) => tr.toolCallId) ?? []),
|
||||
);
|
||||
|
||||
// Build a map from toolCallId → toolName for display
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,6 +180,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
<ToolCall
|
||||
key={tr.toolCallId}
|
||||
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
|
||||
args={toolCallArgs.get(tr.toolCallId)}
|
||||
result={tr.content}
|
||||
isError={tr.isError}
|
||||
/>
|
||||
@@ -235,8 +238,13 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
</MessageResponse>
|
||||
)}
|
||||
|
||||
{/* Tool calls */}
|
||||
{message.toolCalls?.map((tc) => {
|
||||
{/* Pending tool calls from the *last* assistant message are rendered
|
||||
after all tool-result messages (see below) for chronological order.
|
||||
Unresolved tool calls from earlier or cancelled messages are shown
|
||||
inline — as interrupted, or with approval controls if still pending. */}
|
||||
{(message !== lastAssistantMessage || message.executionStatus === 'cancelled') && message.toolCalls?.filter((tc) =>
|
||||
!resolvedToolCallIds.has(tc.id),
|
||||
).map((tc) => {
|
||||
const isPending = pendingApprovals.has(tc.id);
|
||||
const resolved = resolvedApprovals.get(tc.id);
|
||||
const approvalStatus = isPending
|
||||
@@ -246,14 +254,12 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
: resolved === false
|
||||
? 'denied' as const
|
||||
: undefined;
|
||||
|
||||
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)}
|
||||
isInterrupted={!isPending}
|
||||
approvalStatus={approvalStatus}
|
||||
onApprove={() => handleApprove(tc.id)}
|
||||
onReject={() => handleReject(tc.id)}
|
||||
@@ -287,6 +293,33 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Pending tool calls from the last assistant message — rendered here
|
||||
(after all tool-result messages) so they appear at the bottom. */}
|
||||
{lastAssistantMessage?.toolCalls?.filter((tc) =>
|
||||
!resolvedToolCallIds.has(tc.id) && lastAssistantMessage.executionStatus !== 'cancelled',
|
||||
).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;
|
||||
return (
|
||||
<ToolCall
|
||||
key={tc.id}
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isLoading={isStreaming && lastAssistantMessage.executionStatus === 'running' && !isPending}
|
||||
approvalStatus={approvalStatus}
|
||||
onApprove={() => handleApprove(tc.id)}
|
||||
onReject={() => handleReject(tc.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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))
|
||||
|
||||
@@ -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,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 };
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
ChatMessage,
|
||||
ChatMessageAttachment,
|
||||
ExternalAgentConfig,
|
||||
ProviderAdvancedParams,
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from '../../../infrastructure/ai/types';
|
||||
@@ -111,6 +112,13 @@ export interface PanelBridge extends NetcattyBridge {
|
||||
aiSyncProviders?: (providers: Array<{ id: string; providerId: string; apiKey?: string; baseURL?: string; enabled: boolean }>) => Promise<{ ok: boolean }>;
|
||||
aiSyncWebSearch?: (apiHost: string | null, apiKey: string | null) => Promise<{ ok: boolean }>;
|
||||
aiMcpUpdateSessions?: (sessions: TerminalSessionInfo[], chatSessionId?: string) => Promise<unknown>;
|
||||
aiAcpListModels?: (
|
||||
acpCommand: string,
|
||||
acpArgs?: string[],
|
||||
cwd?: string,
|
||||
providerId?: string,
|
||||
chatSessionId?: string,
|
||||
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string }>; currentModelId?: string | null; error?: string }>;
|
||||
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
[key: string]: ((...args: unknown[]) => unknown) | undefined;
|
||||
}
|
||||
@@ -125,6 +133,7 @@ export interface TerminalSessionInfo {
|
||||
username?: string;
|
||||
protocol?: string;
|
||||
shellType?: string;
|
||||
deviceType?: string;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
@@ -186,6 +195,7 @@ export interface UseAIChatStreamingReturn {
|
||||
sdkMessages: Array<ModelMessage>,
|
||||
signal: AbortSignal,
|
||||
currentAssistantMsgId: string,
|
||||
advancedParams?: ProviderAdvancedParams,
|
||||
) => Promise<void>;
|
||||
/** Send a message to the Catty agent (built-in). */
|
||||
sendToCattyAgent: (
|
||||
@@ -320,6 +330,7 @@ export function useAIChatStreaming({
|
||||
sdkMessages: Array<ModelMessage>,
|
||||
signal: AbortSignal,
|
||||
currentAssistantMsgId: string,
|
||||
advancedParams?: ProviderAdvancedParams,
|
||||
): Promise<void> => {
|
||||
const result = streamText({
|
||||
model,
|
||||
@@ -328,6 +339,11 @@ 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
|
||||
@@ -680,6 +696,7 @@ export function useAIChatStreaming({
|
||||
username: s.username,
|
||||
protocol: s.protocol,
|
||||
shellType: s.shellType,
|
||||
deviceType: s.deviceType,
|
||||
connected: s.connected,
|
||||
})),
|
||||
permissionMode: context.globalPermissionMode,
|
||||
@@ -804,7 +821,7 @@ export function useAIChatStreaming({
|
||||
sdkMessages.push({ role: 'user', content: trimmed });
|
||||
}
|
||||
|
||||
await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId);
|
||||
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);
|
||||
|
||||
@@ -123,7 +123,7 @@ export const WizardContent: React.FC<WizardContentProps> = ({
|
||||
>
|
||||
{selectedHost ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} className="h-6 w-6" />
|
||||
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
|
||||
<span>{selectedHost.label}</span>
|
||||
<Check size={14} className="ml-auto" />
|
||||
</div>
|
||||
@@ -228,7 +228,7 @@ export const WizardContent: React.FC<WizardContentProps> = ({
|
||||
>
|
||||
{selectedHost ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} className="h-6 w-6" />
|
||||
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
|
||||
<span>{selectedHost.label}</span>
|
||||
<Check size={14} className="ml-auto" />
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* - SafetySettings
|
||||
*/
|
||||
import { Bot, Globe } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
AIProviderId,
|
||||
@@ -16,8 +16,12 @@ import type {
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from "../../../infrastructure/ai/types";
|
||||
import {
|
||||
getManagedAgentStoredPath,
|
||||
matchesManagedAgentConfig,
|
||||
type ManagedAgentKey,
|
||||
} from "../../../infrastructure/ai/managedAgents";
|
||||
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
|
||||
import { useAgentDiscovery } from "../../../application/state/useAgentDiscovery";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { TabsContent } from "../../ui/tabs";
|
||||
import { Select, SettingRow } from "../settings-ui";
|
||||
@@ -38,6 +42,7 @@ import { ProviderCard } from "./ai/ProviderCard";
|
||||
import { AddProviderDropdown } from "./ai/AddProviderDropdown";
|
||||
import { CodexConnectionCard } from "./ai/CodexConnectionCard";
|
||||
import { ClaudeCodeCard } from "./ai/ClaudeCodeCard";
|
||||
import { CopilotCliCard } from "./ai/CopilotCliCard";
|
||||
import { SafetySettings } from "./ai/SafetySettings";
|
||||
import { WebSearchSettings } from "./ai/WebSearchSettings";
|
||||
|
||||
@@ -70,6 +75,54 @@ interface SettingsAITabProps {
|
||||
setWebSearchConfig: (config: WebSearchConfig | null) => void;
|
||||
}
|
||||
|
||||
function areExternalAgentListsEqual(
|
||||
left: ExternalAgentConfig[],
|
||||
right: ExternalAgentConfig[],
|
||||
): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
return left.every((agent, index) => JSON.stringify(agent) === JSON.stringify(right[index]));
|
||||
}
|
||||
|
||||
function buildManagedAgentState(
|
||||
prevAgents: ExternalAgentConfig[],
|
||||
defaultAgentId: string,
|
||||
agentKey: ManagedAgentKey,
|
||||
pathInfo: AgentPathInfo | null,
|
||||
): { agents: ExternalAgentConfig[]; defaultAgentId: string } {
|
||||
const managedId = `discovered_${agentKey}`;
|
||||
const managedAgents = prevAgents.filter((agent) => matchesManagedAgentConfig(agent, agentKey));
|
||||
const otherAgents = prevAgents.filter((agent) => !matchesManagedAgentConfig(agent, agentKey));
|
||||
const storedPath = getManagedAgentStoredPath(prevAgents, agentKey);
|
||||
|
||||
if (!pathInfo?.available || !pathInfo.path) {
|
||||
return {
|
||||
agents: storedPath ? prevAgents : otherAgents,
|
||||
defaultAgentId: storedPath
|
||||
? defaultAgentId
|
||||
: managedAgents.some((agent) => agent.id === defaultAgentId)
|
||||
? "catty"
|
||||
: defaultAgentId,
|
||||
};
|
||||
}
|
||||
|
||||
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
|
||||
const defaults = AGENT_DEFAULTS[agentKey];
|
||||
const nextManagedAgent: ExternalAgentConfig = {
|
||||
...existingManaged,
|
||||
...defaults,
|
||||
id: managedId,
|
||||
command: pathInfo.path,
|
||||
enabled: managedAgents.length === 0 ? true : managedAgents.some((agent) => agent.enabled),
|
||||
};
|
||||
|
||||
return {
|
||||
agents: [...otherAgents, nextManagedAgent],
|
||||
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
|
||||
? managedId
|
||||
: defaultAgentId,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Tab Component
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -113,58 +166,44 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
const [claudePathInfo, setClaudePathInfo] = useState<AgentPathInfo | null>(null);
|
||||
const [claudeCustomPath, setClaudeCustomPath] = useState("");
|
||||
const [isResolvingClaude, setIsResolvingClaude] = useState(false);
|
||||
const initialManagedPathsRef = useRef<{
|
||||
codex: string;
|
||||
claude: string;
|
||||
copilot: string;
|
||||
} | null>(null);
|
||||
if (!initialManagedPathsRef.current) {
|
||||
initialManagedPathsRef.current = {
|
||||
codex: getManagedAgentStoredPath(externalAgents, "codex") ?? "",
|
||||
claude: getManagedAgentStoredPath(externalAgents, "claude") ?? "",
|
||||
copilot: getManagedAgentStoredPath(externalAgents, "copilot") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
discoveredAgents,
|
||||
isDiscovering,
|
||||
enableAgent,
|
||||
} = useAgentDiscovery(externalAgents, setExternalAgents);
|
||||
const [copilotPathInfo, setCopilotPathInfo] = useState<AgentPathInfo | null>(null);
|
||||
const [copilotCustomPath, setCopilotCustomPath] = useState("");
|
||||
const [isResolvingCopilot, setIsResolvingCopilot] = useState(false);
|
||||
|
||||
// Derive path info from discovery results
|
||||
useEffect(() => {
|
||||
if (isDiscovering) return;
|
||||
// Ref to read current defaultAgentId without adding it as a dependency.
|
||||
const defaultAgentIdRef = useRef(defaultAgentId);
|
||||
defaultAgentIdRef.current = defaultAgentId;
|
||||
|
||||
const codex = discoveredAgents.find((a) => a.command === "codex");
|
||||
setCodexPathInfo(
|
||||
codex
|
||||
? { path: codex.path, version: codex.version, available: true }
|
||||
: { path: null, version: null, available: false },
|
||||
);
|
||||
|
||||
const claude = discoveredAgents.find((a) => a.command === "claude");
|
||||
setClaudePathInfo(
|
||||
claude
|
||||
? { path: claude.path, version: claude.version, available: true }
|
||||
: { path: null, version: null, available: false },
|
||||
);
|
||||
}, [isDiscovering, discoveredAgents]);
|
||||
|
||||
// Auto-register discovered agents in externalAgents
|
||||
useEffect(() => {
|
||||
if (isDiscovering || discoveredAgents.length === 0) return;
|
||||
|
||||
setExternalAgents((prev) => {
|
||||
const agentsToRegister: ExternalAgentConfig[] = [];
|
||||
|
||||
for (const da of discoveredAgents) {
|
||||
if (da.command !== "codex" && da.command !== "claude") continue;
|
||||
const agentId = `discovered_${da.command}`;
|
||||
if (prev.some((ea) => ea.id === agentId)) continue;
|
||||
agentsToRegister.push(enableAgent(da));
|
||||
}
|
||||
|
||||
return agentsToRegister.length > 0 ? [...prev, ...agentsToRegister] : prev;
|
||||
});
|
||||
}, [isDiscovering, discoveredAgents, enableAgent, setExternalAgents]);
|
||||
|
||||
// Validate a custom path for an agent
|
||||
const handleCheckCustomPath = useCallback(async (agentKey: "codex" | "claude") => {
|
||||
const resolveAgentPath = useCallback(async (
|
||||
agentKey: ManagedAgentKey,
|
||||
customPath = "",
|
||||
) => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiResolveCli) return;
|
||||
if (!bridge?.aiResolveCli) return null;
|
||||
|
||||
const customPath = agentKey === "codex" ? codexCustomPath : claudeCustomPath;
|
||||
const setInfo = agentKey === "codex" ? setCodexPathInfo : setClaudePathInfo;
|
||||
const setResolving = agentKey === "codex" ? setIsResolvingCodex : setIsResolvingClaude;
|
||||
const setInfo = agentKey === "codex"
|
||||
? setCodexPathInfo
|
||||
: agentKey === "claude"
|
||||
? setClaudePathInfo
|
||||
: setCopilotPathInfo;
|
||||
const setResolving = agentKey === "codex"
|
||||
? setIsResolvingCodex
|
||||
: agentKey === "claude"
|
||||
? setIsResolvingClaude
|
||||
: setIsResolvingCopilot;
|
||||
|
||||
setResolving(true);
|
||||
try {
|
||||
@@ -174,32 +213,48 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
});
|
||||
setInfo(result);
|
||||
|
||||
// Register/update in externalAgents if valid
|
||||
if (result.available && result.path) {
|
||||
const agentId = `discovered_${agentKey}`;
|
||||
const defaults = AGENT_DEFAULTS[agentKey];
|
||||
setExternalAgents((prev) => {
|
||||
const idx = prev.findIndex((a) => a.id === agentId);
|
||||
const config: ExternalAgentConfig = {
|
||||
id: agentId,
|
||||
command: result.path!,
|
||||
enabled: true,
|
||||
...defaults,
|
||||
};
|
||||
if (idx >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[idx] = { ...updated[idx], command: result.path! };
|
||||
return updated;
|
||||
}
|
||||
return [...prev, config];
|
||||
});
|
||||
// Consolidate managed agent entries using the callback form of
|
||||
// setExternalAgents so we never depend on externalAgents directly.
|
||||
// All three agents resolve concurrently on mount — React runs
|
||||
// state updater callbacks sequentially, so updating the ref inside
|
||||
// ensures later calls see earlier defaultAgentId changes.
|
||||
let nextDefaultId: string | null = null;
|
||||
setExternalAgents((prev) => {
|
||||
const state = buildManagedAgentState(prev, defaultAgentIdRef.current, agentKey, result);
|
||||
if (state.defaultAgentId !== defaultAgentIdRef.current) {
|
||||
nextDefaultId = state.defaultAgentId;
|
||||
defaultAgentIdRef.current = state.defaultAgentId;
|
||||
}
|
||||
return areExternalAgentListsEqual(prev, state.agents) ? prev : state.agents;
|
||||
});
|
||||
if (nextDefaultId !== null) {
|
||||
setDefaultAgentId(nextDefaultId);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error("Path resolution failed:", err);
|
||||
return null;
|
||||
} finally {
|
||||
setResolving(false);
|
||||
}
|
||||
}, [codexCustomPath, claudeCustomPath, setExternalAgents]);
|
||||
}, [setExternalAgents, setDefaultAgentId]);
|
||||
|
||||
useEffect(() => {
|
||||
void resolveAgentPath("codex", initialManagedPathsRef.current?.codex ?? "");
|
||||
void resolveAgentPath("claude", initialManagedPathsRef.current?.claude ?? "");
|
||||
void resolveAgentPath("copilot", initialManagedPathsRef.current?.copilot ?? "");
|
||||
}, [resolveAgentPath]);
|
||||
|
||||
// Validate a custom path for an agent
|
||||
const handleCheckCustomPath = useCallback(async (agentKey: ManagedAgentKey) => {
|
||||
const customPath = agentKey === "codex"
|
||||
? codexCustomPath
|
||||
: agentKey === "claude"
|
||||
? claudeCustomPath
|
||||
: copilotCustomPath;
|
||||
await resolveAgentPath(agentKey, customPath);
|
||||
}, [claudeCustomPath, codexCustomPath, copilotCustomPath, resolveAgentPath]);
|
||||
|
||||
// Add a new provider from preset
|
||||
const handleAddProvider = useCallback(
|
||||
@@ -457,7 +512,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
|
||||
<CodexConnectionCard
|
||||
pathInfo={codexPathInfo}
|
||||
isResolvingPath={isDiscovering || isResolvingCodex}
|
||||
isResolvingPath={isResolvingCodex}
|
||||
customPath={codexCustomPath}
|
||||
onCustomPathChange={setCodexCustomPath}
|
||||
onRecheckPath={() => void handleCheckCustomPath("codex")}
|
||||
@@ -483,13 +538,29 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
|
||||
<ClaudeCodeCard
|
||||
pathInfo={claudePathInfo}
|
||||
isResolvingPath={isDiscovering || isResolvingClaude}
|
||||
isResolvingPath={isResolvingClaude}
|
||||
customPath={claudeCustomPath}
|
||||
onCustomPathChange={setClaudeCustomPath}
|
||||
onRecheckPath={() => void handleCheckCustomPath("claude")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* -- GitHub Copilot CLI Section -- */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIconBadge providerId="copilot" size="sm" />
|
||||
<h3 className="text-base font-medium">{t('ai.copilot.title')}</h3>
|
||||
</div>
|
||||
|
||||
<CopilotCliCard
|
||||
pathInfo={copilotPathInfo}
|
||||
isResolvingPath={isResolvingCopilot}
|
||||
customPath={copilotCustomPath}
|
||||
onCustomPathChange={setCopilotCustomPath}
|
||||
onRecheckPath={() => void handleCheckCustomPath("copilot")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* -- Default Agent Section -- */}
|
||||
{agentOptions.length > 1 && (
|
||||
<div className="space-y-4">
|
||||
@@ -507,7 +578,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
value={defaultAgentId}
|
||||
options={agentOptions}
|
||||
onChange={setDefaultAgentId}
|
||||
className="w-48"
|
||||
className="w-64"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -28,10 +28,12 @@ const getOpenerLabel = (
|
||||
|
||||
export default function SettingsFileAssociationsTab() {
|
||||
const { t } = useI18n();
|
||||
const { getAllAssociations, removeAssociation, setOpenerForExtension } = useSftpFileAssociations();
|
||||
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload, sftpAutoOpenSidebar, setSftpAutoOpenSidebar } = useSettingsState();
|
||||
const { getAllAssociations, removeAssociation, setOpenerForExtension, getDefaultOpener, setDefaultOpener, removeDefaultOpener } = useSftpFileAssociations();
|
||||
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload, sftpAutoOpenSidebar, setSftpAutoOpenSidebar, sftpDefaultViewMode, setSftpDefaultViewMode, sftpTransferConcurrency, setSftpTransferConcurrency } = useSettingsState();
|
||||
const associations = getAllAssociations();
|
||||
const defaultOpener = getDefaultOpener();
|
||||
const [editingExtension, setEditingExtension] = useState<string | null>(null);
|
||||
const [isSelectingDefaultApp, setIsSelectingDefaultApp] = useState(false);
|
||||
|
||||
const handleRemove = useCallback((extension: string) => {
|
||||
if (confirm(t('settings.sftpFileAssociations.removeConfirm', { ext: extension === 'file' ? t('sftp.opener.noExtension') : extension }))) {
|
||||
@@ -39,6 +41,22 @@ export default function SettingsFileAssociationsTab() {
|
||||
}
|
||||
}, [removeAssociation, t]);
|
||||
|
||||
const handleSelectDefaultSystemApp = useCallback(async () => {
|
||||
setIsSelectingDefaultApp(true);
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.selectApplication) return;
|
||||
const result = await bridge.selectApplication();
|
||||
if (result) {
|
||||
setDefaultOpener('system-app', { path: result.path, name: result.name });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to select application:', e);
|
||||
} finally {
|
||||
setIsSelectingDefaultApp(false);
|
||||
}
|
||||
}, [setDefaultOpener]);
|
||||
|
||||
const handleEdit = useCallback(async (extension: string) => {
|
||||
setEditingExtension(extension);
|
||||
try {
|
||||
@@ -130,6 +148,76 @@ export default function SettingsFileAssociationsTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default view mode section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftp.defaultViewMode')} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.defaultViewMode.desc')}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => setSftpDefaultViewMode('list')}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
sftpDefaultViewMode === 'list'
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
sftpDefaultViewMode === 'list'
|
||||
? "border-primary"
|
||||
: "border-muted-foreground/30"
|
||||
)}>
|
||||
{sftpDefaultViewMode === 'list' && (
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{t('settings.sftp.defaultViewMode.list')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.defaultViewMode.listDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSftpDefaultViewMode('tree')}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
sftpDefaultViewMode === 'tree'
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
sftpDefaultViewMode === 'tree'
|
||||
? "border-primary"
|
||||
: "border-muted-foreground/30"
|
||||
)}>
|
||||
{sftpDefaultViewMode === 'tree' && (
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{t('settings.sftp.defaultViewMode.tree')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.defaultViewMode.treeDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-sync section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftp.autoSync')} />
|
||||
@@ -290,6 +378,117 @@ export default function SettingsFileAssociationsTab() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Transfer concurrency section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftp.transferConcurrency')} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.transferConcurrency.desc')}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={16}
|
||||
step={1}
|
||||
value={sftpTransferConcurrency}
|
||||
onChange={(e) => setSftpTransferConcurrency(Number(e.target.value))}
|
||||
className="flex-1 accent-primary"
|
||||
/>
|
||||
<span className="text-sm font-mono w-6 text-center">{sftpTransferConcurrency}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default opener section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftp.defaultOpener')} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.defaultOpener.desc')}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => removeDefaultOpener()}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
!defaultOpener
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
!defaultOpener ? "border-primary" : "border-muted-foreground/30"
|
||||
)}>
|
||||
{!defaultOpener && <div className="h-2.5 w-2.5 rounded-full bg-primary" />}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{t('settings.sftp.defaultOpener.ask')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.defaultOpener.askDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDefaultOpener('builtin-editor')}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
defaultOpener?.openerType === 'builtin-editor'
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
defaultOpener?.openerType === 'builtin-editor' ? "border-primary" : "border-muted-foreground/30"
|
||||
)}>
|
||||
{defaultOpener?.openerType === 'builtin-editor' && <div className="h-2.5 w-2.5 rounded-full bg-primary" />}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{t('sftp.opener.builtInEditor')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.defaultOpener.builtInDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSelectDefaultSystemApp}
|
||||
disabled={isSelectingDefaultApp}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
defaultOpener?.openerType === 'system-app'
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
defaultOpener?.openerType === 'system-app' ? "border-primary" : "border-muted-foreground/30"
|
||||
)}>
|
||||
{defaultOpener?.openerType === 'system-app' && <div className="h-2.5 w-2.5 rounded-full bg-primary" />}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{defaultOpener?.openerType === 'system-app' && defaultOpener.systemApp
|
||||
? defaultOpener.systemApp.name
|
||||
: t('settings.sftp.defaultOpener.systemApp')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.defaultOpener.systemAppDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</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">
|
||||
|
||||
@@ -84,6 +84,8 @@ export default function SettingsTerminalTab(props: {
|
||||
value: TerminalSettings[K],
|
||||
) => void;
|
||||
availableFonts: TerminalFont[];
|
||||
workspaceFocusStyle: 'dim' | 'border';
|
||||
setWorkspaceFocusStyle: (style: 'dim' | 'border') => void;
|
||||
}) {
|
||||
const {
|
||||
terminalThemeId,
|
||||
@@ -95,6 +97,8 @@ export default function SettingsTerminalTab(props: {
|
||||
terminalSettings,
|
||||
updateTerminalSetting,
|
||||
availableFonts,
|
||||
workspaceFocusStyle,
|
||||
setWorkspaceFocusStyle,
|
||||
} = props;
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -114,6 +118,20 @@ 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>) => {
|
||||
@@ -616,6 +634,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")}
|
||||
@@ -844,6 +869,56 @@ export default function SettingsTerminalTab(props: {
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
{/* Autocomplete */}
|
||||
<SectionHeader title={t("settings.terminal.section.workspaceFocus")} />
|
||||
<div className="space-y-1">
|
||||
<SettingRow
|
||||
label={t("settings.terminal.workspaceFocus.style")}
|
||||
description={t("settings.terminal.workspaceFocus.style.desc")}
|
||||
>
|
||||
<Select
|
||||
value={workspaceFocusStyle}
|
||||
onChange={(v) => setWorkspaceFocusStyle(v as 'dim' | 'border')}
|
||||
options={[
|
||||
{ value: 'dim', label: t("settings.terminal.workspaceFocus.dim") },
|
||||
{ value: 'border', label: t("settings.terminal.workspaceFocus.border") },
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
87
components/settings/tabs/ai/CopilotCliCard.tsx
Normal file
87
components/settings/tabs/ai/CopilotCliCard.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { AgentPathInfo } from "./types";
|
||||
import { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
|
||||
export const CopilotCliCard: React.FC<{
|
||||
pathInfo: AgentPathInfo | null;
|
||||
isResolvingPath: boolean;
|
||||
customPath: string;
|
||||
onCustomPathChange: (path: string) => void;
|
||||
onRecheckPath: () => void;
|
||||
}> = ({
|
||||
pathInfo,
|
||||
isResolvingPath,
|
||||
customPath,
|
||||
onCustomPathChange,
|
||||
onRecheckPath,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const found = pathInfo?.available;
|
||||
|
||||
const statusText = isResolvingPath
|
||||
? t('ai.copilot.detecting')
|
||||
: found
|
||||
? t('ai.copilot.detected')
|
||||
: t('ai.copilot.notFound');
|
||||
|
||||
const statusClassName = isResolvingPath
|
||||
? "text-muted-foreground"
|
||||
: found
|
||||
? "text-emerald-500"
|
||||
: "text-amber-500";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIconBadge providerId="copilot" size="sm" />
|
||||
<span className="text-sm font-medium">{t('ai.copilot.title')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2 leading-5">
|
||||
{t('ai.copilot.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
|
||||
{statusText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{found ? (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t('ai.copilot.path')}</span>
|
||||
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{pathInfo.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : !isResolvingPath ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-amber-500">
|
||||
{t('ai.copilot.notFoundHint')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t('ai.copilot.customPathPlaceholder')}
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
{t('ai.copilot.check')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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()}>
|
||||
|
||||
@@ -20,7 +20,8 @@ export const ProviderIconBadge: React.FC<{
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn(
|
||||
"object-contain brightness-0 invert",
|
||||
"object-contain",
|
||||
providerId === "copilot" ? "brightness-0" : "brightness-0 invert",
|
||||
size === "sm" ? "w-3 h-3" : "w-4 h-4",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -5,4 +5,5 @@ export { ProviderCard } from "./ProviderCard";
|
||||
export { AddProviderDropdown } from "./AddProviderDropdown";
|
||||
export { CodexConnectionCard } from "./CodexConnectionCard";
|
||||
export { ClaudeCodeCard } from "./ClaudeCodeCard";
|
||||
export { CopilotCliCard } from "./CopilotCliCard";
|
||||
export { SafetySettings } from "./SafetySettings";
|
||||
|
||||
@@ -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 {
|
||||
@@ -80,6 +82,13 @@ export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "co
|
||||
acpCommand: "claude-agent-acp",
|
||||
acpArgs: [],
|
||||
},
|
||||
copilot: {
|
||||
name: "GitHub Copilot CLI",
|
||||
args: ["-p", "{prompt}"],
|
||||
icon: "copilot",
|
||||
acpCommand: "copilot",
|
||||
acpArgs: ["--acp", "--stdio"],
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -106,12 +115,13 @@ export function normalizeCodexBridgeError(error: unknown): string {
|
||||
// Provider icon helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SettingsIconId = AIProviderId | "claude";
|
||||
export type SettingsIconId = AIProviderId | "claude" | "copilot";
|
||||
|
||||
export const SETTINGS_ICON_PATHS: Record<SettingsIconId, string> = {
|
||||
openai: "/ai/providers/openai.svg",
|
||||
anthropic: "/ai/providers/anthropic.svg",
|
||||
claude: "/ai/agents/claude.svg",
|
||||
copilot: "/ai/agents/copilot.svg",
|
||||
google: "/ai/providers/google.svg",
|
||||
ollama: "/ai/providers/ollama.svg",
|
||||
openrouter: "/ai/providers/openrouter.svg",
|
||||
@@ -122,6 +132,7 @@ export const SETTINGS_ICON_COLORS: Record<SettingsIconId, string> = {
|
||||
openai: "bg-emerald-600",
|
||||
anthropic: "bg-orange-600",
|
||||
claude: "bg-orange-600",
|
||||
copilot: "border border-zinc-300 bg-white",
|
||||
google: "bg-blue-600",
|
||||
ollama: "bg-purple-600",
|
||||
openrouter: "bg-pink-600",
|
||||
|
||||
@@ -9,37 +9,53 @@
|
||||
import React, { createContext, useContext, useMemo, useSyncExternalStore } from "react";
|
||||
import { Host, SftpFileEntry, SftpFilenameEncoding } from "../../types";
|
||||
|
||||
export interface SftpTransferSource {
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
sourcePath?: string;
|
||||
sourceConnectionId?: string;
|
||||
targetPath?: string;
|
||||
}
|
||||
|
||||
// Types for the context
|
||||
export interface SftpPaneCallbacks {
|
||||
onConnect: (host: Host | "local") => void;
|
||||
onDisconnect: () => void;
|
||||
onPrepareSelection: () => void;
|
||||
onNavigateTo: (path: string) => void;
|
||||
onNavigateUp: () => void;
|
||||
onRefresh: () => void;
|
||||
onRefreshTab: (tabId: string) => void;
|
||||
onSetFilenameEncoding: (encoding: SftpFilenameEncoding) => void;
|
||||
onOpenEntry: (entry: SftpFileEntry) => void;
|
||||
onOpenEntry: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
onToggleSelection: (fileName: string, multiSelect: boolean) => void;
|
||||
onRangeSelect: (fileNames: string[]) => void;
|
||||
onClearSelection: () => void;
|
||||
onSetFilter: (filter: string) => void;
|
||||
onCreateDirectory: (name: string) => Promise<void>;
|
||||
onCreateDirectoryAtPath: (path: string, name: string) => Promise<void>;
|
||||
onCreateFile: (name: string) => Promise<void>;
|
||||
onCreateFileAtPath: (path: string, name: string) => Promise<void>;
|
||||
onDeleteFiles: (fileNames: string[]) => Promise<void>;
|
||||
onDeleteFilesAtPath: (connectionId: string, path: string, fileNames: string[]) => Promise<void>;
|
||||
onRenameFile: (oldName: string, newName: string) => Promise<void>;
|
||||
onCopyToOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
|
||||
onReceiveFromOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
|
||||
onEditPermissions?: (file: SftpFileEntry) => void;
|
||||
onRenameFileAtPath: (oldPath: string, newName: string) => Promise<void>;
|
||||
onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise<void>;
|
||||
onCopyToOtherPane: (files: SftpTransferSource[]) => void;
|
||||
onReceiveFromOtherPane: (files: SftpTransferSource[]) => void;
|
||||
onEditPermissions?: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
// File operations
|
||||
onEditFile?: (entry: SftpFileEntry) => void;
|
||||
onOpenFile?: (entry: SftpFileEntry) => void;
|
||||
onOpenFileWith?: (entry: SftpFileEntry) => void; // Always show opener dialog
|
||||
onDownloadFile?: (entry: SftpFileEntry) => void; // Download to local filesystem
|
||||
onEditFile?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
onOpenFile?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
onOpenFileWith?: (entry: SftpFileEntry, fullPath?: string) => void; // Always show opener dialog
|
||||
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void; // Download to local filesystem
|
||||
// External file upload (supports folders via DataTransfer)
|
||||
onUploadExternalFiles?: (dataTransfer: DataTransfer) => Promise<void>;
|
||||
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
|
||||
onListDirectory: (path: string) => Promise<SftpFileEntry[]>;
|
||||
}
|
||||
|
||||
export interface SftpDragCallbacks {
|
||||
onDragStart: (files: { name: string; isDirectory: boolean }[], side: "left" | "right") => void;
|
||||
onDragStart: (files: SftpTransferSource[], side: "left" | "right") => void;
|
||||
onDragEnd: () => void;
|
||||
}
|
||||
|
||||
@@ -91,16 +107,18 @@ export interface SftpContextValue {
|
||||
// Host updater for bookmark persistence
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
|
||||
// Drag state (shared between panes)
|
||||
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
|
||||
dragCallbacks: SftpDragCallbacks;
|
||||
|
||||
// Callbacks for each side
|
||||
leftCallbacks: SftpPaneCallbacks;
|
||||
rightCallbacks: SftpPaneCallbacks;
|
||||
}
|
||||
|
||||
export interface SftpDragContextValue {
|
||||
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
|
||||
dragCallbacks: SftpDragCallbacks;
|
||||
}
|
||||
|
||||
const SftpContext = createContext<SftpContextValue | null>(null);
|
||||
const SftpDragContext = createContext<SftpDragContextValue | null>(null);
|
||||
|
||||
export const useSftpContext = () => {
|
||||
const context = useContext(SftpContext);
|
||||
@@ -116,13 +134,19 @@ export const useSftpPaneCallbacks = (side: "left" | "right"): SftpPaneCallbacks
|
||||
return side === "left" ? context.leftCallbacks : context.rightCallbacks;
|
||||
};
|
||||
|
||||
// Hook to get drag-related values
|
||||
// Hook to get drag-related values (reads from separate SftpDragContext)
|
||||
export const useSftpDrag = () => {
|
||||
const context = useSftpContext();
|
||||
return {
|
||||
draggedFiles: context.draggedFiles,
|
||||
...context.dragCallbacks,
|
||||
};
|
||||
const context = useContext(SftpDragContext);
|
||||
if (!context) {
|
||||
throw new Error("useSftpDrag must be used within SftpContextProvider");
|
||||
}
|
||||
return useMemo(
|
||||
() => ({
|
||||
draggedFiles: context.draggedFiles,
|
||||
...context.dragCallbacks,
|
||||
}),
|
||||
[context.draggedFiles, context.dragCallbacks],
|
||||
);
|
||||
};
|
||||
|
||||
// Hook to get hosts
|
||||
@@ -140,7 +164,7 @@ export const useSftpUpdateHosts = () => {
|
||||
interface SftpContextProviderProps {
|
||||
hosts: Host[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
|
||||
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
|
||||
dragCallbacks: SftpDragCallbacks;
|
||||
leftCallbacks: SftpPaneCallbacks;
|
||||
rightCallbacks: SftpPaneCallbacks;
|
||||
@@ -156,19 +180,29 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
|
||||
rightCallbacks,
|
||||
children,
|
||||
}) => {
|
||||
// Memoize the context value to prevent unnecessary re-renders
|
||||
// Note: The callbacks objects should be stable (created with useMemo in parent)
|
||||
// Memoize the main context value (no drag state, so drag changes won't cause re-renders here)
|
||||
const value = useMemo<SftpContextValue>(
|
||||
() => ({
|
||||
hosts,
|
||||
updateHosts,
|
||||
draggedFiles,
|
||||
dragCallbacks,
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
}),
|
||||
[hosts, updateHosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks],
|
||||
[hosts, updateHosts, leftCallbacks, rightCallbacks],
|
||||
);
|
||||
|
||||
return <SftpContext.Provider value={value}>{children}</SftpContext.Provider>;
|
||||
// Memoize drag context separately so only drag consumers re-render on drag state changes
|
||||
const dragValue = useMemo<SftpDragContextValue>(
|
||||
() => ({
|
||||
draggedFiles,
|
||||
dragCallbacks,
|
||||
}),
|
||||
[draggedFiles, dragCallbacks],
|
||||
);
|
||||
|
||||
return (
|
||||
<SftpContext.Provider value={value}>
|
||||
<SftpDragContext.Provider value={dragValue}>{children}</SftpDragContext.Provider>
|
||||
</SftpContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,12 +6,13 @@ import { Folder, Link } from 'lucide-react';
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { SftpFileEntry } from '../../types';
|
||||
import { ColumnWidths, formatBytes, formatDate, getFileIcon, isNavigableDirectory } from './utils';
|
||||
import { buildSftpColumnTemplate, ColumnWidths, formatBytes, formatDate, getFileIcon, isNavigableDirectory } from './utils';
|
||||
|
||||
interface SftpFileRowProps {
|
||||
entry: SftpFileEntry;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
showSelectionHighlight: boolean;
|
||||
isDragOver: boolean;
|
||||
columnWidths: ColumnWidths;
|
||||
onSelect: (entry: SftpFileEntry, index: number, e: React.MouseEvent) => void;
|
||||
@@ -27,6 +28,7 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
entry,
|
||||
index,
|
||||
isSelected,
|
||||
showSelectionHighlight,
|
||||
isDragOver,
|
||||
columnWidths,
|
||||
onSelect,
|
||||
@@ -58,10 +60,13 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
onDrop(entry, e);
|
||||
}, [entry, onDrop]);
|
||||
const isSelectionVisible = isSelected && showSelectionHighlight;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-sftp-row="true"
|
||||
data-entry-name={entry.name}
|
||||
data-selected={isSelected ? "true" : "false"}
|
||||
draggable={!isParentDir}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
@@ -71,33 +76,53 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
onClick={handleSelect}
|
||||
onDoubleClick={handleOpen}
|
||||
className={cn(
|
||||
"px-4 py-2 items-center cursor-pointer text-sm transition-colors",
|
||||
isSelected ? "bg-primary/15 text-foreground" : "hover:bg-secondary/40",
|
||||
"px-4 py-2 items-center cursor-pointer text-sm",
|
||||
isSelectionVisible
|
||||
? "bg-accent text-accent-foreground hover:bg-accent"
|
||||
: "hover:bg-accent/50",
|
||||
isDragOver && isNavDir && "bg-primary/25 ring-1 ring-primary/50"
|
||||
)}
|
||||
style={{ display: 'grid', gridTemplateColumns: `${columnWidths.name}% ${columnWidths.modified}% ${columnWidths.size}% ${columnWidths.type}%` }}
|
||||
style={{ display: 'grid', gridTemplateColumns: buildSftpColumnTemplate(columnWidths) }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={cn(
|
||||
"h-7 w-7 rounded flex items-center justify-center shrink-0 relative",
|
||||
isNavDir ? "bg-primary/10 text-primary" : "bg-secondary/60 text-muted-foreground"
|
||||
isSelectionVisible
|
||||
? "bg-accent-foreground/10 text-accent-foreground"
|
||||
: isNavDir
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-secondary/60 text-muted-foreground"
|
||||
)}>
|
||||
{isNavDir ? <Folder size={14} /> : getFileIcon(entry)}
|
||||
{/* Show link indicator for symlinks */}
|
||||
{entry.type === 'symlink' && (
|
||||
<Link size={8} className="absolute -bottom-0.5 -right-0.5 text-muted-foreground" aria-hidden="true" />
|
||||
<Link
|
||||
size={8}
|
||||
className={cn(
|
||||
"absolute -bottom-0.5 -right-0.5",
|
||||
isSelectionVisible ? "text-accent-foreground/80" : "text-muted-foreground",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className={cn("truncate", entry.type === 'symlink' && "italic pr-1")}>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate",
|
||||
entry.type === 'symlink' && "italic pr-1",
|
||||
isSelectionVisible && "font-medium",
|
||||
)}
|
||||
title={entry.name}
|
||||
>
|
||||
{entry.name}
|
||||
{entry.type === 'symlink' && <span className="sr-only"> (symbolic link)</span>}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate">{modifiedLabel}</span>
|
||||
<span className="text-xs text-muted-foreground truncate text-right">
|
||||
<span className={cn("text-xs truncate", isSelectionVisible ? "text-accent-foreground/85" : "text-muted-foreground")}>{modifiedLabel}</span>
|
||||
<span className={cn("text-xs truncate text-right", isSelectionVisible ? "text-accent-foreground/85" : "text-muted-foreground")}>
|
||||
{isNavDir ? '--' : sizeLabel}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate capitalize text-right">
|
||||
<span className={cn("text-xs truncate capitalize text-right", isSelectionVisible ? "text-accent-foreground/85" : "text-muted-foreground")}>
|
||||
{isSymlinkToDirectory ? 'link → folder' : entry.type === 'directory' ? 'folder' : entry.type === 'symlink' ? 'link' : entry.name.split('.').pop()?.toLowerCase() || 'file'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -107,6 +132,8 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
const areEqual = (prev: SftpFileRowProps, next: SftpFileRowProps): boolean => {
|
||||
if (prev.index !== next.index) return false;
|
||||
if (prev.isSelected !== next.isSelected) return false;
|
||||
// Only re-render for showSelectionHighlight changes when the row is actually selected
|
||||
if (prev.isSelected && prev.showSelectionHighlight !== next.showSelectionHighlight) return false;
|
||||
if (prev.isDragOver !== next.isDragOver) return false;
|
||||
if (prev.columnWidths.name !== next.columnWidths.name) return false;
|
||||
if (prev.columnWidths.modified !== next.columnWidths.modified) return false;
|
||||
|
||||
@@ -24,8 +24,8 @@ interface SftpOverlaysProps {
|
||||
setHostSearchRight: (value: string) => void;
|
||||
handleHostSelectLeft: (host: Host | "local") => void;
|
||||
handleHostSelectRight: (host: Host | "local") => void;
|
||||
permissionsState: { file: SftpFileEntry; side: "left" | "right" } | null;
|
||||
setPermissionsState: (state: { file: SftpFileEntry; side: "left" | "right" } | null) => void;
|
||||
permissionsState: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
|
||||
setPermissionsState: (state: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null) => void;
|
||||
showTextEditor: boolean;
|
||||
setShowTextEditor: (open: boolean) => void;
|
||||
textEditorTarget: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
|
||||
@@ -43,7 +43,7 @@ interface SftpOverlaysProps {
|
||||
handleSelectSystemApp: (systemApp: { path: string; name: string }) => void;
|
||||
}
|
||||
|
||||
export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
|
||||
export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
hosts,
|
||||
sftp,
|
||||
visibleTransfers,
|
||||
@@ -101,7 +101,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
|
||||
/>
|
||||
|
||||
{showTransferQueue && (
|
||||
<SftpTransferQueue sftp={sftp} visibleTransfers={visibleTransfers} />
|
||||
<SftpTransferQueue sftp={sftp} visibleTransfers={visibleTransfers} allTransfers={sftp.transfers} />
|
||||
)}
|
||||
|
||||
<SftpConflictDialog
|
||||
@@ -114,17 +114,11 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
|
||||
open={!!permissionsState}
|
||||
onOpenChange={(open) => !open && setPermissionsState(null)}
|
||||
file={permissionsState?.file ?? null}
|
||||
onSave={(file, permissions) => {
|
||||
onSave={(_file, permissions) => {
|
||||
if (permissionsState) {
|
||||
const fullPath = sftp.joinPath(
|
||||
permissionsState.side === "left"
|
||||
? sftp.leftPane.connection?.currentPath || ""
|
||||
: sftp.rightPane.connection?.currentPath || "",
|
||||
file.name,
|
||||
);
|
||||
sftp.changePermissions(
|
||||
permissionsState.side,
|
||||
fullPath,
|
||||
permissionsState.fullPath,
|
||||
permissions,
|
||||
);
|
||||
}
|
||||
@@ -160,4 +154,4 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -11,11 +11,14 @@ import {
|
||||
} from "../ui/dialog";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import { getFileName, getParentPath } from "../../application/state/sftp/utils";
|
||||
import { SftpHostPicker } from "./index";
|
||||
import type { Host } from "../../types";
|
||||
|
||||
interface SftpPaneDialogsProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
hostLabel?: string;
|
||||
currentPath?: string;
|
||||
// New folder
|
||||
showNewFolderDialog: boolean;
|
||||
setShowNewFolderDialog: (open: boolean) => void;
|
||||
@@ -61,8 +64,15 @@ interface SftpPaneDialogsProps {
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
const HostHint: React.FC<{ label?: string }> = ({ label }) =>
|
||||
label ? (
|
||||
<div className="text-xs text-muted-foreground truncate mb-1">{label}</div>
|
||||
) : null;
|
||||
|
||||
export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
t,
|
||||
hostLabel,
|
||||
currentPath,
|
||||
showNewFolderDialog,
|
||||
setShowNewFolderDialog,
|
||||
newFolderName,
|
||||
@@ -100,12 +110,36 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
setHostSearch,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
}) => (
|
||||
}) => {
|
||||
const isSingleDeleteTarget = deleteTargets.length === 1;
|
||||
const deletePath = (() => {
|
||||
if (isSingleDeleteTarget) {
|
||||
return deleteTargets[0];
|
||||
}
|
||||
|
||||
const uniquePaths = Array.from(new Set(deleteTargets.map((target) => getParentPath(target)).filter(Boolean)));
|
||||
if (uniquePaths.length === 1) return uniquePaths[0];
|
||||
if (uniquePaths.length > 1) return "Multiple locations";
|
||||
return currentPath;
|
||||
})();
|
||||
const showDeleteList = deleteTargets.length > 1;
|
||||
const deleteListItems = (() => {
|
||||
if (!showDeleteList) return [];
|
||||
|
||||
const uniquePaths = Array.from(new Set(deleteTargets.map((target) => getParentPath(target)).filter(Boolean)));
|
||||
if (uniquePaths.length === 1) {
|
||||
return deleteTargets.map((target) => getFileName(target) || target);
|
||||
}
|
||||
return deleteTargets;
|
||||
})();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Dialogs */}
|
||||
<Dialog open={showNewFolderDialog} onOpenChange={setShowNewFolderDialog}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<HostHint label={hostLabel} />
|
||||
<DialogTitle>{t("sftp.newFolder")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
@@ -148,6 +182,7 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
}}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<HostHint label={hostLabel} />
|
||||
<DialogTitle>{t("sftp.newFile")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
@@ -192,6 +227,7 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
<Dialog open={showOverwriteConfirm} onOpenChange={setShowOverwriteConfirm}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<HostHint label={hostLabel} />
|
||||
<DialogTitle>{t("sftp.overwrite.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("sftp.overwrite.desc", { name: overwriteTarget || "" })}
|
||||
@@ -217,6 +253,7 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<HostHint label={hostLabel} />
|
||||
<DialogTitle>{t("sftp.rename.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
@@ -258,19 +295,39 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
{t("sftp.deleteConfirm.title", { count: deleteTargets.length })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("sftp.deleteConfirm.desc")}
|
||||
{t(showDeleteList ? "sftp.deleteConfirm.desc" : "sftp.deleteConfirm.descSingle")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-32 overflow-auto text-sm space-y-1">
|
||||
{deleteTargets.map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
<span className="truncate">{name}</span>
|
||||
<div className="space-y-3">
|
||||
{hostLabel || deletePath ? (
|
||||
<div className="text-xs text-muted-foreground space-y-1.5">
|
||||
{hostLabel ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-medium text-foreground/80 shrink-0">{t("sftp.deleteConfirm.host")}:</span>
|
||||
<span className="break-all">{hostLabel}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{deletePath ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-medium text-foreground/80 shrink-0">{t("sftp.deleteConfirm.path")}:</span>
|
||||
<span className="break-all">{deletePath}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
) : null}
|
||||
{showDeleteList ? (
|
||||
<div className="max-h-32 overflow-auto text-sm space-y-1">
|
||||
{deleteListItems.map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -310,4 +367,5 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { AlertCircle, ArrowDown, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ArrowDown, ArrowRight, ArrowUp, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -9,9 +9,12 @@ import {
|
||||
ContextMenuTrigger,
|
||||
} from "../ui/context-menu";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { getParentPath, joinPath } from "../../application/state/sftp/utils";
|
||||
import type { SftpFileEntry } from "../../types";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import type { ColumnWidths, SortField, SortOrder } from "./utils";
|
||||
import type { SftpTransferSource } from "./SftpContext";
|
||||
import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
|
||||
import { buildSftpColumnTemplate, type ColumnWidths, type SortField, type SortOrder } from "./utils";
|
||||
import { isNavigableDirectory } from "./index";
|
||||
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
|
||||
import { SftpFileRow } from "./index";
|
||||
@@ -20,6 +23,7 @@ interface SftpPaneFileListProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
pane: SftpPane;
|
||||
side: "left" | "right";
|
||||
isPaneFocused: boolean;
|
||||
columnWidths: ColumnWidths;
|
||||
sortField: SortField;
|
||||
sortOrder: SortOrder;
|
||||
@@ -31,8 +35,10 @@ interface SftpPaneFileListProps {
|
||||
totalHeight: number;
|
||||
sortedDisplayFiles: SftpFileEntry[];
|
||||
isDragOverPane: boolean;
|
||||
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
|
||||
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
|
||||
onRefresh: () => void;
|
||||
onNavigateTo: (path: string) => void;
|
||||
onClearSelection: () => void;
|
||||
setShowNewFolderDialog: (open: boolean) => void;
|
||||
setShowNewFileDialog: (open: boolean) => void;
|
||||
getNextUntitledName: (existingNames: string[]) => string;
|
||||
@@ -47,7 +53,8 @@ interface SftpPaneFileListProps {
|
||||
handleEntryDragOver: (entry: SftpFileEntry, e: React.DragEvent) => void;
|
||||
handleRowDragLeave: () => void;
|
||||
handleEntryDrop: (entry: SftpFileEntry, e: React.DragEvent) => void;
|
||||
onCopyToOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
|
||||
onCopyToOtherPane: (files: SftpTransferSource[]) => void;
|
||||
onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise<void>;
|
||||
onOpenFileWith?: (entry: SftpFileEntry) => void;
|
||||
onEditFile?: (entry: SftpFileEntry) => void;
|
||||
onDownloadFile?: (entry: SftpFileEntry) => void;
|
||||
@@ -58,10 +65,51 @@ interface SftpPaneFileListProps {
|
||||
visibleRows: { entry: SftpFileEntry; index: number; top: number }[];
|
||||
}
|
||||
|
||||
export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
const SftpErrorWithLogs: React.FC<{
|
||||
error: string;
|
||||
connectionLogs: string[];
|
||||
onRetry: () => void;
|
||||
t: (key: string) => string;
|
||||
}> = ({ error, connectionLogs, onRetry, t }) => {
|
||||
const [showLogs, setShowLogs] = useState(connectionLogs.length > 0);
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground">
|
||||
<Unplug size={28} className="text-destructive/70" />
|
||||
<span className="text-xs text-center px-6 max-w-xs leading-relaxed">{t(error)}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={onRetry}>
|
||||
{t("sftp.retry")}
|
||||
</Button>
|
||||
{connectionLogs.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-muted-foreground"
|
||||
onClick={() => setShowLogs(!showLogs)}
|
||||
>
|
||||
<ChevronDown size={14} className={`mr-1 transition-transform ${showLogs ? 'rotate-180' : ''}`} />
|
||||
{showLogs ? "Hide logs" : "Show logs"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{showLogs && connectionLogs.length > 0 && (
|
||||
<div className="w-full max-w-sm mt-1 p-2 rounded-md bg-secondary/50 border border-border/60 space-y-0.5 max-h-40 overflow-y-auto">
|
||||
{connectionLogs.map((log, i) => (
|
||||
<div key={i} className="text-[11px] text-muted-foreground truncate font-mono">
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
t,
|
||||
pane,
|
||||
side,
|
||||
isPaneFocused,
|
||||
columnWidths,
|
||||
sortField,
|
||||
sortOrder,
|
||||
@@ -75,6 +123,8 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
isDragOverPane,
|
||||
draggedFiles,
|
||||
onRefresh,
|
||||
onNavigateTo,
|
||||
onClearSelection,
|
||||
setShowNewFolderDialog,
|
||||
setShowNewFileDialog,
|
||||
getNextUntitledName,
|
||||
@@ -89,6 +139,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
handleRowDragLeave,
|
||||
handleEntryDrop,
|
||||
onCopyToOtherPane,
|
||||
onMoveEntriesToPath,
|
||||
onOpenFileWith,
|
||||
onEditFile,
|
||||
onDownloadFile,
|
||||
@@ -106,6 +157,39 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
return map;
|
||||
}, [sortedDisplayFiles]);
|
||||
|
||||
// Push sorted file names into the list order store for keyboard navigation
|
||||
useEffect(() => {
|
||||
const names = sortedDisplayFiles
|
||||
.filter((f) => f.name !== "..")
|
||||
.map((f) => f.name);
|
||||
sftpListOrderStore.setItems(pane.id, names);
|
||||
return () => sftpListOrderStore.clearPane(pane.id);
|
||||
}, [sortedDisplayFiles, pane.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pane.selectedFiles.size !== 1) return;
|
||||
const selectedName = Array.from(pane.selectedFiles)[0];
|
||||
if (!selectedName) return;
|
||||
|
||||
const container = fileListRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const row = Array.from(container.querySelectorAll<HTMLElement>('[data-sftp-row="true"]'))
|
||||
.find((element) => element.dataset.entryName === selectedName);
|
||||
row?.scrollIntoView({ block: "nearest" });
|
||||
}, [fileListRef, pane.selectedFiles]);
|
||||
|
||||
// Use refs for frequently-changing values in context-menu actions
|
||||
const selectedFilesRef = useRef(pane.selectedFiles);
|
||||
selectedFilesRef.current = pane.selectedFiles;
|
||||
|
||||
const handleBackgroundClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-sftp-row="true"]')) return;
|
||||
if (pane.selectedFiles.size === 0) return;
|
||||
onClearSelection();
|
||||
}, [onClearSelection, pane.selectedFiles.size]);
|
||||
|
||||
const renderRow = useCallback(
|
||||
(entry: SftpFileEntry, index: number) => (
|
||||
<ContextMenu>
|
||||
@@ -114,6 +198,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
entry={entry}
|
||||
index={index}
|
||||
isSelected={pane.selectedFiles.has(entry.name)}
|
||||
showSelectionHighlight={isPaneFocused}
|
||||
isDragOver={dragOverEntry === entry.name}
|
||||
columnWidths={columnWidths}
|
||||
onSelect={handleRowSelect}
|
||||
@@ -139,6 +224,11 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
</>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
{isNavigableDirectory(entry) && (
|
||||
<ContextMenuItem onClick={() => onNavigateTo(joinPath(pane.connection.currentPath, entry.name))}>
|
||||
<ArrowRight size={14} className="mr-2" /> {t("sftp.context.navigateTo")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{!isNavigableDirectory(entry) && onOpenFileWith && (
|
||||
<ContextMenuItem onClick={() => onOpenFileWith(entry)}>
|
||||
<ExternalLink size={14} className="mr-2" />{" "}
|
||||
@@ -161,8 +251,9 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
const files = pane.selectedFiles.has(entry.name)
|
||||
? Array.from(pane.selectedFiles)
|
||||
const currentSelected = selectedFilesRef.current;
|
||||
const files = currentSelected.has(entry.name)
|
||||
? Array.from(currentSelected)
|
||||
: [entry.name];
|
||||
const fileData = files.map((name) => {
|
||||
const fileName = String(name);
|
||||
@@ -170,6 +261,8 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
return {
|
||||
name: fileName,
|
||||
isDirectory: file ? isNavigableDirectory(file) : false,
|
||||
sourceConnectionId: pane.connection?.id,
|
||||
sourcePath: pane.connection?.currentPath,
|
||||
};
|
||||
});
|
||||
onCopyToOtherPane(fileData);
|
||||
@@ -178,8 +271,36 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
<Copy size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.copyToOtherPane")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(joinPath(pane.connection.currentPath, entry.name));
|
||||
}}
|
||||
>
|
||||
<ClipboardCopy size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.copyPath")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => openRenameDialog(entry.name)}>
|
||||
{(() => {
|
||||
const sourceParent = getParentPath(joinPath(pane.connection?.currentPath ?? "", entry.name));
|
||||
const targetParent = getParentPath(sourceParent);
|
||||
if (sourceParent === targetParent) return null;
|
||||
|
||||
return (
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
const currentSelected = selectedFilesRef.current;
|
||||
const sourcePaths = currentSelected.has(entry.name)
|
||||
? Array.from(currentSelected as Set<string>).map((n) => joinPath(pane.connection?.currentPath ?? "", n))
|
||||
: [joinPath(pane.connection?.currentPath ?? "", entry.name)];
|
||||
void onMoveEntriesToPath(sourcePaths, targetParent);
|
||||
}}
|
||||
>
|
||||
<ArrowUp size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.moveToParent")}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
})()}
|
||||
<ContextMenuItem onClick={() => openRenameDialog(joinPath(pane.connection?.currentPath ?? "", entry.name))}>
|
||||
<Pencil size={14} className="mr-2" /> {t("common.rename")}
|
||||
</ContextMenuItem>
|
||||
{onEditPermissions && pane.connection && !pane.connection.isLocal && (
|
||||
@@ -191,9 +312,10 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
const files = pane.selectedFiles.has(entry.name)
|
||||
? Array.from(pane.selectedFiles)
|
||||
: [entry.name];
|
||||
const currentSelected = selectedFilesRef.current;
|
||||
const files = currentSelected.has(entry.name)
|
||||
? Array.from(currentSelected as Set<string>).map((n) => joinPath(pane.connection?.currentPath ?? "", n))
|
||||
: [joinPath(pane.connection?.currentPath ?? "", entry.name)];
|
||||
openDeleteConfirm(files);
|
||||
}}
|
||||
>
|
||||
@@ -215,7 +337,6 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
),
|
||||
[
|
||||
columnWidths,
|
||||
dragOverEntry,
|
||||
filesByName,
|
||||
handleEntryDragOver,
|
||||
handleEntryDrop,
|
||||
@@ -223,11 +344,15 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
handleRowDragLeave,
|
||||
handleRowOpen,
|
||||
handleRowSelect,
|
||||
dragOverEntry,
|
||||
isPaneFocused,
|
||||
onCopyToOtherPane,
|
||||
onMoveEntriesToPath,
|
||||
onDownloadFile,
|
||||
onDragEnd,
|
||||
onEditFile,
|
||||
onEditPermissions,
|
||||
onNavigateTo,
|
||||
onOpenFileWith,
|
||||
onRefresh,
|
||||
openDeleteConfirm,
|
||||
@@ -257,7 +382,13 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
{renderRow(entry, index)}
|
||||
</React.Fragment>
|
||||
)),
|
||||
[renderRow, rowHeight, shouldVirtualize, sortedDisplayFiles, visibleRows],
|
||||
[
|
||||
renderRow,
|
||||
rowHeight,
|
||||
shouldVirtualize,
|
||||
sortedDisplayFiles,
|
||||
visibleRows,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -267,16 +398,16 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
className="text-[11px] uppercase tracking-wide text-muted-foreground px-4 py-2 border-b border-border/40 bg-secondary/10 select-none"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.modified}% ${columnWidths.size}% ${columnWidths.type}%`,
|
||||
gridTemplateColumns: buildSftpColumnTemplate(columnWidths),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
|
||||
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 overflow-hidden"
|
||||
onClick={() => handleSort("name")}
|
||||
>
|
||||
<span>{t("sftp.columns.name")}</span>
|
||||
<span className="truncate whitespace-nowrap">{t("sftp.columns.name")}</span>
|
||||
{sortField === "name" && (
|
||||
<span className="text-primary">
|
||||
<span className="shrink-0 text-primary">
|
||||
{sortOrder === "asc" ? "↑" : "↓"}
|
||||
</span>
|
||||
)}
|
||||
@@ -286,12 +417,12 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
|
||||
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 overflow-hidden"
|
||||
onClick={() => handleSort("modified")}
|
||||
>
|
||||
<span>{t("sftp.columns.modified")}</span>
|
||||
<span className="truncate whitespace-nowrap">{t("sftp.columns.modified")}</span>
|
||||
{sortField === "modified" && (
|
||||
<span className="text-primary">
|
||||
<span className="shrink-0 text-primary">
|
||||
{sortOrder === "asc" ? "↑" : "↓"}
|
||||
</span>
|
||||
)}
|
||||
@@ -301,30 +432,30 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 justify-end"
|
||||
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 justify-end overflow-hidden"
|
||||
onClick={() => handleSort("size")}
|
||||
>
|
||||
{sortField === "size" && (
|
||||
<span className="text-primary">
|
||||
<span className="shrink-0 text-primary">
|
||||
{sortOrder === "asc" ? "↑" : "↓"}
|
||||
</span>
|
||||
)}
|
||||
<span>{t("sftp.columns.size")}</span>
|
||||
<span className="truncate whitespace-nowrap">{t("sftp.columns.size")}</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 justify-end"
|
||||
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground justify-end overflow-hidden"
|
||||
onClick={() => handleSort("type")}
|
||||
>
|
||||
{sortField === "type" && (
|
||||
<span className="text-primary">
|
||||
<span className="shrink-0 text-primary">
|
||||
{sortOrder === "asc" ? "↑" : "↓"}
|
||||
</span>
|
||||
)}
|
||||
<span>{t("sftp.columns.kind")}</span>
|
||||
<span className="truncate whitespace-nowrap">{t("sftp.columns.kind")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -337,20 +468,29 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
"flex-1 min-h-0 overflow-y-auto relative",
|
||||
isDragOverPane && "ring-2 ring-primary/30 ring-inset",
|
||||
)}
|
||||
onClick={handleBackgroundClick}
|
||||
onScroll={handleFileListScroll}
|
||||
>
|
||||
{pane.loading && sortedDisplayFiles.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
{pane.connectionLogs.length > 0 && (
|
||||
<div className="w-full max-w-sm mt-2 space-y-0.5 px-4">
|
||||
{pane.connectionLogs.map((log, i) => (
|
||||
<div key={i} className="text-[11px] text-muted-foreground truncate">
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : pane.error && !pane.reconnecting ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2 text-destructive">
|
||||
<AlertCircle size={24} />
|
||||
<span className="text-sm">{t(pane.error)}</span>
|
||||
<Button variant="outline" size="sm" onClick={onRefresh}>
|
||||
{t("sftp.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
<SftpErrorWithLogs
|
||||
error={pane.error}
|
||||
connectionLogs={pane.connectionLogs}
|
||||
onRetry={onRefresh}
|
||||
t={t}
|
||||
/>
|
||||
) : sortedDisplayFiles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Folder size={32} className="mb-2 opacity-50" />
|
||||
@@ -400,7 +540,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
<div className="h-9 shrink-0 px-4 flex items-center justify-between text-[11px] text-muted-foreground border-t border-border/40 bg-secondary/30">
|
||||
<span>
|
||||
{t("sftp.itemsCount", {
|
||||
count: sortedDisplayFiles.filter((f) => f.name !== "..").length,
|
||||
count: sortedDisplayFiles.length - (sortedDisplayFiles[0]?.name === ".." ? 1 : 0),
|
||||
})}
|
||||
{pane.selectedFiles.size > 0 &&
|
||||
` - ${t("sftp.selectedCount", { count: pane.selectedFiles.size })}`}
|
||||
@@ -410,10 +550,19 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Loading overlay - covers entire pane when navigating directories */}
|
||||
{/* Loading overlay - covers entire pane when navigating or reconnecting */}
|
||||
{pane.loading && sortedDisplayFiles.length > 0 && !pane.reconnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-[1px] z-10">
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-background/40 backdrop-blur-[1px] z-10">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
{pane.connectionLogs.length > 0 && (
|
||||
<div className="w-full max-w-sm mt-2 space-y-0.5 px-4">
|
||||
{pane.connectionLogs.map((log, i) => (
|
||||
<div key={i} className="text-[11px] text-muted-foreground truncate">
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -431,4 +580,4 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Bookmark, Check, Eye, EyeOff, FilePlus, Folder, FolderPlus, Home, Languages, MoreHorizontal, RefreshCw, Search, TerminalSquare, Trash2, X } from "lucide-react";
|
||||
import { Bookmark, Check, Eye, EyeOff, FilePlus, Folder, FolderPlus, Globe, Home, Languages, List, ListTree, MoreHorizontal, RefreshCw, Search, TerminalSquare, Trash2, X } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
@@ -46,11 +46,15 @@ interface SftpPaneToolbarProps {
|
||||
bookmarks: SftpBookmark[];
|
||||
isCurrentPathBookmarked: boolean;
|
||||
onToggleBookmark: () => void;
|
||||
onAddGlobalBookmark: (path: string) => void;
|
||||
isCurrentPathGlobalBookmarked: boolean;
|
||||
onNavigateToBookmark: (path: string) => void;
|
||||
onDeleteBookmark: (id: string) => void;
|
||||
showHiddenFiles: boolean;
|
||||
onToggleShowHiddenFiles?: () => void;
|
||||
onGoToTerminalCwd?: () => void;
|
||||
viewMode: 'list' | 'tree';
|
||||
onSetViewMode: (mode: 'list' | 'tree') => void;
|
||||
}
|
||||
|
||||
// Prioritize breadcrumb path display. 6 action buttons need ~156px,
|
||||
@@ -58,7 +62,7 @@ interface SftpPaneToolbarProps {
|
||||
// always gets at least ~200px of space.
|
||||
const COLLAPSE_WIDTH = 400;
|
||||
|
||||
export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
t,
|
||||
pane,
|
||||
onNavigateTo,
|
||||
@@ -92,14 +96,29 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
onToggleBookmark,
|
||||
onAddGlobalBookmark,
|
||||
isCurrentPathGlobalBookmarked,
|
||||
onNavigateToBookmark,
|
||||
onDeleteBookmark,
|
||||
showHiddenFiles,
|
||||
onToggleShowHiddenFiles,
|
||||
onGoToTerminalCwd,
|
||||
viewMode,
|
||||
onSetViewMode,
|
||||
}) => {
|
||||
const outerRef = useRef<HTMLDivElement>(null);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [displayPath, setDisplayPath] = useState(pane.connection?.currentPath ?? "");
|
||||
const prevDisplayConnectionIdRef = useRef(pane.connection?.id);
|
||||
|
||||
useEffect(() => {
|
||||
const connectionChanged = pane.connection?.id !== prevDisplayConnectionIdRef.current;
|
||||
prevDisplayConnectionIdRef.current = pane.connection?.id;
|
||||
// Sync immediately on connection change; otherwise defer until loading completes
|
||||
if (connectionChanged || !pane.loading) {
|
||||
setDisplayPath(pane.connection?.currentPath ?? "");
|
||||
}
|
||||
}, [pane.connection?.currentPath, pane.connection?.id, pane.loading]);
|
||||
|
||||
// Observe the overall toolbar width to decide whether to collapse action buttons
|
||||
useEffect(() => {
|
||||
@@ -153,6 +172,36 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
<TooltipContent>{t("sftp.goToTerminalCwd")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-6 w-6", viewMode === 'list' && "bg-secondary text-foreground")}
|
||||
aria-pressed={viewMode === 'list'}
|
||||
aria-label={t('sftp.viewMode.list')}
|
||||
onClick={() => onSetViewMode('list')}
|
||||
>
|
||||
<List size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('sftp.viewMode.list')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-6 w-6", viewMode === 'tree' && "bg-secondary text-foreground")}
|
||||
aria-pressed={viewMode === 'tree'}
|
||||
aria-label={t('sftp.viewMode.tree')}
|
||||
onClick={() => onSetViewMode('tree')}
|
||||
>
|
||||
<ListTree size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('sftp.viewMode.tree')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -275,6 +324,32 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
// Overflow dropdown menu items (same collapsible actions as menu items)
|
||||
const overflowMenuItems = (
|
||||
<div className="flex flex-col min-w-[140px]">
|
||||
<div role="radiogroup" aria-label={t('sftp.viewMode.label')}>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left",
|
||||
viewMode === 'list' && "text-primary"
|
||||
)}
|
||||
role="radio"
|
||||
aria-checked={viewMode === 'list'}
|
||||
onClick={() => onSetViewMode('list')}
|
||||
>
|
||||
<List size={14} className="shrink-0" />
|
||||
{t('sftp.viewMode.list')}
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left",
|
||||
viewMode === 'tree' && "text-primary"
|
||||
)}
|
||||
role="radio"
|
||||
aria-checked={viewMode === 'tree'}
|
||||
onClick={() => onSetViewMode('tree')}
|
||||
>
|
||||
<ListTree size={14} className="shrink-0" />
|
||||
{t('sftp.viewMode.tree')}
|
||||
</button>
|
||||
</div>
|
||||
{isRemote && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -406,7 +481,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
title={t("sftp.path.doubleClickToEdit")}
|
||||
>
|
||||
<SftpBreadcrumb
|
||||
path={pane.connection.currentPath}
|
||||
path={displayPath}
|
||||
onNavigate={onNavigateTo}
|
||||
onHome={() =>
|
||||
pane.connection?.homeDir &&
|
||||
@@ -440,16 +515,31 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
<TooltipContent>{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-64 p-0" align="start">
|
||||
<div className="p-2 border-b border-border/40">
|
||||
<div className="p-2 border-b border-border/40 flex gap-1">
|
||||
<Button
|
||||
variant={isCurrentPathBookmarked ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="w-full justify-start text-xs h-7"
|
||||
className="flex-1 justify-start text-xs h-7"
|
||||
onClick={onToggleBookmark}
|
||||
>
|
||||
<Bookmark size={12} fill={isCurrentPathBookmarked ? "currentColor" : "none"} className={cn("mr-2", isCurrentPathBookmarked && "text-yellow-500")} />
|
||||
{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
|
||||
</Button>
|
||||
{pane.connection?.currentPath && !isCurrentPathGlobalBookmarked && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs h-7 px-2 shrink-0"
|
||||
onClick={() => pane.connection?.currentPath && onAddGlobalBookmark(pane.connection.currentPath)}
|
||||
>
|
||||
{t("sftp.bookmark.addGlobal")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.bookmark.addGlobalTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{bookmarks.length > 0 ? (
|
||||
<div className="max-h-48 overflow-auto py-1">
|
||||
@@ -458,6 +548,9 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
key={bm.id}
|
||||
className="flex items-center gap-1 px-2 py-1 hover:bg-secondary/60 group"
|
||||
>
|
||||
{bm.global && (
|
||||
<Globe size={10} className="shrink-0 text-primary" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 text-left text-xs truncate font-mono"
|
||||
@@ -578,4 +671,4 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
)}
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
1543
components/sftp/SftpPaneTreeView.tsx
Normal file
1543
components/sftp/SftpPaneTreeView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import { SftpPaneDialogs } from "./SftpPaneDialogs";
|
||||
import { SftpPaneEmptyState } from "./SftpPaneEmptyState";
|
||||
import { SftpPaneFileList } from "./SftpPaneFileList";
|
||||
import { SftpPaneToolbar } from "./SftpPaneToolbar";
|
||||
import { SftpPaneTreeView } from "./SftpPaneTreeView";
|
||||
import {
|
||||
useActiveTabId,
|
||||
useSftpDrag,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
useSftpUpdateHosts,
|
||||
} from "./index";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import { joinPath } from "../../application/state/sftp/utils";
|
||||
import type { Host } from "../../domain/models";
|
||||
import { useSftpPaneDialogs } from "./hooks/useSftpPaneDialogs";
|
||||
import { useSftpPaneDragAndSelect } from "./hooks/useSftpPaneDragAndSelect";
|
||||
@@ -25,6 +27,16 @@ import { useSftpPaneVirtualList } from "./hooks/useSftpPaneVirtualList";
|
||||
import { useSftpDialogActionHandler } from "./hooks/useSftpDialogAction";
|
||||
import { useSftpBookmarks } from "./hooks/useSftpBookmarks";
|
||||
import { useLocalSftpBookmarks } from "./hooks/useLocalSftpBookmarks";
|
||||
import { useGlobalSftpBookmarks } from "./hooks/useGlobalSftpBookmarks";
|
||||
import { useSftpHostViewMode } from "./hooks/useSftpHostViewMode";
|
||||
import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
|
||||
import { sftpTreeSelectionStore } from "./hooks/useSftpTreeSelectionStore";
|
||||
|
||||
interface TreeReloadRequest {
|
||||
token: number;
|
||||
paths?: string[];
|
||||
full?: boolean;
|
||||
}
|
||||
|
||||
interface SftpPaneWrapperProps {
|
||||
side: "left" | "right";
|
||||
@@ -55,31 +67,66 @@ SftpPaneWrapper.displayName = "SftpPaneWrapper";
|
||||
interface SftpPaneViewProps {
|
||||
side: "left" | "right";
|
||||
pane: SftpPane;
|
||||
dialogActionScopeId: string;
|
||||
isPaneFocused: boolean;
|
||||
sftpDefaultViewMode: 'list' | 'tree';
|
||||
showHeader?: boolean;
|
||||
showEmptyHeader?: boolean;
|
||||
onToggleShowHiddenFiles?: () => void;
|
||||
onGoToTerminalCwd?: () => void;
|
||||
/** When true, treat this pane as always active (used by SftpSidePanel which manages visibility itself) */
|
||||
forceActive?: boolean;
|
||||
}
|
||||
|
||||
const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
side,
|
||||
pane,
|
||||
dialogActionScopeId,
|
||||
isPaneFocused,
|
||||
sftpDefaultViewMode,
|
||||
showHeader = true,
|
||||
showEmptyHeader = true,
|
||||
onToggleShowHiddenFiles,
|
||||
onGoToTerminalCwd,
|
||||
forceActive,
|
||||
}) => {
|
||||
const isActive = true;
|
||||
const activeTabId = useActiveTabId(side);
|
||||
const isActive = forceActive || (activeTabId ? pane.id === activeTabId : true);
|
||||
|
||||
const callbacks = useSftpPaneCallbacks(side);
|
||||
const { draggedFiles, onDragStart, onDragEnd } = useSftpDrag();
|
||||
const hosts = useSftpHosts();
|
||||
|
||||
const { t } = useI18n();
|
||||
const hostId = pane.connection?.hostId;
|
||||
const { hostViewMode, setHostViewMode: saveHostViewMode } = useSftpHostViewMode(hostId);
|
||||
const [, startTransition] = useTransition();
|
||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||
const initialViewMode = hostViewMode ?? sftpDefaultViewMode ?? 'list';
|
||||
const [viewMode, setViewMode] = useState<'list' | 'tree'>(initialViewMode);
|
||||
const [treeReloadRequest, setTreeReloadRequest] = useState<TreeReloadRequest>({ token: 0, full: true });
|
||||
// Lazy-mount: only render the tree component once tree mode has been activated
|
||||
const [treeEverMounted, setTreeEverMounted] = useState(initialViewMode === 'tree');
|
||||
useEffect(() => {
|
||||
if (viewMode === 'tree' && !treeEverMounted) setTreeEverMounted(true);
|
||||
}, [viewMode, treeEverMounted]);
|
||||
const filterInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const requestTreeReload = useCallback((paths?: string[], full = false) => {
|
||||
setTreeReloadRequest((prev) => ({
|
||||
token: prev.token + 1,
|
||||
paths,
|
||||
full,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const requestNestedTreeReload = useCallback((paths?: string[]) => {
|
||||
const targets = Array.from(new Set((paths ?? []).filter(Boolean)));
|
||||
if (targets.length > 0) {
|
||||
requestTreeReload(targets);
|
||||
}
|
||||
}, [requestTreeReload]);
|
||||
|
||||
useRenderTracker(`SftpPaneView[${side}]`, {
|
||||
side,
|
||||
paneId: pane.id,
|
||||
@@ -109,18 +156,43 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
const localBookmarks = useLocalSftpBookmarks({
|
||||
currentPath: pane.connection?.currentPath,
|
||||
});
|
||||
const {
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
toggleBookmark,
|
||||
deleteBookmark,
|
||||
} = pane.connection?.isLocal ? localBookmarks : remoteBookmarks;
|
||||
const globalBookmarks = useGlobalSftpBookmarks({
|
||||
currentPath: pane.connection?.currentPath,
|
||||
});
|
||||
const hostBookmarks = pane.connection?.isLocal ? localBookmarks : remoteBookmarks;
|
||||
const mergedBookmarks = useMemo(
|
||||
() => [...globalBookmarks.bookmarks.map((b) => ({ ...b, global: true as const })), ...hostBookmarks.bookmarks],
|
||||
[hostBookmarks.bookmarks, globalBookmarks.bookmarks],
|
||||
);
|
||||
const isCurrentPathBookmarked = hostBookmarks.isCurrentPathBookmarked || globalBookmarks.isCurrentPathBookmarked;
|
||||
const toggleBookmark = useCallback(() => {
|
||||
if (globalBookmarks.isCurrentPathBookmarked && !hostBookmarks.isCurrentPathBookmarked) {
|
||||
const currentPath = pane.connection?.currentPath;
|
||||
if (currentPath) {
|
||||
const bm = globalBookmarks.bookmarks.find((b) => b.path === currentPath);
|
||||
if (bm) globalBookmarks.deleteBookmark(bm.id);
|
||||
}
|
||||
} else {
|
||||
hostBookmarks.toggleBookmark();
|
||||
}
|
||||
}, [hostBookmarks, globalBookmarks, pane.connection?.currentPath]);
|
||||
const deleteBookmark = useCallback(
|
||||
(id: string) => {
|
||||
if (id.startsWith("gbm-")) {
|
||||
globalBookmarks.deleteBookmark(id);
|
||||
} else {
|
||||
hostBookmarks.deleteBookmark(id);
|
||||
}
|
||||
},
|
||||
[hostBookmarks, globalBookmarks],
|
||||
);
|
||||
|
||||
const { filteredFiles, sortedDisplayFiles } = useSftpPaneFiles({
|
||||
const { sortedDisplayFiles } = useSftpPaneFiles({
|
||||
files: pane.files,
|
||||
filter: pane.filter,
|
||||
connection: pane.connection,
|
||||
showHiddenFiles: pane.showHiddenFiles,
|
||||
enableListView: viewMode === 'list',
|
||||
sortField,
|
||||
sortOrder,
|
||||
});
|
||||
@@ -141,7 +213,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
handlePathSubmit,
|
||||
} = useSftpPanePath({
|
||||
connection: pane.connection,
|
||||
filteredFiles,
|
||||
files: pane.files,
|
||||
showHiddenFiles: pane.showHiddenFiles,
|
||||
onNavigateTo: callbacks.onNavigateTo,
|
||||
});
|
||||
const {
|
||||
@@ -179,6 +252,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
handleConfirmOverwrite,
|
||||
handleRename,
|
||||
handleDelete,
|
||||
openNewFolderDialogAtPath,
|
||||
openNewFileDialogAtPath,
|
||||
openRenameDialog,
|
||||
openDeleteConfirm,
|
||||
getNextUntitledName,
|
||||
@@ -186,11 +261,25 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
t,
|
||||
pane,
|
||||
onCreateDirectory: callbacks.onCreateDirectory,
|
||||
onCreateDirectoryAtPath: callbacks.onCreateDirectoryAtPath,
|
||||
onCreateFile: callbacks.onCreateFile,
|
||||
onRenameFile: callbacks.onRenameFile,
|
||||
onDeleteFiles: callbacks.onDeleteFiles,
|
||||
onCreateFileAtPath: callbacks.onCreateFileAtPath,
|
||||
onRenameFileAtPath: callbacks.onRenameFileAtPath,
|
||||
onDeleteFilesAtPath: callbacks.onDeleteFilesAtPath,
|
||||
onClearSelection: callbacks.onClearSelection,
|
||||
onMutateSuccess: (paths?: string[]) => requestNestedTreeReload(paths),
|
||||
});
|
||||
const handleUploadExternalFiles = useCallback(async (dataTransfer: DataTransfer, targetPath?: string) => {
|
||||
await callbacks.onUploadExternalFiles?.(dataTransfer, targetPath);
|
||||
const affectedPath = targetPath ?? pane.connection?.currentPath;
|
||||
if (affectedPath && affectedPath !== pane.connection?.currentPath) {
|
||||
requestTreeReload([affectedPath]);
|
||||
}
|
||||
}, [callbacks, pane.connection?.currentPath, requestTreeReload]);
|
||||
|
||||
const handleMoveEntriesToPath = useCallback(async (sourcePaths: string[], targetPath: string) => {
|
||||
await callbacks.onMoveEntriesToPath(sourcePaths, targetPath);
|
||||
}, [callbacks]);
|
||||
const {
|
||||
dragOverEntry,
|
||||
isDragOverPane,
|
||||
@@ -211,7 +300,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
draggedFiles,
|
||||
onDragStart,
|
||||
onReceiveFromOtherPane: callbacks.onReceiveFromOtherPane,
|
||||
onUploadExternalFiles: callbacks.onUploadExternalFiles,
|
||||
onMoveEntriesToPath: callbacks.onMoveEntriesToPath,
|
||||
onUploadExternalFiles: handleUploadExternalFiles,
|
||||
onOpenEntry: callbacks.onOpenEntry,
|
||||
onRangeSelect: callbacks.onRangeSelect,
|
||||
onToggleSelection: callbacks.onToggleSelection,
|
||||
@@ -225,14 +315,26 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
visibleRows,
|
||||
} = useSftpPaneVirtualList({
|
||||
isActive,
|
||||
enabled: viewMode === 'list',
|
||||
sortedDisplayFiles,
|
||||
});
|
||||
|
||||
const toFullPath = useCallback(
|
||||
(target: string) => {
|
||||
const currentPath = pane.connection?.currentPath;
|
||||
if (!currentPath || target.includes("/") || target.includes("\\")) {
|
||||
return target;
|
||||
}
|
||||
return joinPath(currentPath, target);
|
||||
},
|
||||
[pane.connection?.currentPath],
|
||||
);
|
||||
|
||||
// Handle keyboard shortcut dialog actions
|
||||
const dialogActionHandlers = useMemo(
|
||||
() => ({
|
||||
onRename: (fileName: string) => openRenameDialog(fileName),
|
||||
onDelete: (fileNames: string[]) => openDeleteConfirm(fileNames),
|
||||
onRename: (fileName: string) => openRenameDialog(toFullPath(fileName)),
|
||||
onDelete: (fileNames: string[]) => openDeleteConfirm(fileNames.map(toFullPath)),
|
||||
onNewFolder: () => {
|
||||
setNewFolderName("");
|
||||
setShowNewFolderDialog(true);
|
||||
@@ -249,6 +351,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
openDeleteConfirm,
|
||||
openRenameDialog,
|
||||
pane.files,
|
||||
toFullPath,
|
||||
setFileNameError,
|
||||
setNewFileName,
|
||||
setNewFolderName,
|
||||
@@ -257,12 +360,51 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
],
|
||||
);
|
||||
|
||||
useSftpDialogActionHandler(side, dialogActionHandlers);
|
||||
useSftpDialogActionHandler(side, dialogActionScopeId, dialogActionHandlers, isActive);
|
||||
|
||||
const handleSortWithTransition = (field: typeof sortField) => {
|
||||
startTransition(() => handleSort(field));
|
||||
};
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
callbacks.onRefresh();
|
||||
if (viewMode === 'tree') {
|
||||
requestTreeReload(undefined, true);
|
||||
}
|
||||
}, [callbacks, requestTreeReload, viewMode]);
|
||||
|
||||
const onSetFilterRef = useRef(callbacks.onSetFilter);
|
||||
onSetFilterRef.current = callbacks.onSetFilter;
|
||||
const onClearSelectionRef = useRef(callbacks.onClearSelection);
|
||||
onClearSelectionRef.current = callbacks.onClearSelection;
|
||||
|
||||
const handleSetViewMode = useCallback((mode: 'list' | 'tree') => {
|
||||
setViewMode(mode);
|
||||
saveHostViewMode(mode);
|
||||
if (mode === 'tree') {
|
||||
setShowFilterBar(false);
|
||||
onSetFilterRef.current('');
|
||||
onClearSelectionRef.current();
|
||||
}
|
||||
}, [saveHostViewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'list') {
|
||||
sftpTreeSelectionStore.clearPane(pane.id);
|
||||
return;
|
||||
}
|
||||
sftpListOrderStore.clearPane(pane.id);
|
||||
}, [pane.id, viewMode]);
|
||||
|
||||
// When connecting to a host, restore its saved view mode preference
|
||||
const prevHostIdRef = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (hostId && hostId !== prevHostIdRef.current) {
|
||||
setViewMode(hostViewMode ?? sftpDefaultViewMode);
|
||||
}
|
||||
prevHostIdRef.current = hostId;
|
||||
}, [hostId, hostViewMode, sftpDefaultViewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
logger.debug("SftpPaneView active state", {
|
||||
side,
|
||||
@@ -271,6 +413,17 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
});
|
||||
}, [isActive, pane.id, side]);
|
||||
|
||||
const lastHandledTransferMutationTokenRef = useRef(0);
|
||||
useEffect(() => {
|
||||
if (!pane.connection || pane.transferMutationToken === 0) return;
|
||||
if (pane.transferMutationToken === lastHandledTransferMutationTokenRef.current) return;
|
||||
lastHandledTransferMutationTokenRef.current = pane.transferMutationToken;
|
||||
callbacks.onRefreshTab(pane.id);
|
||||
if (viewMode === 'tree') {
|
||||
requestTreeReload(undefined, true);
|
||||
}
|
||||
}, [callbacks, pane.connection, pane.id, pane.transferMutationToken, requestTreeReload, viewMode]);
|
||||
|
||||
if (!pane.connection) {
|
||||
return (
|
||||
<SftpPaneEmptyState
|
||||
@@ -304,7 +457,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
onNavigateTo={callbacks.onNavigateTo}
|
||||
onSetFilter={callbacks.onSetFilter}
|
||||
onSetFilenameEncoding={callbacks.onSetFilenameEncoding}
|
||||
onRefresh={callbacks.onRefresh}
|
||||
onRefresh={handleRefresh}
|
||||
showFilterBar={showFilterBar}
|
||||
setShowFilterBar={setShowFilterBar}
|
||||
filterInputRef={filterInputRef}
|
||||
@@ -329,20 +482,61 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
setShowNewFileDialog={setShowNewFileDialog}
|
||||
setShowNewFolderDialog={setShowNewFolderDialog}
|
||||
setNewFolderName={setNewFolderName}
|
||||
bookmarks={bookmarks}
|
||||
bookmarks={mergedBookmarks}
|
||||
isCurrentPathBookmarked={isCurrentPathBookmarked}
|
||||
onToggleBookmark={toggleBookmark}
|
||||
onAddGlobalBookmark={globalBookmarks.addBookmark}
|
||||
isCurrentPathGlobalBookmarked={globalBookmarks.isCurrentPathBookmarked}
|
||||
onNavigateToBookmark={callbacks.onNavigateTo}
|
||||
onDeleteBookmark={deleteBookmark}
|
||||
showHiddenFiles={pane.showHiddenFiles}
|
||||
onToggleShowHiddenFiles={onToggleShowHiddenFiles}
|
||||
onGoToTerminalCwd={onGoToTerminalCwd}
|
||||
viewMode={viewMode}
|
||||
onSetViewMode={handleSetViewMode}
|
||||
/>
|
||||
|
||||
{treeEverMounted && (
|
||||
<div className={viewMode === 'tree' ? 'flex-1 min-h-0 flex flex-col' : 'hidden'}>
|
||||
<SftpPaneTreeView
|
||||
pane={pane}
|
||||
side={side}
|
||||
onPrepareSelection={callbacks.onPrepareSelection}
|
||||
onLoadChildren={callbacks.onListDirectory}
|
||||
onMoveEntriesToPath={handleMoveEntriesToPath}
|
||||
onNavigateUp={callbacks.onNavigateUp}
|
||||
onNavigateTo={callbacks.onNavigateTo}
|
||||
onRefresh={handleRefresh}
|
||||
onOpenEntry={callbacks.onOpenEntry}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
openRenameDialog={openRenameDialog}
|
||||
openDeleteConfirm={openDeleteConfirm}
|
||||
onCopyToOtherPane={callbacks.onCopyToOtherPane}
|
||||
onReceiveFromOtherPane={callbacks.onReceiveFromOtherPane}
|
||||
onOpenFileWith={callbacks.onOpenFileWith}
|
||||
onEditFile={callbacks.onEditFile}
|
||||
onDownloadFile={callbacks.onDownloadFile}
|
||||
onEditPermissions={callbacks.onEditPermissions}
|
||||
draggedFiles={draggedFiles}
|
||||
openNewFolderDialog={openNewFolderDialogAtPath}
|
||||
openNewFileDialog={openNewFileDialogAtPath}
|
||||
onUploadExternalFiles={handleUploadExternalFiles}
|
||||
columnWidths={columnWidths}
|
||||
handleSort={handleSortWithTransition}
|
||||
handleResizeStart={handleResizeStart}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
reloadRequest={treeReloadRequest}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={viewMode === 'list' ? 'flex-1 min-h-0 flex flex-col' : 'hidden'}>
|
||||
<SftpPaneFileList
|
||||
t={t}
|
||||
pane={pane}
|
||||
side={side}
|
||||
isPaneFocused={isPaneFocused}
|
||||
columnWidths={columnWidths}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
@@ -355,7 +549,9 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
sortedDisplayFiles={sortedDisplayFiles}
|
||||
isDragOverPane={isDragOverPane}
|
||||
draggedFiles={draggedFiles}
|
||||
onRefresh={callbacks.onRefresh}
|
||||
onRefresh={handleRefresh}
|
||||
onNavigateTo={callbacks.onNavigateTo}
|
||||
onClearSelection={callbacks.onClearSelection}
|
||||
setShowNewFolderDialog={setShowNewFolderDialog}
|
||||
setShowNewFileDialog={setShowNewFileDialog}
|
||||
getNextUntitledName={getNextUntitledName}
|
||||
@@ -370,6 +566,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
handleRowDragLeave={handleRowDragLeave}
|
||||
handleEntryDrop={handleEntryDrop}
|
||||
onCopyToOtherPane={callbacks.onCopyToOtherPane}
|
||||
onMoveEntriesToPath={handleMoveEntriesToPath}
|
||||
onOpenFileWith={callbacks.onOpenFileWith}
|
||||
onEditFile={callbacks.onEditFile}
|
||||
onDownloadFile={callbacks.onDownloadFile}
|
||||
@@ -379,9 +576,12 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
rowHeight={rowHeight}
|
||||
visibleRows={visibleRows}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SftpPaneDialogs
|
||||
t={t}
|
||||
hostLabel={pane.connection?.hostLabel}
|
||||
currentPath={pane.connection?.currentPath}
|
||||
showNewFolderDialog={showNewFolderDialog}
|
||||
setShowNewFolderDialog={setShowNewFolderDialog}
|
||||
newFolderName={newFolderName}
|
||||
@@ -430,8 +630,11 @@ const sftpPaneViewAreEqual = (
|
||||
): boolean => {
|
||||
if (prev.pane !== next.pane) return false;
|
||||
if (prev.side !== next.side) return false;
|
||||
if (prev.dialogActionScopeId !== next.dialogActionScopeId) return false;
|
||||
if (prev.isPaneFocused !== next.isPaneFocused) return false;
|
||||
if (prev.showHeader !== next.showHeader) return false;
|
||||
if (prev.showEmptyHeader !== next.showEmptyHeader) return false;
|
||||
if (prev.sftpDefaultViewMode !== next.sftpDefaultViewMode) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -147,7 +147,8 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
container.scrollLeft += tabRect.right - containerRect.right + 8;
|
||||
}
|
||||
}
|
||||
setTimeout(updateScrollState, 100);
|
||||
const timer = setTimeout(updateScrollState, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [activeTabId, updateScrollState]);
|
||||
|
||||
// Drag handlers
|
||||
@@ -213,6 +214,22 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
[onCloseTab],
|
||||
);
|
||||
|
||||
const handleSelectTabClick = useCallback(
|
||||
(e: React.MouseEvent, tabId: string) => {
|
||||
e.stopPropagation();
|
||||
onSelectTab(tabId);
|
||||
},
|
||||
[onSelectTab],
|
||||
);
|
||||
|
||||
const handleAddTabClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onAddTab();
|
||||
},
|
||||
[onAddTab],
|
||||
);
|
||||
|
||||
// Cross-pane drag handlers
|
||||
const handleCrossPaneDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
@@ -301,7 +318,7 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
<div
|
||||
key={tab.id}
|
||||
data-tab-id={tab.id}
|
||||
onClick={() => onSelectTab(tab.id)}
|
||||
onClick={(e) => handleSelectTabClick(e, tab.id)}
|
||||
draggable
|
||||
onDragStart={(e) => handleTabDragStart(e, tab.id)}
|
||||
onDragEnd={handleTabDragEnd}
|
||||
@@ -378,7 +395,7 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
{/* Add tab button */}
|
||||
<button
|
||||
className="px-2 flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-[linear-gradient(135deg,_hsl(var(--accent)_/_0.18),_hsl(var(--primary)_/_0.18))] transition-all duration-150 border-l border-border/40 cursor-pointer"
|
||||
onClick={onAddTab}
|
||||
onClick={handleAddTabClick}
|
||||
title={t("sftp.tabs.addTab")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
@@ -417,4 +434,3 @@ const sftpTabBarAreEqual = (
|
||||
|
||||
export const SftpTabBar = memo(SftpTabBarInner, sftpTabBarAreEqual);
|
||||
SftpTabBar.displayName = "SftpTabBar";
|
||||
|
||||
|
||||
@@ -4,237 +4,375 @@
|
||||
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
File,
|
||||
FolderUp,
|
||||
GripVertical,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
X,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import React, { memo } from 'react';
|
||||
import { getParentPath } from '../../application/state/sftp/utils';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { getParentPath } from '../../application/state/sftp/utils';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { TransferTask } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
|
||||
import { formatSpeed, formatTransferBytes } from './utils';
|
||||
|
||||
interface SftpTransferItemProps {
|
||||
task: TransferTask;
|
||||
isChild?: boolean;
|
||||
childNameColumnWidth?: number;
|
||||
onResizeNameColumn?: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onCancel: () => void;
|
||||
onRetry: () => void;
|
||||
onDismiss: () => void;
|
||||
canRevealTarget?: boolean;
|
||||
onRevealTarget?: () => void;
|
||||
canToggleChildren?: boolean;
|
||||
isExpanded?: boolean;
|
||||
visibleChildCount?: number;
|
||||
onToggleChildren?: () => void;
|
||||
}
|
||||
|
||||
const TruncatedTextWithTooltip: React.FC<{
|
||||
text: string;
|
||||
className?: string;
|
||||
}> = ({ text, className }) => (
|
||||
<TooltipProvider delayDuration={300} skipDelayDuration={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn("truncate", className)}>
|
||||
{text}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start" className="max-w-md break-all">
|
||||
{text}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
task,
|
||||
isChild = false,
|
||||
childNameColumnWidth = 260,
|
||||
onResizeNameColumn,
|
||||
onCancel,
|
||||
onRetry,
|
||||
onDismiss,
|
||||
canRevealTarget = false,
|
||||
onRevealTarget,
|
||||
canToggleChildren = false,
|
||||
isExpanded = false,
|
||||
visibleChildCount: _visibleChildCount = 0,
|
||||
onToggleChildren,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const hasKnownTotal = task.totalBytes > 0;
|
||||
const progress = task.totalBytes > 0 ? Math.min((task.transferredBytes / task.totalBytes) * 100, 100) : 0;
|
||||
// Show indeterminate state when transferring but no real progress received yet
|
||||
const isIndeterminate = task.status === 'transferring' && hasKnownTotal && task.transferredBytes === 0;
|
||||
|
||||
// Calculate remaining time from backend-reported sliding-window speed
|
||||
const remainingBytes = task.totalBytes - task.transferredBytes;
|
||||
const progressMode = task.progressMode ?? 'bytes';
|
||||
const isDirParent = task.isDirectory && !task.parentTaskId && progressMode === 'files';
|
||||
const hasKnownTotal = task.totalBytes > 0 || (!isDirParent && !!task.sourceLastModified);
|
||||
const progress = hasKnownTotal
|
||||
? Math.min((task.transferredBytes / task.totalBytes) * 100, 100)
|
||||
: 0;
|
||||
const isIndeterminate = task.status === 'transferring' && !hasKnownTotal;
|
||||
const effectiveSpeed = task.status === 'transferring'
|
||||
? (Number.isFinite(task.speed) && task.speed > 0 ? task.speed : 0)
|
||||
: 0;
|
||||
const remainingTime = hasKnownTotal && effectiveSpeed > 0
|
||||
? Math.ceil(remainingBytes / effectiveSpeed)
|
||||
: 0;
|
||||
const remainingFormatted = remainingTime > 60
|
||||
? `~${Math.ceil(remainingTime / 60)}m left`
|
||||
: remainingTime > 0
|
||||
? `~${remainingTime}s left`
|
||||
: '';
|
||||
|
||||
// Format bytes transferred / total
|
||||
const bytesDisplay = task.status === 'transferring' && task.totalBytes > 0
|
||||
? `${formatTransferBytes(task.transferredBytes)} / ${formatTransferBytes(task.totalBytes)}`
|
||||
: task.status === 'transferring'
|
||||
? formatTransferBytes(task.transferredBytes)
|
||||
: task.status === 'completed' && task.totalBytes > 0
|
||||
? formatTransferBytes(task.totalBytes)
|
||||
const bytesDisplay = isDirParent
|
||||
? ''
|
||||
: task.status === 'transferring' && hasKnownTotal
|
||||
? `${formatTransferBytes(task.transferredBytes)} / ${formatTransferBytes(task.totalBytes)}`
|
||||
: task.status === 'transferring'
|
||||
? formatTransferBytes(task.transferredBytes)
|
||||
: task.status === 'completed' && hasKnownTotal
|
||||
? formatTransferBytes(task.totalBytes)
|
||||
: '';
|
||||
|
||||
const fileCountDisplay = isDirParent && task.status === 'transferring'
|
||||
? (task.totalBytes > 0
|
||||
? t('sftp.transfers.filesProgress', { current: task.transferredBytes, total: task.totalBytes })
|
||||
: t('sftp.transfers.filesCount', { count: task.transferredBytes }))
|
||||
: isDirParent && task.status === 'completed' && task.totalBytes > 0
|
||||
? t('sftp.transfers.filesCount', { count: task.totalBytes })
|
||||
: '';
|
||||
|
||||
const speedFormatted = effectiveSpeed > 0 ? formatSpeed(effectiveSpeed) : '';
|
||||
const targetDirectoryPath = task.isDirectory ? task.targetPath : getParentPath(task.targetPath);
|
||||
|
||||
const details = (
|
||||
<>
|
||||
<div className="h-5 w-5 rounded flex items-center justify-center shrink-0">
|
||||
{task.status === 'transferring' && <Loader2 size={12} className="animate-spin text-primary" />}
|
||||
{task.status === 'pending' && (task.isDirectory
|
||||
? <FolderUp size={12} className="text-muted-foreground animate-pulse" />
|
||||
: <ArrowDown size={12} className="text-muted-foreground animate-bounce" />
|
||||
)}
|
||||
{task.status === 'completed' && <CheckCircle2 size={12} className="text-green-500" />}
|
||||
{task.status === 'failed' && <XCircle size={12} className="text-destructive" />}
|
||||
{task.status === 'cancelled' && <XCircle size={12} className="text-muted-foreground" />}
|
||||
</div>
|
||||
const progressOverlayText = task.status === 'pending'
|
||||
? t('sftp.task.waiting')
|
||||
: isIndeterminate
|
||||
? t('sftp.transfer.preparing')
|
||||
: isDirParent
|
||||
? (fileCountDisplay
|
||||
? `${fileCountDisplay}${hasKnownTotal ? ` • ${Math.round(progress)}%` : ''}`
|
||||
: hasKnownTotal
|
||||
? `${Math.round(progress)}%`
|
||||
: '...')
|
||||
: bytesDisplay
|
||||
? `${bytesDisplay}${hasKnownTotal ? ` • ${Math.round(progress)}%` : ''}`
|
||||
: hasKnownTotal
|
||||
? `${Math.round(progress)}%`
|
||||
: '...';
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] leading-5 truncate font-medium">{task.fileName}</span>
|
||||
{task.status === 'transferring' && !isIndeterminate && speedFormatted && (
|
||||
<span className="text-[10px] text-primary/80 font-mono transition-opacity duration-300">{speedFormatted}</span>
|
||||
)}
|
||||
{task.status === 'transferring' && !isIndeterminate && remainingFormatted && (
|
||||
<span className="text-[10px] text-muted-foreground transition-opacity duration-300">{remainingFormatted}</span>
|
||||
)}
|
||||
const progressBarWidth = task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal) || isIndeterminate
|
||||
? (task.status === 'pending' || !hasKnownTotal ? '100%' : `${progress}%`)
|
||||
: `${progress}%`;
|
||||
|
||||
const statusIcon = task.status === 'transferring'
|
||||
? <Loader2 size={12} className="animate-spin text-primary" />
|
||||
: task.status === 'pending'
|
||||
? (task.isDirectory
|
||||
? <FolderUp size={12} className="text-muted-foreground animate-pulse" />
|
||||
: <ArrowDown size={12} className="text-muted-foreground animate-bounce" />)
|
||||
: task.status === 'completed'
|
||||
? <CheckCircle2 size={12} className="text-green-500" />
|
||||
: <XCircle size={12} className={task.status === 'failed' ? "text-destructive" : "text-muted-foreground"} />;
|
||||
|
||||
const childProgressBar = (
|
||||
<div className="relative h-full overflow-hidden border border-border/60 bg-secondary/70">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full relative overflow-hidden",
|
||||
task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal)
|
||||
? "bg-muted-foreground/35 animate-pulse"
|
||||
: isIndeterminate
|
||||
? "bg-primary/60 animate-pulse"
|
||||
: task.status === 'completed'
|
||||
? "bg-emerald-500/80"
|
||||
: task.status === 'failed'
|
||||
? "bg-destructive/70"
|
||||
: task.status === 'cancelled'
|
||||
? "bg-muted-foreground/45"
|
||||
: "bg-gradient-to-r from-primary via-primary/90 to-primary"
|
||||
)}
|
||||
style={{
|
||||
width: progressBarWidth,
|
||||
transition: 'width 150ms ease-out',
|
||||
}}
|
||||
>
|
||||
{task.status === 'transferring' && (
|
||||
<div
|
||||
className="absolute inset-0 w-1/2 h-full"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.32) 50%, transparent 100%)',
|
||||
animation: 'progress-shimmer 1.5s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center px-2">
|
||||
<span className="truncate whitespace-nowrap text-[10px] font-medium text-foreground">
|
||||
{progressOverlayText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const progressSummaryText = task.status === 'transferring' || task.status === 'pending'
|
||||
? [speedFormatted, progressOverlayText].filter(Boolean).join(' • ')
|
||||
: '';
|
||||
const showTransferSizeCalculation = task.status === 'transferring' && !hasKnownTotal && !isDirParent;
|
||||
const showFailedError = task.status === 'failed' && !!task.error;
|
||||
const hasFooterContent = showTransferSizeCalculation || showFailedError;
|
||||
|
||||
const actionButtons = (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{task.status === 'failed' && task.retryable !== false && (
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onRetry} title="Retry">
|
||||
<RefreshCw size={12} />
|
||||
</Button>
|
||||
)}
|
||||
{(task.status === 'pending' || task.status === 'transferring') && (
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive hover:text-destructive" onClick={onCancel} title="Cancel">
|
||||
<X size={12} />
|
||||
</Button>
|
||||
)}
|
||||
{(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') && (
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onDismiss} title="Dismiss">
|
||||
<X size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isChild) {
|
||||
return (
|
||||
<div
|
||||
className="grid h-7 items-stretch border-t border-border/20 bg-background/20 px-3"
|
||||
style={{
|
||||
gridTemplateColumns: `24px ${childNameColumnWidth}px 10px minmax(0, 1fr) 24px`,
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
{task.isDirectory ? <FolderUp size={12} /> : <File size={12} />}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center pr-2">
|
||||
<TruncatedTextWithTooltip
|
||||
text={task.fileName}
|
||||
className="min-w-0 text-[11px] font-medium text-foreground/90"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-[9px] mt-0.5 truncate",
|
||||
canRevealTarget ? "text-primary/80" : "text-muted-foreground",
|
||||
)}
|
||||
title={targetDirectoryPath}
|
||||
className="flex h-full cursor-col-resize items-center justify-center text-muted-foreground/35 hover:text-foreground/70"
|
||||
onMouseDown={onResizeNameColumn}
|
||||
title="Resize file name column"
|
||||
>
|
||||
{targetDirectoryPath}
|
||||
<GripVertical size={10} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
{childProgressBar}
|
||||
</div>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
{actionButtons}
|
||||
</div>
|
||||
{(task.status === 'transferring' || task.status === 'pending') && (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="flex-1 h-1.5 bg-secondary/80 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full relative overflow-hidden",
|
||||
task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal)
|
||||
? "bg-muted-foreground/50 animate-pulse"
|
||||
: isIndeterminate
|
||||
? "bg-primary/60 animate-pulse"
|
||||
: "bg-gradient-to-r from-primary via-primary/90 to-primary"
|
||||
)}
|
||||
style={{
|
||||
width: task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal) || isIndeterminate
|
||||
? '100%'
|
||||
: `${progress}%`,
|
||||
transition: 'width 150ms ease-out'
|
||||
}}
|
||||
>
|
||||
{/* Animated shine effect */}
|
||||
{task.status === 'transferring' && (
|
||||
<div
|
||||
className="absolute inset-0 w-1/2 h-full"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.4) 50%, transparent 100%)',
|
||||
animation: 'progress-shimmer 1.5s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground shrink-0 min-w-[34px] text-right font-mono">
|
||||
{task.status === 'pending'
|
||||
? 'waiting...'
|
||||
: isIndeterminate
|
||||
? t('sftp.transfer.preparing')
|
||||
: hasKnownTotal
|
||||
? `${Math.round(progress)}%`
|
||||
: '...'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{task.status === 'transferring' && bytesDisplay && (
|
||||
<div className="text-[9px] text-muted-foreground mt-0.5 font-mono">
|
||||
{bytesDisplay}
|
||||
</div>
|
||||
)}
|
||||
{task.status === 'transferring' && !hasKnownTotal && (
|
||||
<div className="text-[9px] text-muted-foreground mt-0.5">
|
||||
{t('sftp.transfers.calculatingTotal')}
|
||||
</div>
|
||||
)}
|
||||
{task.status === 'completed' && bytesDisplay && (
|
||||
<div className="text-[9px] text-green-600 mt-0.5">
|
||||
Completed - {bytesDisplay}
|
||||
</div>
|
||||
)}
|
||||
{task.status === 'failed' && task.error && (
|
||||
<span className="text-[10px] text-destructive">{task.error}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const showBelowParentProgress = task.status === 'transferring' || task.status === 'pending';
|
||||
|
||||
const titleBlock = (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<TruncatedTextWithTooltip
|
||||
text={task.fileName}
|
||||
className="text-[12px] font-medium leading-5"
|
||||
/>
|
||||
<ArrowRight size={11} className="shrink-0 text-muted-foreground/70" />
|
||||
<TruncatedTextWithTooltip
|
||||
text={targetDirectoryPath}
|
||||
className={cn(
|
||||
"min-w-0 text-[11px]",
|
||||
canRevealTarget ? "text-primary/80" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
{canToggleChildren && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded border border-border/60 bg-secondary/60 px-1.5 py-0.5 text-[10px] text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
onClick={onToggleChildren}
|
||||
title={isExpanded ? t('sftp.transfers.collapseChildList') : t('sftp.transfers.expandChildList')}
|
||||
>
|
||||
{isExpanded ? t('sftp.transfers.collapseChildList') : t('sftp.transfers.expandChildList')}
|
||||
{isExpanded ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 px-3 py-2 bg-background/60 border-t border-border/40 backdrop-blur-sm">
|
||||
{canRevealTarget && onRevealTarget ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 min-w-0 items-center gap-2.5 rounded-sm text-left transition-colors hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
|
||||
onClick={onRevealTarget}
|
||||
title="Open transfer destination"
|
||||
>
|
||||
{details}
|
||||
</button>
|
||||
) : (
|
||||
details
|
||||
<div className="border-t border-border/40 bg-background/60 px-3 py-2.5 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex h-5 w-5 items-center justify-center shrink-0 -translate-y-px">
|
||||
{statusIcon}
|
||||
</div>
|
||||
|
||||
{canRevealTarget && onRevealTarget ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 rounded-sm text-left transition-colors hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
|
||||
onClick={onRevealTarget}
|
||||
>
|
||||
{titleBlock}
|
||||
</button>
|
||||
) : (
|
||||
<div className="min-w-0 flex-1">
|
||||
{titleBlock}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progressSummaryText && (
|
||||
<span className="ml-auto shrink-0 whitespace-nowrap text-[10px] text-muted-foreground font-mono">
|
||||
{progressSummaryText}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{actionButtons}
|
||||
</div>
|
||||
|
||||
{showBelowParentProgress && (
|
||||
<div className="mt-2 ml-7">
|
||||
<div className="h-1.5 overflow-hidden bg-secondary/80">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full relative overflow-hidden",
|
||||
task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal)
|
||||
? "bg-muted-foreground/50 animate-pulse"
|
||||
: isIndeterminate
|
||||
? "bg-primary/60 animate-pulse"
|
||||
: "bg-gradient-to-r from-primary via-primary/90 to-primary",
|
||||
)}
|
||||
style={{
|
||||
width: progressBarWidth,
|
||||
transition: 'width 150ms ease-out',
|
||||
}}
|
||||
>
|
||||
{task.status === 'transferring' && (
|
||||
<div
|
||||
className="absolute inset-0 w-1/2 h-full"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.32) 50%, transparent 100%)',
|
||||
animation: 'progress-shimmer 1.5s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{task.status === 'failed' && task.retryable !== false && (
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onRetry} title="Retry">
|
||||
<RefreshCw size={12} />
|
||||
</Button>
|
||||
{hasFooterContent && (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px]">
|
||||
{showTransferSizeCalculation && (
|
||||
<span className="text-muted-foreground">{t('sftp.transfers.calculatingTotal')}</span>
|
||||
)}
|
||||
{(task.status === 'pending' || task.status === 'transferring') && (
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive hover:text-destructive" onClick={onCancel} title="Cancel">
|
||||
<X size={12} />
|
||||
</Button>
|
||||
{showFailedError && (
|
||||
<span className="text-destructive">{task.error}</span>
|
||||
)}
|
||||
{(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') && (
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onDismiss} title="Dismiss">
|
||||
<X size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom comparison function to reduce unnecessary re-renders
|
||||
// Only re-render if meaningful values change
|
||||
const arePropsEqual = (
|
||||
prevProps: SftpTransferItemProps,
|
||||
nextProps: SftpTransferItemProps
|
||||
nextProps: SftpTransferItemProps,
|
||||
): boolean => {
|
||||
const prev = prevProps.task;
|
||||
const next = nextProps.task;
|
||||
|
||||
// Always re-render on status change
|
||||
if (prev.status !== next.status) return false;
|
||||
|
||||
// Always re-render on error change
|
||||
if (prev.error !== next.error) return false;
|
||||
|
||||
// Always re-render on fileName change
|
||||
if (prev.fileName !== next.fileName) return false;
|
||||
if (prev.targetPath !== next.targetPath) return false;
|
||||
if (prev.totalBytes !== next.totalBytes) return false;
|
||||
if ((prevProps.canRevealTarget ?? false) !== (nextProps.canRevealTarget ?? false)) return false;
|
||||
if ((prevProps.isChild ?? false) !== (nextProps.isChild ?? false)) return false;
|
||||
if ((prevProps.childNameColumnWidth ?? 260) !== (nextProps.childNameColumnWidth ?? 260)) return false;
|
||||
if ((prevProps.canToggleChildren ?? false) !== (nextProps.canToggleChildren ?? false)) return false;
|
||||
if ((prevProps.isExpanded ?? false) !== (nextProps.isExpanded ?? false)) return false;
|
||||
if ((prevProps.visibleChildCount ?? 0) !== (nextProps.visibleChildCount ?? 0)) return false;
|
||||
|
||||
// For transferring status, allow frequent re-renders for smooth progress bar
|
||||
if (next.status === 'transferring') {
|
||||
if (next.totalBytes <= 0 && prev.transferredBytes !== next.transferredBytes) return false;
|
||||
|
||||
// Re-render on any meaningful progress change (0.1% for smooth bar animation)
|
||||
const prevProgress = prev.totalBytes > 0 ? (prev.transferredBytes / prev.totalBytes) * 100 : 0;
|
||||
const nextProgress = next.totalBytes > 0 ? (next.transferredBytes / next.totalBytes) * 100 : 0;
|
||||
if (Math.abs(nextProgress - prevProgress) >= 0.1) return false;
|
||||
|
||||
// Re-render on any speed change (backend already smooths via sliding window)
|
||||
if (next.speed !== prev.speed) return false;
|
||||
}
|
||||
|
||||
// For pending status, don't re-render unless status changes
|
||||
if (next.status === 'pending') {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import React from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import { GripHorizontal } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { useStoredNumber } from "../../application/state/useStoredNumber";
|
||||
import type { useSftpState } from "../../application/state/useSftpState";
|
||||
import {
|
||||
STORAGE_KEY_SFTP_TRANSFER_CHILD_NAME_WIDTH,
|
||||
STORAGE_KEY_SFTP_TRANSFER_PANEL_HEIGHT,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
import type { TransferTask } from "../../types";
|
||||
import { Button } from "../ui/button";
|
||||
import { SftpTransferItem } from "./SftpTransferItem";
|
||||
|
||||
type SftpState = ReturnType<typeof useSftpState>;
|
||||
@@ -10,25 +16,327 @@ type SftpState = ReturnType<typeof useSftpState>;
|
||||
interface SftpTransferQueueProps {
|
||||
sftp: SftpState;
|
||||
visibleTransfers: SftpState["transfers"];
|
||||
allTransfers: SftpState["transfers"];
|
||||
canRevealTransferTarget?: (task: TransferTask) => boolean;
|
||||
onRevealTransferTarget?: (task: TransferTask) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const MIN_PANEL_HEIGHT = 112;
|
||||
const MAX_PANEL_HEIGHT = 480;
|
||||
const HEADER_HEIGHT = 42;
|
||||
const MIN_CHILD_NAME_WIDTH = 160;
|
||||
const MAX_CHILD_NAME_WIDTH = 480;
|
||||
const CHILD_ROW_HEIGHT = 28;
|
||||
const CHILD_VIRTUALIZE_THRESHOLD = 80;
|
||||
const CHILD_OVERSCAN = 8;
|
||||
|
||||
interface TransferChildListProps {
|
||||
childTasks: TransferTask[];
|
||||
childNameWidth: number;
|
||||
onResizeNameColumn: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement>;
|
||||
scrollTop: number;
|
||||
viewportHeight: number;
|
||||
onCancel: (taskId: string) => void;
|
||||
onRetry: (taskId: string) => Promise<void>;
|
||||
onDismiss: (taskId: string) => void;
|
||||
}
|
||||
|
||||
const TransferChildList: React.FC<TransferChildListProps> = ({
|
||||
childTasks,
|
||||
childNameWidth,
|
||||
onResizeNameColumn,
|
||||
scrollContainerRef,
|
||||
scrollTop,
|
||||
viewportHeight,
|
||||
onCancel,
|
||||
onRetry,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [contentTop, setContentTop] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!container || !scrollContainer) return;
|
||||
|
||||
const nextTop =
|
||||
container.getBoundingClientRect().top -
|
||||
scrollContainer.getBoundingClientRect().top +
|
||||
scrollTop;
|
||||
|
||||
if (Math.abs(nextTop - contentTop) > 1) {
|
||||
setContentTop(nextTop);
|
||||
}
|
||||
}, [childTasks.length, contentTop, scrollContainerRef, scrollTop, viewportHeight]);
|
||||
|
||||
const needsVirtualization = childTasks.length > CHILD_VIRTUALIZE_THRESHOLD;
|
||||
// Use a fallback viewport height when not yet measured to avoid rendering
|
||||
// all children on the first frame. This caps the initial render to ~15 rows
|
||||
// instead of potentially thousands.
|
||||
const effectiveViewportHeight = viewportHeight > 0 ? viewportHeight : MAX_PANEL_HEIGHT;
|
||||
const shouldVirtualize = needsVirtualization;
|
||||
|
||||
const { startIndex, visibleTasks } = useMemo(() => {
|
||||
if (!shouldVirtualize) {
|
||||
return {
|
||||
startIndex: 0,
|
||||
visibleTasks: childTasks,
|
||||
};
|
||||
}
|
||||
|
||||
const relativeTop = Math.max(0, scrollTop - contentTop);
|
||||
const relativeBottom = Math.max(0, scrollTop + effectiveViewportHeight - contentTop);
|
||||
const start = Math.max(0, Math.floor(relativeTop / CHILD_ROW_HEIGHT) - CHILD_OVERSCAN);
|
||||
const end = Math.min(
|
||||
childTasks.length - 1,
|
||||
Math.ceil(relativeBottom / CHILD_ROW_HEIGHT) + CHILD_OVERSCAN,
|
||||
);
|
||||
|
||||
return {
|
||||
startIndex: start,
|
||||
visibleTasks: childTasks.slice(start, end + 1),
|
||||
};
|
||||
}, [childTasks, contentTop, effectiveViewportHeight, scrollTop, shouldVirtualize]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="border-t border-border/30 bg-background/30"
|
||||
>
|
||||
<div
|
||||
className={shouldVirtualize ? "relative" : undefined}
|
||||
style={shouldVirtualize ? { height: childTasks.length * CHILD_ROW_HEIGHT } : undefined}
|
||||
>
|
||||
{visibleTasks.map((child, visibleIndex) => {
|
||||
const index = shouldVirtualize ? startIndex + visibleIndex : visibleIndex;
|
||||
return (
|
||||
<div
|
||||
key={child.id}
|
||||
className={shouldVirtualize ? "absolute left-0 right-0" : undefined}
|
||||
style={shouldVirtualize ? { top: index * CHILD_ROW_HEIGHT } : undefined}
|
||||
>
|
||||
<SftpTransferItem
|
||||
task={child}
|
||||
isChild
|
||||
childNameColumnWidth={childNameWidth}
|
||||
onResizeNameColumn={onResizeNameColumn}
|
||||
onCancel={() => onCancel(child.id)}
|
||||
onRetry={() => onRetry(child.id)}
|
||||
onDismiss={() => onDismiss(child.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
|
||||
sftp,
|
||||
visibleTransfers,
|
||||
allTransfers,
|
||||
canRevealTransferTarget,
|
||||
onRevealTransferTarget,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [expandedParents, setExpandedParents] = useState<Record<string, boolean>>({});
|
||||
const [panelHeight, setPanelHeight, persistPanelHeight] = useStoredNumber(
|
||||
STORAGE_KEY_SFTP_TRANSFER_PANEL_HEIGHT,
|
||||
220,
|
||||
{ min: MIN_PANEL_HEIGHT, max: MAX_PANEL_HEIGHT },
|
||||
);
|
||||
const [childNameWidth, setChildNameWidth, persistChildNameWidth] = useStoredNumber(
|
||||
STORAGE_KEY_SFTP_TRANSFER_CHILD_NAME_WIDTH,
|
||||
260,
|
||||
{ min: MIN_CHILD_NAME_WIDTH, max: MAX_CHILD_NAME_WIDTH },
|
||||
);
|
||||
const panelHeightRef = useRef(panelHeight);
|
||||
const childNameWidthRef = useRef(childNameWidth);
|
||||
const dragStateRef = useRef<{ startY: number; startHeight: number } | null>(null);
|
||||
const childColumnDragRef = useRef<{ startX: number; startWidth: number } | null>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
const scrollFrameRef = useRef<number | null>(null);
|
||||
|
||||
if (sftp.transfers.length === 0) {
|
||||
panelHeightRef.current = panelHeight;
|
||||
childNameWidthRef.current = childNameWidth;
|
||||
|
||||
const childrenByParent = useMemo(() => {
|
||||
const map = new Map<string, TransferTask[]>();
|
||||
for (const task of allTransfers) {
|
||||
if (task.parentTaskId && task.status !== "cancelled") {
|
||||
const children = map.get(task.parentTaskId) || [];
|
||||
children.push(task);
|
||||
map.set(task.parentTaskId, children);
|
||||
}
|
||||
}
|
||||
for (const [parentId, children] of map) {
|
||||
map.set(
|
||||
parentId,
|
||||
[...children].sort((a, b) => b.startTime - a.startTime),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}, [allTransfers]);
|
||||
|
||||
const topLevelTransfers = useMemo(
|
||||
() => visibleTransfers.filter((task) => !task.parentTaskId),
|
||||
[visibleTransfers],
|
||||
);
|
||||
|
||||
const clampPanelHeight = useCallback((height: number) => {
|
||||
if (typeof window === "undefined") {
|
||||
return Math.max(MIN_PANEL_HEIGHT, Math.min(MAX_PANEL_HEIGHT, height));
|
||||
}
|
||||
const viewportMax = Math.floor(window.innerHeight * 0.6);
|
||||
return Math.max(MIN_PANEL_HEIGHT, Math.min(Math.min(MAX_PANEL_HEIGHT, viewportMax), height));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedParents((prev) => {
|
||||
const next: Record<string, boolean> = {};
|
||||
let changed = false;
|
||||
|
||||
for (const task of topLevelTransfers) {
|
||||
const hasChildren = (childrenByParent.get(task.id)?.length ?? 0) > 0;
|
||||
if (!hasChildren) continue;
|
||||
next[task.id] = prev[task.id] ?? true;
|
||||
if (next[task.id] !== prev[task.id]) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed && Object.keys(prev).length === Object.keys(next).length) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}, [childrenByParent, topLevelTransfers]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const updateViewport = () => setViewportHeight(scrollContainer.clientHeight);
|
||||
updateViewport();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateViewport);
|
||||
resizeObserver.observe(scrollContainer);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(scrollFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (dragStateRef.current) {
|
||||
const deltaY = dragStateRef.current.startY - event.clientY;
|
||||
setPanelHeight(clampPanelHeight(dragStateRef.current.startHeight + deltaY));
|
||||
}
|
||||
if (childColumnDragRef.current) {
|
||||
const deltaX = event.clientX - childColumnDragRef.current.startX;
|
||||
const nextWidth = Math.max(
|
||||
MIN_CHILD_NAME_WIDTH,
|
||||
Math.min(MAX_CHILD_NAME_WIDTH, childColumnDragRef.current.startWidth + deltaX),
|
||||
);
|
||||
setChildNameWidth(nextWidth);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
const hadPanelDrag = !!dragStateRef.current;
|
||||
const hadChildColumnDrag = !!childColumnDragRef.current;
|
||||
dragStateRef.current = null;
|
||||
childColumnDragRef.current = null;
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
if (hadPanelDrag) {
|
||||
persistPanelHeight(panelHeightRef.current);
|
||||
}
|
||||
if (hadChildColumnDrag) {
|
||||
persistChildNameWidth(childNameWidthRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
}, [clampPanelHeight, panelHeight, persistChildNameWidth, persistPanelHeight, setChildNameWidth, setPanelHeight]);
|
||||
|
||||
const handleResizeStart = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||
dragStateRef.current = {
|
||||
startY: event.clientY,
|
||||
startHeight: panelHeight,
|
||||
};
|
||||
document.body.style.cursor = "row-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}, [panelHeight]);
|
||||
|
||||
const handleChildColumnResizeStart = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
childColumnDragRef.current = {
|
||||
startX: event.clientX,
|
||||
startWidth: childNameWidth,
|
||||
};
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}, [childNameWidth]);
|
||||
|
||||
const toggleExpanded = useCallback((taskId: string) => {
|
||||
setExpandedParents((prev) => ({
|
||||
...prev,
|
||||
[taskId]: !(prev[taskId] ?? true),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||
const nextTop = event.currentTarget.scrollTop;
|
||||
if (scrollFrameRef.current !== null) return;
|
||||
scrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||
scrollFrameRef.current = null;
|
||||
setScrollTop(nextTop);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (topLevelTransfers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-border/70 bg-secondary/80 backdrop-blur-sm shrink-0">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 text-[11px] text-muted-foreground border-b border-border/40">
|
||||
<div
|
||||
className="border-t border-border/70 bg-secondary/80 backdrop-blur-sm shrink-0"
|
||||
style={{ height: clampPanelHeight(panelHeight) }}
|
||||
>
|
||||
<div
|
||||
className="group flex h-3 cursor-row-resize items-center justify-center border-b border-border/30 text-muted-foreground/70"
|
||||
onMouseDown={handleResizeStart}
|
||||
title={t("sftp.transfers.dragToResize")}
|
||||
>
|
||||
<GripHorizontal size={14} className="transition-colors group-hover:text-foreground/80" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-b border-border/40 px-3 py-1.5 text-[11px] text-muted-foreground">
|
||||
<span className="font-medium">
|
||||
{t("sftp.transfers")}
|
||||
{sftp.activeTransfersCount > 0 && (
|
||||
@@ -37,8 +345,9 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{sftp.transfers.some(
|
||||
(tr) => tr.status === "completed" || tr.status === "cancelled",
|
||||
(transfer) => transfer.status === "completed" || transfer.status === "cancelled",
|
||||
) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -50,29 +359,59 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-40 overflow-auto">
|
||||
{visibleTransfers.map((task) => (
|
||||
<SftpTransferItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
onCancel={() => {
|
||||
if (task.sourceConnectionId === "external") {
|
||||
sftp.cancelExternalUpload();
|
||||
}
|
||||
sftp.cancelTransfer(task.id);
|
||||
}}
|
||||
onRetry={() => sftp.retryTransfer(task.id)}
|
||||
onDismiss={() => sftp.dismissTransfer(task.id)}
|
||||
canRevealTarget={canRevealTransferTarget?.(task) ?? false}
|
||||
onRevealTarget={
|
||||
onRevealTransferTarget
|
||||
? () => {
|
||||
void onRevealTransferTarget(task);
|
||||
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="overflow-auto"
|
||||
style={{ height: `calc(100% - ${HEADER_HEIGHT}px)` }}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{topLevelTransfers.map((task) => {
|
||||
const childTasks = childrenByParent.get(task.id) ?? [];
|
||||
const isExpanded = expandedParents[task.id] ?? true;
|
||||
|
||||
return (
|
||||
<React.Fragment key={task.id}>
|
||||
<SftpTransferItem
|
||||
task={task}
|
||||
canToggleChildren={childTasks.length > 0}
|
||||
isExpanded={isExpanded}
|
||||
visibleChildCount={childTasks.length}
|
||||
onToggleChildren={() => toggleExpanded(task.id)}
|
||||
onCancel={() => {
|
||||
if (task.sourceConnectionId === "external") {
|
||||
sftp.cancelExternalUpload();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
sftp.cancelTransfer(task.id);
|
||||
}}
|
||||
onRetry={() => sftp.retryTransfer(task.id)}
|
||||
onDismiss={() => sftp.dismissTransfer(task.id)}
|
||||
canRevealTarget={canRevealTransferTarget?.(task) ?? false}
|
||||
onRevealTarget={
|
||||
onRevealTransferTarget
|
||||
? () => {
|
||||
void onRevealTransferTarget(task);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{isExpanded && childTasks.length > 0 && (
|
||||
<TransferChildList
|
||||
childTasks={childTasks}
|
||||
childNameWidth={childNameWidth}
|
||||
onResizeNameColumn={handleChildColumnResizeStart}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
scrollTop={scrollTop}
|
||||
viewportHeight={viewportHeight}
|
||||
onCancel={(taskId) => sftp.cancelTransfer(taskId)}
|
||||
onRetry={(taskId) => sftp.retryTransfer(taskId)}
|
||||
onDismiss={(taskId) => sftp.dismissTransfer(taskId)}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
37
components/sftp/hooks/selectionScope.ts
Normal file
37
components/sftp/hooks/selectionScope.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import { sftpTreeSelectionStore } from "./useSftpTreeSelectionStore";
|
||||
|
||||
export interface SftpSelectionTarget {
|
||||
side: "left" | "right";
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
export const keepOnlyPaneSelections = (
|
||||
sftp: SftpStateApi,
|
||||
target: SftpSelectionTarget | null,
|
||||
) => {
|
||||
sftp.clearSelectionsExcept(target);
|
||||
const paneIds = [
|
||||
...sftp.leftTabs.tabs.map((tab) => tab.id),
|
||||
...sftp.rightTabs.tabs.map((tab) => tab.id),
|
||||
];
|
||||
for (const paneId of paneIds) {
|
||||
if (target?.tabId === paneId) continue;
|
||||
sftpTreeSelectionStore.clearSelection(paneId);
|
||||
}
|
||||
};
|
||||
|
||||
export const keepOnlyActivePaneSelections = (
|
||||
sftp: SftpStateApi,
|
||||
side: "left" | "right",
|
||||
): SftpSelectionTarget | null => {
|
||||
const tabId = sftp.getActiveTabId(side);
|
||||
if (!tabId) {
|
||||
keepOnlyPaneSelections(sftp, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = { side, tabId } as const;
|
||||
keepOnlyPaneSelections(sftp, target);
|
||||
return target;
|
||||
};
|
||||
83
components/sftp/hooks/useGlobalSftpBookmarks.ts
Normal file
83
components/sftp/hooks/useGlobalSftpBookmarks.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import type { SftpBookmark } from "../../../domain/models";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
|
||||
|
||||
type Listener = () => void;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
let snapshot: SftpBookmark[] =
|
||||
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
|
||||
|
||||
function subscribe(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Re-read bookmarks from localStorage (e.g. after cloud sync import). */
|
||||
export function rehydrateGlobalBookmarks() {
|
||||
snapshot = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
// Rehydrate when another window updates the same localStorage key
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (e.key === STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) {
|
||||
rehydrateGlobalBookmarks();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setBookmarks(next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[])) {
|
||||
snapshot = typeof next === "function" ? next(snapshot) : next;
|
||||
localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, snapshot);
|
||||
for (const l of listeners) l();
|
||||
window.dispatchEvent(new CustomEvent('sftp-bookmarks-changed'));
|
||||
}
|
||||
|
||||
interface UseGlobalSftpBookmarksParams {
|
||||
currentPath: string | undefined;
|
||||
}
|
||||
|
||||
export const useGlobalSftpBookmarks = ({
|
||||
currentPath,
|
||||
}: UseGlobalSftpBookmarksParams) => {
|
||||
const bookmarks = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
|
||||
const isCurrentPathBookmarked = useMemo(
|
||||
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
|
||||
[currentPath, bookmarks],
|
||||
);
|
||||
|
||||
const addBookmark = useCallback((path: string) => {
|
||||
if (!path) return;
|
||||
if (bookmarks.some((b) => b.path === path)) return;
|
||||
const isRoot = path === "/" || /^[A-Za-z]:\\?$/.test(path);
|
||||
const label = isRoot
|
||||
? path
|
||||
: path.split(/[\\/]/).filter(Boolean).pop() || path;
|
||||
const newBookmark: SftpBookmark = {
|
||||
id: `gbm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
path,
|
||||
label,
|
||||
global: true,
|
||||
};
|
||||
setBookmarks((prev) => [...prev, newBookmark]);
|
||||
}, [bookmarks]);
|
||||
|
||||
const deleteBookmark = useCallback((id: string) => {
|
||||
setBookmarks((prev) => prev.filter((b) => b.id !== id));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
addBookmark,
|
||||
deleteBookmark,
|
||||
};
|
||||
};
|
||||
@@ -13,6 +13,7 @@ type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null
|
||||
interface SftpDialogAction {
|
||||
type: SftpDialogActionType;
|
||||
targetSide: SftpFocusedSide;
|
||||
targetScopeId: string;
|
||||
targetFiles?: string[]; // For rename (single file) or delete (multiple files)
|
||||
timestamp: number; // To distinguish different triggers of the same action
|
||||
}
|
||||
@@ -37,13 +38,14 @@ export const sftpDialogActionStore = {
|
||||
/**
|
||||
* Trigger a dialog action
|
||||
*/
|
||||
trigger: (type: SftpDialogActionType, targetFiles?: string[]) => {
|
||||
trigger: (type: SftpDialogActionType, targetScopeId: string, targetFiles?: string[]) => {
|
||||
if (!type) {
|
||||
dialogAction = null;
|
||||
} else {
|
||||
dialogAction = {
|
||||
type,
|
||||
targetSide: sftpFocusStore.getFocusedSide(),
|
||||
targetScopeId,
|
||||
targetFiles,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
@@ -82,17 +84,19 @@ export const useSftpDialogAction = (): SftpDialogAction | null => {
|
||||
*/
|
||||
export const useSftpDialogActionHandler = (
|
||||
side: SftpFocusedSide,
|
||||
scopeId: string,
|
||||
handlers: {
|
||||
onRename?: (fileName: string) => void;
|
||||
onDelete?: (fileNames: string[]) => void;
|
||||
onNewFolder?: () => void;
|
||||
onNewFile?: () => void;
|
||||
}
|
||||
},
|
||||
isActive = true
|
||||
) => {
|
||||
const action = useSftpDialogAction();
|
||||
|
||||
useEffect(() => {
|
||||
if (!action || action.targetSide !== side) return;
|
||||
if (!action || action.targetSide !== side || action.targetScopeId !== scopeId || !isActive) return;
|
||||
|
||||
// Handle the action and clear it
|
||||
switch (action.type) {
|
||||
@@ -116,5 +120,5 @@ export const useSftpDialogActionHandler = (
|
||||
|
||||
// Clear the action after handling
|
||||
sftpDialogActionStore.clear();
|
||||
}, [action, side, handlers]);
|
||||
}, [action, side, scopeId, handlers, isActive]);
|
||||
};
|
||||
|
||||
70
components/sftp/hooks/useSftpHostViewMode.ts
Normal file
70
components/sftp/hooks/useSftpHostViewMode.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { STORAGE_KEY_SFTP_HOST_VIEW_MODES } from "../../../infrastructure/config/storageKeys";
|
||||
|
||||
// ── Shared external store for per-host SFTP view mode preferences ──
|
||||
|
||||
type ViewMode = 'list' | 'tree';
|
||||
type Listener = () => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
let snapshot: Record<string, ViewMode> =
|
||||
localStorageAdapter.read<Record<string, ViewMode>>(STORAGE_KEY_SFTP_HOST_VIEW_MODES) ?? {};
|
||||
|
||||
function subscribe(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function persist(next: Record<string, ViewMode>) {
|
||||
snapshot = next;
|
||||
localStorageAdapter.write(STORAGE_KEY_SFTP_HOST_VIEW_MODES, snapshot);
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
// Sync across windows/tabs via storage events
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (e.key !== STORAGE_KEY_SFTP_HOST_VIEW_MODES) return;
|
||||
try {
|
||||
snapshot = e.newValue
|
||||
? (JSON.parse(e.newValue) as Record<string, ViewMode>)
|
||||
: {};
|
||||
} catch {
|
||||
snapshot = {};
|
||||
}
|
||||
for (const l of listeners) l();
|
||||
});
|
||||
}
|
||||
|
||||
/** Get the saved view mode for a specific host, or null if none saved. */
|
||||
export function getHostViewMode(hostId: string): ViewMode | null {
|
||||
return snapshot[hostId] ?? null;
|
||||
}
|
||||
|
||||
/** Save the view mode preference for a specific host. */
|
||||
export function setHostViewMode(hostId: string, mode: ViewMode): void {
|
||||
if (snapshot[hostId] === mode) return;
|
||||
persist({ ...snapshot, [hostId]: mode });
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
|
||||
export function useSftpHostViewMode(hostId: string | undefined) {
|
||||
const store = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
|
||||
const mode: ViewMode | null = hostId ? (store[hostId] ?? null) : null;
|
||||
|
||||
const setMode = useCallback((newMode: ViewMode) => {
|
||||
if (hostId) {
|
||||
setHostViewMode(hostId, newMode);
|
||||
}
|
||||
}, [hostId]);
|
||||
|
||||
return { hostViewMode: mode, setHostViewMode: setMode };
|
||||
}
|
||||
@@ -8,11 +8,16 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { KeyBinding, matchesKeyBinding } from "../../../domain/models";
|
||||
import { getParentPath, joinPath } from "../../../application/state/sftp/utils";
|
||||
import { sftpClipboardStore, SftpClipboardFile } from "./useSftpClipboard";
|
||||
import { sftpFocusStore } from "./useSftpFocusedPane";
|
||||
import { sftpDialogActionStore } from "./useSftpDialogAction";
|
||||
import { sftpTreeSelectionStore } from "./useSftpTreeSelectionStore";
|
||||
import { sftpListOrderStore } from "./useSftpListOrderStore";
|
||||
import { keepOnlyPaneSelections } from "./selectionScope";
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import { filterHiddenFiles, isNavigableDirectory } from "../index";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
// SFTP action names that we handle
|
||||
@@ -25,12 +30,70 @@ const SFTP_ACTIONS = new Set([
|
||||
"sftpDelete",
|
||||
"sftpRefresh",
|
||||
"sftpNewFolder",
|
||||
"sftpOpen",
|
||||
"sftpGoParent",
|
||||
"sftpNavigateTo",
|
||||
]);
|
||||
|
||||
// ── Tree Enter key action store ──────────────────────────────────────
|
||||
// Allows the keyboard shortcut hook to signal tree views to handle Enter.
|
||||
|
||||
type TreeEnterListener = () => void;
|
||||
|
||||
interface TreeEnterAction {
|
||||
paneId: string;
|
||||
entryPath: string;
|
||||
isDirectory: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
let _treeEnterAction: TreeEnterAction | null = null;
|
||||
const _treeEnterListeners = new Set<TreeEnterListener>();
|
||||
const notifyTreeEnterListeners = () => _treeEnterListeners.forEach((l) => l());
|
||||
|
||||
export const sftpTreeEnterStore = {
|
||||
trigger: (paneId: string, entryPath: string, isDirectory: boolean) => {
|
||||
_treeEnterAction = { paneId, entryPath, isDirectory, timestamp: Date.now() };
|
||||
notifyTreeEnterListeners();
|
||||
},
|
||||
get: () => _treeEnterAction,
|
||||
clear: () => {
|
||||
_treeEnterAction = null;
|
||||
notifyTreeEnterListeners();
|
||||
},
|
||||
subscribe: (listener: TreeEnterListener) => {
|
||||
_treeEnterListeners.add(listener);
|
||||
return () => { _treeEnterListeners.delete(listener); };
|
||||
},
|
||||
getSnapshot: () => _treeEnterAction,
|
||||
};
|
||||
|
||||
// ── Keyboard selection anchor/focus tracking ────────────────────────
|
||||
// Tracks the anchor (where Shift-selection started) and focus (cursor)
|
||||
// indices per pane so Shift+Arrow extends correctly.
|
||||
const _kbSelectionState = new Map<string, { anchor: number; focus: number }>();
|
||||
|
||||
export const sftpKeyboardSelectionStore = {
|
||||
get: (paneId: string) => _kbSelectionState.get(paneId) ?? { anchor: 0, focus: 0 },
|
||||
set: (paneId: string, anchor: number, focus: number) => {
|
||||
_kbSelectionState.set(paneId, { anchor, focus });
|
||||
},
|
||||
clear: (paneId: string) => {
|
||||
_kbSelectionState.delete(paneId);
|
||||
},
|
||||
};
|
||||
|
||||
// Basic navigation keys that work even when custom hotkeys are disabled.
|
||||
const BASIC_NAV_KEYS: Record<string, string> = {
|
||||
'Enter': 'sftpOpen',
|
||||
'Backspace': 'sftpGoParent',
|
||||
};
|
||||
|
||||
interface UseSftpKeyboardShortcutsParams {
|
||||
keyBindings: KeyBinding[];
|
||||
hotkeyScheme: "disabled" | "mac" | "pc";
|
||||
sftpRef: MutableRefObject<SftpStateApi>;
|
||||
dialogActionScopeId: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
@@ -56,12 +119,14 @@ export const useSftpKeyboardShortcuts = ({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
sftpRef,
|
||||
dialogActionScopeId,
|
||||
isActive,
|
||||
}: UseSftpKeyboardShortcutsParams) => {
|
||||
const handleKeyDown = useCallback(
|
||||
async (e: KeyboardEvent) => {
|
||||
// Skip if shortcuts are disabled or SFTP is not active
|
||||
if (hotkeyScheme === "disabled" || !isActive) return;
|
||||
// Basic SFTP keyboard navigation should work whenever the SFTP tab is active,
|
||||
// even if the user has disabled global/custom hotkeys.
|
||||
if (!isActive) return;
|
||||
|
||||
// Skip if focus is on an input element
|
||||
const target = e.target as HTMLElement;
|
||||
@@ -74,12 +139,126 @@ export const useSftpKeyboardShortcuts = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const isMac = hotkeyScheme === "mac";
|
||||
const matched = matchSftpAction(e, keyBindings, isMac);
|
||||
if (!matched) return;
|
||||
// Skip when a dialog or overlay is open to prevent SFTP shortcuts from
|
||||
// firing while interacting with unrelated dialogs (e.g. settings, confirm).
|
||||
if (document.querySelector('[role="dialog"][data-state="open"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { action } = matched;
|
||||
if (!SFTP_ACTIONS.has(action)) return;
|
||||
// ── Arrow Up/Down: move selection ────────────────────────────────
|
||||
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
const sftp = sftpRef.current;
|
||||
const focusedSide = sftpFocusStore.getFocusedSide();
|
||||
const pane = focusedSide === "left"
|
||||
? sftp.leftTabs.tabs.find(p => p.id === sftp.leftTabs.activeTabId)
|
||||
: sftp.rightTabs.tabs.find(p => p.id === sftp.rightTabs.activeTabId);
|
||||
if (!pane || !pane.connection) return;
|
||||
|
||||
const delta = e.key === 'ArrowDown' ? 1 : -1;
|
||||
|
||||
// List view: navigate sorted display files.
|
||||
// Prefer the list store when it exists so stale tree selection state
|
||||
// cannot swallow keyboard navigation after switching views.
|
||||
const listItems = sftpListOrderStore.getItems(pane.id);
|
||||
if (listItems.length > 0) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Resolve current focus position from tracked state, falling back
|
||||
// to the actual selection when out of sync (e.g. after mouse click).
|
||||
let { anchor: anchorIdx, focus: focusIdx } = sftpKeyboardSelectionStore.get(pane.id);
|
||||
const currentSelected = Array.from(pane.selectedFiles) as string[];
|
||||
if (currentSelected.length === 0) {
|
||||
// No selection: start from before the list so the first arrow press lands on item 0.
|
||||
// For Shift+Arrow, anchor at 0 so range selection starts from the first item.
|
||||
anchorIdx = e.shiftKey ? 0 : -1;
|
||||
focusIdx = -1;
|
||||
} else if (!currentSelected.includes(listItems[focusIdx])) {
|
||||
// Tracked focus doesn't match actual selection, re-sync
|
||||
focusIdx = listItems.indexOf(currentSelected[currentSelected.length - 1]);
|
||||
if (focusIdx < 0) focusIdx = 0;
|
||||
anchorIdx = focusIdx;
|
||||
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, focusIdx);
|
||||
}
|
||||
|
||||
let nextIdx = focusIdx + delta;
|
||||
if (nextIdx < 0) nextIdx = 0;
|
||||
if (nextIdx >= listItems.length) nextIdx = listItems.length - 1;
|
||||
|
||||
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
|
||||
if (e.shiftKey) {
|
||||
// Shift+Arrow: extend range from anchor to new focus
|
||||
const start = Math.min(anchorIdx, nextIdx);
|
||||
const end = Math.max(anchorIdx, nextIdx);
|
||||
sftp.rangeSelect(focusedSide, listItems.slice(start, end + 1));
|
||||
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, nextIdx);
|
||||
} else {
|
||||
sftp.rangeSelect(focusedSide, [listItems[nextIdx]]);
|
||||
sftpKeyboardSelectionStore.set(pane.id, nextIdx, nextIdx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Tree view: navigate visible items
|
||||
const treeState = sftpTreeSelectionStore.getPaneState(pane.id);
|
||||
if (treeState.visibleItems.length > 0) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const items = treeState.visibleItems;
|
||||
const currentSelected = [...treeState.selectedPaths];
|
||||
|
||||
// Use tracked state, re-sync if needed
|
||||
let { anchor: anchorIdx, focus: focusIdx } = sftpKeyboardSelectionStore.get(pane.id);
|
||||
if (currentSelected.length === 0) {
|
||||
// No selection: start from before the list so the first arrow press lands on item 0.
|
||||
// For Shift+Arrow, anchor at 0 so range selection starts from the first item.
|
||||
anchorIdx = e.shiftKey ? 0 : -1;
|
||||
focusIdx = -1;
|
||||
} else {
|
||||
const focusPath = items[focusIdx]?.path;
|
||||
if (!focusPath || !treeState.selectedPaths.has(focusPath)) {
|
||||
focusIdx = treeState.visibleIndexByPath.get(currentSelected[currentSelected.length - 1]) ?? 0;
|
||||
anchorIdx = focusIdx;
|
||||
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, focusIdx);
|
||||
}
|
||||
}
|
||||
|
||||
let nextIdx = focusIdx + delta;
|
||||
if (nextIdx < 0) nextIdx = 0;
|
||||
if (nextIdx >= items.length) nextIdx = items.length - 1;
|
||||
|
||||
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
|
||||
if (e.shiftKey) {
|
||||
const start = Math.min(anchorIdx, nextIdx);
|
||||
const end = Math.max(anchorIdx, nextIdx);
|
||||
const paths = items.slice(start, end + 1).map(item => item.path);
|
||||
sftpTreeSelectionStore.setSelection(pane.id, paths);
|
||||
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, nextIdx);
|
||||
} else {
|
||||
sftpTreeSelectionStore.setSelection(pane.id, [items[nextIdx].path]);
|
||||
sftpKeyboardSelectionStore.set(pane.id, nextIdx, nextIdx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic navigation actions (Enter, Backspace) must work even when
|
||||
// custom hotkeys are disabled — they are essential SFTP navigation.
|
||||
// When hotkeys are enabled, defer to matchSftpAction so user
|
||||
// customizations are respected.
|
||||
const basicNavAction = hotkeyScheme === "disabled" && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey
|
||||
? BASIC_NAV_KEYS[e.key]
|
||||
: undefined;
|
||||
|
||||
if (hotkeyScheme === "disabled" && !basicNavAction) return;
|
||||
|
||||
const isMac = hotkeyScheme === "mac";
|
||||
const matched = basicNavAction ? null : matchSftpAction(e, keyBindings, isMac);
|
||||
if (!matched && !basicNavAction) return;
|
||||
|
||||
const action = basicNavAction ?? matched?.action;
|
||||
if (!action || !SFTP_ACTIONS.has(action)) return;
|
||||
|
||||
// Prevent default behavior
|
||||
e.preventDefault();
|
||||
@@ -94,49 +273,100 @@ export const useSftpKeyboardShortcuts = ({
|
||||
: sftp.rightTabs.tabs.find(p => p.id === sftp.rightTabs.activeTabId);
|
||||
|
||||
if (!pane || !pane.connection) return;
|
||||
const treeSelectionState = sftpTreeSelectionStore.getPaneState(pane.id);
|
||||
const treeSelection = sftpTreeSelectionStore.getSelectedItems(pane.id);
|
||||
const treeActionSelection = treeSelection.filter((entry) => entry.name !== '..');
|
||||
|
||||
switch (action) {
|
||||
case "sftpCopy": {
|
||||
if (treeActionSelection.length > 0) {
|
||||
const parentPaths = new Set(treeActionSelection.map((entry) => getParentPath(entry.path)));
|
||||
if (parentPaths.size !== 1) {
|
||||
toast.info("Tree selection across multiple folders can't be copied with shortcuts yet.", "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
const clipboardFiles: SftpClipboardFile[] = treeActionSelection.map((entry) => ({
|
||||
name: entry.name,
|
||||
isDirectory: entry.isDirectory,
|
||||
}));
|
||||
|
||||
sftpClipboardStore.copy(
|
||||
clipboardFiles,
|
||||
Array.from(parentPaths)[0],
|
||||
pane.connection.id,
|
||||
focusedSide,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Copy selected files to clipboard
|
||||
const selectedFiles = Array.from(pane.selectedFiles) as string[];
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
const clipboardFiles: SftpClipboardFile[] = selectedFiles.map((name: string) => {
|
||||
const file = pane.files.find((f) => f.name === name);
|
||||
return {
|
||||
name,
|
||||
isDirectory: file ? isNavigableDirectory(file) : false,
|
||||
};
|
||||
});
|
||||
{
|
||||
const filesByName = new Map((pane.files as SftpFileEntry[]).map(f => [f.name, f]));
|
||||
const clipboardFiles: SftpClipboardFile[] = selectedFiles.map((name: string) => {
|
||||
const file = filesByName.get(name);
|
||||
return {
|
||||
name,
|
||||
isDirectory: file ? isNavigableDirectory(file) : false,
|
||||
};
|
||||
});
|
||||
|
||||
sftpClipboardStore.copy(
|
||||
clipboardFiles,
|
||||
pane.connection.currentPath,
|
||||
pane.connection.id,
|
||||
focusedSide
|
||||
);
|
||||
sftpClipboardStore.copy(
|
||||
clipboardFiles,
|
||||
pane.connection.currentPath,
|
||||
pane.connection.id,
|
||||
focusedSide
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpCut": {
|
||||
if (treeActionSelection.length > 0) {
|
||||
const parentPaths = new Set(treeActionSelection.map((entry) => getParentPath(entry.path)));
|
||||
if (parentPaths.size !== 1) {
|
||||
toast.info("Tree selection across multiple folders can't be cut with shortcuts yet.", "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
const clipboardFiles: SftpClipboardFile[] = treeActionSelection.map((entry) => ({
|
||||
name: entry.name,
|
||||
isDirectory: entry.isDirectory,
|
||||
}));
|
||||
|
||||
sftpClipboardStore.cut(
|
||||
clipboardFiles,
|
||||
Array.from(parentPaths)[0],
|
||||
pane.connection.id,
|
||||
focusedSide,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Cut selected files to clipboard
|
||||
const selectedFiles = Array.from(pane.selectedFiles) as string[];
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
const clipboardFiles: SftpClipboardFile[] = selectedFiles.map((name: string) => {
|
||||
const file = pane.files.find((f) => f.name === name);
|
||||
return {
|
||||
name,
|
||||
isDirectory: file ? isNavigableDirectory(file) : false,
|
||||
};
|
||||
});
|
||||
{
|
||||
const filesByName = new Map((pane.files as SftpFileEntry[]).map(f => [f.name, f]));
|
||||
const clipboardFiles: SftpClipboardFile[] = selectedFiles.map((name: string) => {
|
||||
const file = filesByName.get(name);
|
||||
return {
|
||||
name,
|
||||
isDirectory: file ? isNavigableDirectory(file) : false,
|
||||
};
|
||||
});
|
||||
|
||||
sftpClipboardStore.cut(
|
||||
clipboardFiles,
|
||||
pane.connection.currentPath,
|
||||
pane.connection.id,
|
||||
focusedSide
|
||||
);
|
||||
sftpClipboardStore.cut(
|
||||
clipboardFiles,
|
||||
pane.connection.currentPath,
|
||||
pane.connection.id,
|
||||
focusedSide
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -146,8 +376,10 @@ export const useSftpKeyboardShortcuts = ({
|
||||
if (!clipboard || clipboard.files.length === 0) return;
|
||||
|
||||
// Use startTransfer to paste files from source to current pane
|
||||
// The transfer direction is determined by clipboard sourceSide and current focusedSide
|
||||
if (clipboard.sourceSide !== focusedSide) {
|
||||
// Allow paste when source and target are different connections, even on the same side
|
||||
const isSameConnection = clipboard.sourceSide === focusedSide
|
||||
&& clipboard.sourceConnectionId === pane.connection.id;
|
||||
if (!isSameConnection) {
|
||||
const sourceTabs = clipboard.sourceSide === "left" ? sftp.leftTabs.tabs : sftp.rightTabs.tabs;
|
||||
const sourcePane = sourceTabs.find((tab) => tab.connection?.id === clipboard.sourceConnectionId);
|
||||
|
||||
@@ -234,7 +466,17 @@ export const useSftpKeyboardShortcuts = ({
|
||||
}
|
||||
|
||||
case "sftpSelectAll": {
|
||||
if (treeSelectionState.visibleItems.length > 0) {
|
||||
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
|
||||
sftpTreeSelectionStore.selectAllVisible(pane.id);
|
||||
break;
|
||||
}
|
||||
|
||||
// Select all files in the current pane
|
||||
// TODO: Reference already-computed filtered files from useSftpPaneFiles
|
||||
// instead of re-implementing the hidden file + filter logic here.
|
||||
// This requires either lifting the computed files into pane state or
|
||||
// passing them via a shared store, which needs a larger refactor.
|
||||
const term = pane.filter.trim().toLowerCase();
|
||||
let visibleFiles = filterHiddenFiles(pane.files, pane.showHiddenFiles);
|
||||
if (term) {
|
||||
@@ -245,23 +487,38 @@ export const useSftpKeyboardShortcuts = ({
|
||||
const allFileNames = visibleFiles
|
||||
.filter((f) => f.name !== "..")
|
||||
.map((f) => f.name);
|
||||
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
|
||||
sftp.rangeSelect(focusedSide, allFileNames);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpRename": {
|
||||
if (treeActionSelection.length === 1) {
|
||||
sftpDialogActionStore.trigger("rename", dialogActionScopeId, [treeActionSelection[0].path]);
|
||||
break;
|
||||
}
|
||||
|
||||
// Trigger rename for the first selected file
|
||||
const selectedFiles = Array.from(pane.selectedFiles) as string[];
|
||||
if (selectedFiles.length !== 1) return;
|
||||
sftpDialogActionStore.trigger("rename", selectedFiles);
|
||||
sftpDialogActionStore.trigger("rename", dialogActionScopeId, selectedFiles);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpDelete": {
|
||||
if (treeActionSelection.length > 0) {
|
||||
sftpDialogActionStore.trigger(
|
||||
"delete",
|
||||
dialogActionScopeId,
|
||||
treeActionSelection.map((entry) => entry.path),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Delete selected files
|
||||
const selectedFiles = Array.from(pane.selectedFiles) as string[];
|
||||
if (selectedFiles.length === 0) return;
|
||||
sftpDialogActionStore.trigger("delete", selectedFiles);
|
||||
sftpDialogActionStore.trigger("delete", dialogActionScopeId, selectedFiles);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -273,12 +530,70 @@ export const useSftpKeyboardShortcuts = ({
|
||||
|
||||
case "sftpNewFolder": {
|
||||
// Create new folder
|
||||
sftpDialogActionStore.trigger("newFolder");
|
||||
sftpDialogActionStore.trigger("newFolder", dialogActionScopeId);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpOpen": {
|
||||
// Prefer list selection when the list store is active
|
||||
const listItems = sftpListOrderStore.getItems(pane.id);
|
||||
const selectedFiles = Array.from(pane.selectedFiles) as string[];
|
||||
if (listItems.length > 0 && selectedFiles.length === 1) {
|
||||
const fileName = selectedFiles[0];
|
||||
const entry = (pane.files as SftpFileEntry[]).find(f => f.name === fileName);
|
||||
if (entry) {
|
||||
if (isNavigableDirectory(entry)) {
|
||||
_kbSelectionState.delete(pane.id);
|
||||
sftp.navigateTo(focusedSide, joinPath(pane.connection.currentPath, entry.name));
|
||||
} else {
|
||||
sftp.openEntry(focusedSide, entry);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Only fall through to tree view if list store is empty (tree view mode)
|
||||
if (listItems.length > 0) break;
|
||||
const treeOpenSelection = sftpTreeSelectionStore.getSelectedItems(pane.id);
|
||||
if (treeOpenSelection.length === 1) {
|
||||
const item = treeOpenSelection[0];
|
||||
if (item.isDirectory) _kbSelectionState.delete(pane.id);
|
||||
sftpTreeEnterStore.trigger(pane.id, item.path, item.isDirectory);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpGoParent": {
|
||||
const parentPath = getParentPath(pane.connection.currentPath);
|
||||
if (parentPath !== pane.connection.currentPath) {
|
||||
_kbSelectionState.delete(pane.id);
|
||||
sftp.navigateTo(focusedSide, parentPath);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpNavigateTo": {
|
||||
// Navigate to the selected directory (useful in tree view)
|
||||
// Filter out ".." entry for consistency with other handlers
|
||||
if (treeActionSelection.length === 1 && treeActionSelection[0].isDirectory) {
|
||||
_kbSelectionState.delete(pane.id);
|
||||
sftp.navigateTo(focusedSide, treeActionSelection[0].path);
|
||||
break;
|
||||
}
|
||||
// In list view, navigate to selected directory
|
||||
const selectedFiles = Array.from(pane.selectedFiles) as string[];
|
||||
if (selectedFiles.length === 1) {
|
||||
const entry = (pane.files as SftpFileEntry[]).find(f => f.name === selectedFiles[0]);
|
||||
if (entry && isNavigableDirectory(entry)) {
|
||||
_kbSelectionState.delete(pane.id);
|
||||
sftp.navigateTo(focusedSide, joinPath(pane.connection.currentPath, entry.name));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[hotkeyScheme, isActive, keyBindings, sftpRef]
|
||||
[dialogActionScopeId, hotkeyScheme, isActive, keyBindings, sftpRef]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
20
components/sftp/hooks/useSftpListOrderStore.ts
Normal file
20
components/sftp/hooks/useSftpListOrderStore.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Lightweight store that tracks the sorted display file names per SFTP pane.
|
||||
* Used by keyboard shortcuts to navigate with ArrowUp/ArrowDown in list view.
|
||||
*/
|
||||
|
||||
const paneItems = new Map<string, string[]>();
|
||||
|
||||
export const sftpListOrderStore = {
|
||||
/** Update the ordered list of file names for a pane (call from SftpPaneFileList). */
|
||||
setItems: (paneId: string, names: string[]) => {
|
||||
paneItems.set(paneId, names);
|
||||
},
|
||||
|
||||
/** Get the ordered list of file names (excluding "..") for arrow key navigation. */
|
||||
getItems: (paneId: string): string[] => paneItems.get(paneId) ?? [],
|
||||
|
||||
clearPane: (paneId: string) => {
|
||||
paneItems.delete(paneId);
|
||||
},
|
||||
};
|
||||
@@ -1,15 +1,46 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { SftpPaneCallbacks } from "../SftpContext";
|
||||
import type { SftpPane } from "../../../application/state/sftp/types";
|
||||
import { getFileName, getParentPath } from "../../../application/state/sftp/utils";
|
||||
import { logger } from "../../../lib/logger";
|
||||
|
||||
const INVALID_FILENAME_CHARS = /[/\\:*?"<>|]/;
|
||||
const RESERVED_NAMES = new Set([
|
||||
"CON",
|
||||
"PRN",
|
||||
"AUX",
|
||||
"NUL",
|
||||
"COM1",
|
||||
"COM2",
|
||||
"COM3",
|
||||
"COM4",
|
||||
"COM5",
|
||||
"COM6",
|
||||
"COM7",
|
||||
"COM8",
|
||||
"COM9",
|
||||
"LPT1",
|
||||
"LPT2",
|
||||
"LPT3",
|
||||
"LPT4",
|
||||
"LPT5",
|
||||
"LPT6",
|
||||
"LPT7",
|
||||
"LPT8",
|
||||
"LPT9",
|
||||
]);
|
||||
|
||||
interface UseSftpPaneDialogsParams {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
pane: SftpPane;
|
||||
onCreateDirectory: SftpPaneCallbacks["onCreateDirectory"];
|
||||
onCreateDirectoryAtPath: SftpPaneCallbacks["onCreateDirectoryAtPath"];
|
||||
onCreateFile: SftpPaneCallbacks["onCreateFile"];
|
||||
onRenameFile: SftpPaneCallbacks["onRenameFile"];
|
||||
onDeleteFiles: SftpPaneCallbacks["onDeleteFiles"];
|
||||
onCreateFileAtPath: SftpPaneCallbacks["onCreateFileAtPath"];
|
||||
onRenameFileAtPath: SftpPaneCallbacks["onRenameFileAtPath"];
|
||||
onDeleteFilesAtPath: SftpPaneCallbacks["onDeleteFilesAtPath"];
|
||||
onClearSelection: SftpPaneCallbacks["onClearSelection"];
|
||||
onMutateSuccess?: (paths?: string[]) => void;
|
||||
}
|
||||
|
||||
interface UseSftpPaneDialogsResult {
|
||||
@@ -47,6 +78,8 @@ interface UseSftpPaneDialogsResult {
|
||||
handleConfirmOverwrite: () => Promise<void>;
|
||||
handleRename: () => Promise<void>;
|
||||
handleDelete: () => Promise<void>;
|
||||
openNewFolderDialogAtPath: (path: string) => void;
|
||||
openNewFileDialogAtPath: (path: string) => void;
|
||||
openRenameDialog: (name: string) => void;
|
||||
openDeleteConfirm: (names: string[]) => void;
|
||||
getNextUntitledName: (existingFiles: string[]) => string;
|
||||
@@ -56,17 +89,21 @@ export const useSftpPaneDialogs = ({
|
||||
t,
|
||||
pane,
|
||||
onCreateDirectory,
|
||||
onCreateDirectoryAtPath,
|
||||
onCreateFile,
|
||||
onRenameFile,
|
||||
onDeleteFiles,
|
||||
onCreateFileAtPath,
|
||||
onRenameFileAtPath,
|
||||
onDeleteFilesAtPath,
|
||||
onClearSelection,
|
||||
onMutateSuccess,
|
||||
}: UseSftpPaneDialogsParams): UseSftpPaneDialogsResult => {
|
||||
const [showHostPicker, setShowHostPicker] = useState(false);
|
||||
const [hostSearch, setHostSearch] = useState("");
|
||||
const [showNewFolderDialog, setShowNewFolderDialog] = useState(false);
|
||||
const [showNewFolderDialogState, setShowNewFolderDialogState] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState("");
|
||||
const [showNewFileDialog, setShowNewFileDialog] = useState(false);
|
||||
const [showNewFileDialogState, setShowNewFileDialogState] = useState(false);
|
||||
const [newFileName, setNewFileName] = useState("");
|
||||
const [createTargetPath, setCreateTargetPath] = useState<string | null>(null);
|
||||
const [fileNameError, setFileNameError] = useState<string | null>(null);
|
||||
const [showOverwriteConfirm, setShowOverwriteConfirm] = useState(false);
|
||||
const [overwriteTarget, setOverwriteTarget] = useState<string | null>(null);
|
||||
@@ -80,34 +117,24 @@ export const useSftpPaneDialogs = ({
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Refs for values accessed inside useCallback to avoid stale closures
|
||||
const newFolderNameRef = useRef(newFolderName);
|
||||
newFolderNameRef.current = newFolderName;
|
||||
const newFileNameRef = useRef(newFileName);
|
||||
newFileNameRef.current = newFileName;
|
||||
const createTargetPathRef = useRef(createTargetPath);
|
||||
createTargetPathRef.current = createTargetPath;
|
||||
const renameTargetRef = useRef(renameTarget);
|
||||
renameTargetRef.current = renameTarget;
|
||||
const renameNameRef = useRef(renameName);
|
||||
renameNameRef.current = renameName;
|
||||
const deleteTargetsRef = useRef(deleteTargets);
|
||||
deleteTargetsRef.current = deleteTargets;
|
||||
const paneRef = useRef(pane);
|
||||
paneRef.current = pane;
|
||||
|
||||
const validateFileName = useCallback(
|
||||
(name: string): string | null => {
|
||||
const INVALID_FILENAME_CHARS = /[/\\:*?"<>|]/;
|
||||
const RESERVED_NAMES = new Set([
|
||||
"CON",
|
||||
"PRN",
|
||||
"AUX",
|
||||
"NUL",
|
||||
"COM1",
|
||||
"COM2",
|
||||
"COM3",
|
||||
"COM4",
|
||||
"COM5",
|
||||
"COM6",
|
||||
"COM7",
|
||||
"COM8",
|
||||
"COM9",
|
||||
"LPT1",
|
||||
"LPT2",
|
||||
"LPT3",
|
||||
"LPT4",
|
||||
"LPT5",
|
||||
"LPT6",
|
||||
"LPT7",
|
||||
"LPT8",
|
||||
"LPT9",
|
||||
]);
|
||||
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
@@ -145,22 +172,29 @@ export const useSftpPaneDialogs = ({
|
||||
return `untitled_${Date.now()}.txt`;
|
||||
}, []);
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
if (!newFolderName.trim() || isCreating) return;
|
||||
const handleCreateFolder = useCallback(async () => {
|
||||
if (!newFolderNameRef.current.trim() || isCreating) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await onCreateDirectory(newFolderName.trim());
|
||||
setShowNewFolderDialog(false);
|
||||
if (createTargetPathRef.current) {
|
||||
await onCreateDirectoryAtPath(createTargetPathRef.current, newFolderNameRef.current.trim());
|
||||
} else {
|
||||
await onCreateDirectory(newFolderNameRef.current.trim());
|
||||
}
|
||||
const affectedPath = createTargetPathRef.current ?? paneRef.current.connection?.currentPath;
|
||||
onMutateSuccess?.(affectedPath ? [affectedPath] : undefined);
|
||||
setShowNewFolderDialogState(false);
|
||||
setCreateTargetPath(null);
|
||||
setNewFolderName("");
|
||||
} catch {
|
||||
/* Error handling */
|
||||
} catch (err) {
|
||||
logger.warn("Failed to create folder", err);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
}, [isCreating, onCreateDirectory, onCreateDirectoryAtPath, onMutateSuccess]);
|
||||
|
||||
const handleCreateFile = async (forceOverwrite = false) => {
|
||||
const trimmedName = newFileName.trim();
|
||||
const handleCreateFile = useCallback(async (forceOverwrite = false) => {
|
||||
const trimmedName = newFileNameRef.current.trim();
|
||||
if (!trimmedName || isCreatingFile) return;
|
||||
|
||||
const error = validateFileName(trimmedName);
|
||||
@@ -169,8 +203,9 @@ export const useSftpPaneDialogs = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!forceOverwrite) {
|
||||
const existingFile = pane.files.find(
|
||||
const currentPane = paneRef.current;
|
||||
if (!forceOverwrite && (!createTargetPathRef.current || createTargetPathRef.current === currentPane.connection?.currentPath)) {
|
||||
const existingFile = currentPane.files.find(
|
||||
(f) =>
|
||||
f.name.toLowerCase() === trimmedName.toLowerCase() && f.type === "file",
|
||||
);
|
||||
@@ -183,59 +218,112 @@ export const useSftpPaneDialogs = ({
|
||||
|
||||
setIsCreatingFile(true);
|
||||
try {
|
||||
await onCreateFile(trimmedName);
|
||||
setShowNewFileDialog(false);
|
||||
if (createTargetPathRef.current) {
|
||||
await onCreateFileAtPath(createTargetPathRef.current, trimmedName);
|
||||
} else {
|
||||
await onCreateFile(trimmedName);
|
||||
}
|
||||
const affectedPath = createTargetPathRef.current ?? paneRef.current.connection?.currentPath;
|
||||
onMutateSuccess?.(affectedPath ? [affectedPath] : undefined);
|
||||
setShowNewFileDialogState(false);
|
||||
setShowOverwriteConfirm(false);
|
||||
setOverwriteTarget(null);
|
||||
setCreateTargetPath(null);
|
||||
setNewFileName("");
|
||||
setFileNameError(null);
|
||||
} catch {
|
||||
/* Error handling */
|
||||
} catch (err) {
|
||||
logger.warn("Failed to create file", err);
|
||||
} finally {
|
||||
setIsCreatingFile(false);
|
||||
}
|
||||
};
|
||||
}, [isCreatingFile, validateFileName, onCreateFile, onCreateFileAtPath, onMutateSuccess]);
|
||||
|
||||
const handleConfirmOverwrite = async () => {
|
||||
const handleConfirmOverwrite = useCallback(async () => {
|
||||
await handleCreateFile(true);
|
||||
};
|
||||
}, [handleCreateFile]);
|
||||
|
||||
const handleRename = async () => {
|
||||
if (!renameTarget || !renameName.trim() || isRenaming) return;
|
||||
const handleRename = useCallback(async () => {
|
||||
if (!renameTargetRef.current || !renameNameRef.current.trim() || isRenaming) return;
|
||||
setIsRenaming(true);
|
||||
try {
|
||||
await onRenameFile(renameTarget, renameName.trim());
|
||||
// renameTarget is always a full path; use the path-aware variant
|
||||
await onRenameFileAtPath(renameTargetRef.current, renameNameRef.current.trim());
|
||||
onMutateSuccess?.([getParentPath(renameTargetRef.current)]);
|
||||
setShowRenameDialog(false);
|
||||
setRenameTarget(null);
|
||||
setRenameName("");
|
||||
} catch {
|
||||
/* Error handling */
|
||||
} catch (err) {
|
||||
logger.warn("Failed to rename file", err);
|
||||
} finally {
|
||||
setIsRenaming(false);
|
||||
}
|
||||
};
|
||||
}, [isRenaming, onRenameFileAtPath, onMutateSuccess]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (deleteTargets.length === 0 || isDeleting) return;
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (deleteTargetsRef.current.length === 0 || isDeleting) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDeleteFiles(deleteTargets);
|
||||
// deleteTargets are full paths; group by parent dir and use path-aware variant
|
||||
const byDir = new Map<string, string[]>();
|
||||
for (const fullPath of deleteTargetsRef.current) {
|
||||
const dir = getParentPath(fullPath);
|
||||
const name = getFileName(fullPath);
|
||||
const list = byDir.get(dir) ?? [];
|
||||
list.push(name);
|
||||
byDir.set(dir, list);
|
||||
}
|
||||
const connectionId = paneRef.current.connection?.id;
|
||||
if (!connectionId) {
|
||||
throw new Error("Pane connection is no longer available");
|
||||
}
|
||||
for (const [dir, names] of byDir) {
|
||||
await onDeleteFilesAtPath(connectionId, dir, names);
|
||||
}
|
||||
onMutateSuccess?.(Array.from(byDir.keys()));
|
||||
setShowDeleteConfirm(false);
|
||||
setDeleteTargets([]);
|
||||
onClearSelection();
|
||||
} catch {
|
||||
/* Error handling */
|
||||
} catch (err) {
|
||||
logger.warn("Failed to delete files", err);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
}, [isDeleting, onDeleteFilesAtPath, onMutateSuccess, onClearSelection]);
|
||||
|
||||
const openRenameDialog = useCallback((name: string) => {
|
||||
setRenameTarget(name);
|
||||
setRenameName(name);
|
||||
// entryPath is the full path; renameName is initialized to the basename
|
||||
const openRenameDialog = useCallback((entryPath: string) => {
|
||||
setRenameTarget(entryPath);
|
||||
setRenameName(getFileName(entryPath) || entryPath);
|
||||
setShowRenameDialog(true);
|
||||
}, []);
|
||||
|
||||
const setShowNewFolderDialog = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setCreateTargetPath(null);
|
||||
}
|
||||
setShowNewFolderDialogState(open);
|
||||
}, []);
|
||||
|
||||
const setShowNewFileDialog = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setCreateTargetPath(null);
|
||||
}
|
||||
setShowNewFileDialogState(open);
|
||||
}, []);
|
||||
|
||||
const openNewFolderDialogAtPath = useCallback((path: string) => {
|
||||
setCreateTargetPath(path);
|
||||
setNewFolderName("");
|
||||
setShowNewFolderDialogState(true);
|
||||
}, []);
|
||||
|
||||
const openNewFileDialogAtPath = useCallback((path: string) => {
|
||||
setCreateTargetPath(path);
|
||||
setNewFileName("");
|
||||
setFileNameError(null);
|
||||
setShowNewFileDialogState(true);
|
||||
}, []);
|
||||
|
||||
const openDeleteConfirm = useCallback((names: string[]) => {
|
||||
setDeleteTargets(names);
|
||||
setShowDeleteConfirm(true);
|
||||
@@ -244,9 +332,9 @@ export const useSftpPaneDialogs = ({
|
||||
return {
|
||||
showHostPicker,
|
||||
hostSearch,
|
||||
showNewFolderDialog,
|
||||
showNewFolderDialog: showNewFolderDialogState,
|
||||
newFolderName,
|
||||
showNewFileDialog,
|
||||
showNewFileDialog: showNewFileDialogState,
|
||||
newFileName,
|
||||
fileNameError,
|
||||
showOverwriteConfirm,
|
||||
@@ -276,6 +364,8 @@ export const useSftpPaneDialogs = ({
|
||||
handleConfirmOverwrite,
|
||||
handleRename,
|
||||
handleDelete,
|
||||
openNewFolderDialogAtPath,
|
||||
openNewFileDialogAtPath,
|
||||
openRenameDialog,
|
||||
openDeleteConfirm,
|
||||
getNextUntitledName,
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import type { SftpPaneCallbacks, SftpDragCallbacks } from "../SftpContext";
|
||||
import type { SftpPaneCallbacks, SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
|
||||
import { isNavigableDirectory } from "../index";
|
||||
import { joinPath } from "../../../application/state/sftp/utils";
|
||||
|
||||
interface UseSftpPaneDragAndSelectParams {
|
||||
side: "left" | "right";
|
||||
pane: { selectedFiles: Set<string> };
|
||||
pane: {
|
||||
selectedFiles: Set<string>;
|
||||
connection?: { currentPath: string; id: string } | null;
|
||||
};
|
||||
sortedDisplayFiles: SftpFileEntry[];
|
||||
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
|
||||
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
|
||||
onDragStart: SftpDragCallbacks["onDragStart"];
|
||||
onReceiveFromOtherPane: SftpPaneCallbacks["onReceiveFromOtherPane"];
|
||||
onMoveEntriesToPath: SftpPaneCallbacks["onMoveEntriesToPath"];
|
||||
onUploadExternalFiles?: SftpPaneCallbacks["onUploadExternalFiles"];
|
||||
onOpenEntry: SftpPaneCallbacks["onOpenEntry"];
|
||||
onRangeSelect: SftpPaneCallbacks["onRangeSelect"];
|
||||
@@ -38,6 +43,7 @@ export const useSftpPaneDragAndSelect = ({
|
||||
draggedFiles,
|
||||
onDragStart,
|
||||
onReceiveFromOtherPane,
|
||||
onMoveEntriesToPath,
|
||||
onUploadExternalFiles,
|
||||
onOpenEntry,
|
||||
onRangeSelect,
|
||||
@@ -49,17 +55,38 @@ export const useSftpPaneDragAndSelect = ({
|
||||
const lastSelectedIndexRef = useRef<number | null>(null);
|
||||
|
||||
const selectedFilesRef = useRef(pane.selectedFiles);
|
||||
selectedFilesRef.current = pane.selectedFiles;
|
||||
const sortedFilesRef = useRef(sortedDisplayFiles);
|
||||
sortedFilesRef.current = sortedDisplayFiles;
|
||||
const draggedFilesRef = useRef(draggedFiles);
|
||||
draggedFilesRef.current = draggedFiles;
|
||||
const onReceiveRef = useRef(onReceiveFromOtherPane);
|
||||
onReceiveRef.current = onReceiveFromOtherPane;
|
||||
const onMoveEntriesToPathRef = useRef(onMoveEntriesToPath);
|
||||
onMoveEntriesToPathRef.current = onMoveEntriesToPath;
|
||||
const onUploadRef = useRef(onUploadExternalFiles);
|
||||
onUploadRef.current = onUploadExternalFiles;
|
||||
|
||||
useEffect(() => {
|
||||
selectedFilesRef.current = pane.selectedFiles;
|
||||
}, [pane.selectedFiles]);
|
||||
if (pane.selectedFiles.size === 0) {
|
||||
lastSelectedIndexRef.current = null;
|
||||
}
|
||||
}, [pane.selectedFiles.size]);
|
||||
|
||||
useEffect(() => {
|
||||
sortedFilesRef.current = sortedDisplayFiles;
|
||||
}, [sortedDisplayFiles]);
|
||||
const getSamePaneDragPaths = useCallback((): string[] | null => {
|
||||
const dragged = draggedFilesRef.current;
|
||||
if (!dragged || dragged.length === 0) return null;
|
||||
if (dragged[0]?.side !== side) return null;
|
||||
|
||||
const handlePaneDragOver = (e: React.DragEvent) => {
|
||||
const currentConnectionId = pane.connection?.id;
|
||||
const paths = dragged
|
||||
.filter((file) => file.sourceConnectionId === currentConnectionId && file.sourcePath)
|
||||
.map((file) => joinPath(file.sourcePath!, file.name));
|
||||
|
||||
return paths.length > 0 ? paths : null;
|
||||
}, [pane.connection?.id, side]);
|
||||
|
||||
const handlePaneDragOver = useCallback((e: React.DragEvent) => {
|
||||
const hasFiles = e.dataTransfer.types.includes("Files");
|
||||
|
||||
if (hasFiles) {
|
||||
@@ -69,38 +96,36 @@ export const useSftpPaneDragAndSelect = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!draggedFiles || draggedFiles[0]?.side === side) return;
|
||||
if (!draggedFilesRef.current || draggedFilesRef.current[0]?.side === side) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setIsDragOverPane(true);
|
||||
};
|
||||
}, [side]);
|
||||
|
||||
const handlePaneDragLeave = (e: React.DragEvent) => {
|
||||
const handlePaneDragLeave = useCallback((e: React.DragEvent) => {
|
||||
const relatedTarget = e.relatedTarget as Node | null;
|
||||
if (relatedTarget && paneContainerRef.current?.contains(relatedTarget)) return;
|
||||
setIsDragOverPane(false);
|
||||
setDragOverEntry(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handlePaneDrop = async (e: React.DragEvent) => {
|
||||
const handlePaneDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOverPane(false);
|
||||
setDragOverEntry(null);
|
||||
|
||||
if (draggedFiles && draggedFiles.length > 0) {
|
||||
if (draggedFiles[0]?.side !== side) {
|
||||
onReceiveFromOtherPane(
|
||||
draggedFiles.map((f) => ({ name: f.name, isDirectory: f.isDirectory })),
|
||||
);
|
||||
if (draggedFilesRef.current && draggedFilesRef.current.length > 0) {
|
||||
if (draggedFilesRef.current[0]?.side !== side) {
|
||||
onReceiveRef.current(draggedFilesRef.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.dataTransfer.items.length > 0 && onUploadExternalFiles) {
|
||||
await onUploadExternalFiles(e.dataTransfer);
|
||||
if (e.dataTransfer.items.length > 0 && onUploadRef.current) {
|
||||
await onUploadRef.current(e.dataTransfer);
|
||||
}
|
||||
};
|
||||
}, [side]);
|
||||
|
||||
const handleFileDragStart = useCallback(
|
||||
(entry: SftpFileEntry, e: React.DragEvent) => {
|
||||
@@ -108,55 +133,112 @@ export const useSftpPaneDragAndSelect = ({
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const selectedNames = Array.from(selectedFilesRef.current);
|
||||
const files = selectedNames.includes(entry.name)
|
||||
const selectedNames = new Set(selectedFilesRef.current);
|
||||
const files = selectedNames.has(entry.name)
|
||||
? sortedFilesRef.current
|
||||
.filter((f) => selectedNames.includes(f.name))
|
||||
.filter((f) => selectedNames.has(f.name))
|
||||
.map((f) => ({
|
||||
name: f.name,
|
||||
isDirectory: isNavigableDirectory(f),
|
||||
sourceConnectionId: pane.connection?.id,
|
||||
sourcePath: pane.connection?.currentPath,
|
||||
side,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
name: entry.name,
|
||||
isDirectory: isNavigableDirectory(entry),
|
||||
sourceConnectionId: pane.connection?.id,
|
||||
sourcePath: pane.connection?.currentPath,
|
||||
side,
|
||||
},
|
||||
];
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
e.dataTransfer.setData("text/plain", files.map((f) => f.name).join("\n"));
|
||||
onDragStart(files, side);
|
||||
},
|
||||
[onDragStart, side],
|
||||
[onDragStart, pane.connection?.currentPath, pane.connection?.id, side],
|
||||
);
|
||||
|
||||
const handleEntryDragOver = useCallback(
|
||||
(entry: SftpFileEntry, e: React.DragEvent) => {
|
||||
if (!draggedFiles || draggedFiles[0]?.side === side) return;
|
||||
if (isNavigableDirectory(entry) && entry.name !== "..") {
|
||||
const samePaneDragPaths = getSamePaneDragPaths();
|
||||
if (samePaneDragPaths && isNavigableDirectory(entry) && entry.name !== "..") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOverEntry(entry.name);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle cross-pane internal drag
|
||||
if (draggedFilesRef.current && draggedFilesRef.current[0]?.side !== side) {
|
||||
if (isNavigableDirectory(entry) && entry.name !== "..") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverEntry(entry.name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Handle external file drag (from OS file explorer)
|
||||
const hasFiles = e.dataTransfer.types.includes("Files");
|
||||
if (hasFiles && isNavigableDirectory(entry) && entry.name !== "..") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setDragOverEntry(entry.name);
|
||||
}
|
||||
},
|
||||
[draggedFiles, side],
|
||||
[getSamePaneDragPaths, side],
|
||||
);
|
||||
|
||||
const handleEntryDrop = useCallback(
|
||||
(entry: SftpFileEntry, e: React.DragEvent) => {
|
||||
if (!draggedFiles || draggedFiles[0]?.side === side) return;
|
||||
if (isNavigableDirectory(entry) && entry.name !== "..") {
|
||||
async (entry: SftpFileEntry, e: React.DragEvent) => {
|
||||
const samePaneDragPaths = getSamePaneDragPaths();
|
||||
if (samePaneDragPaths && isNavigableDirectory(entry) && entry.name !== "..") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverEntry(null);
|
||||
setIsDragOverPane(false);
|
||||
onReceiveFromOtherPane(
|
||||
draggedFiles.map((f) => ({ name: f.name, isDirectory: f.isDirectory })),
|
||||
);
|
||||
const targetPath = pane.connection?.currentPath
|
||||
? joinPath(pane.connection.currentPath, entry.name)
|
||||
: undefined;
|
||||
if (targetPath) {
|
||||
await onMoveEntriesToPathRef.current(samePaneDragPaths, targetPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle cross-pane internal drag
|
||||
if (draggedFilesRef.current && draggedFilesRef.current[0]?.side !== side) {
|
||||
if (isNavigableDirectory(entry) && entry.name !== "..") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverEntry(null);
|
||||
setIsDragOverPane(false);
|
||||
const targetPath = pane.connection?.currentPath
|
||||
? joinPath(pane.connection.currentPath, entry.name)
|
||||
: undefined;
|
||||
onReceiveRef.current(
|
||||
draggedFilesRef.current.map((file) => ({ ...file, targetPath })),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Handle external file drop on a directory entry
|
||||
const hasFiles = e.dataTransfer.types.includes("Files");
|
||||
if (hasFiles && isNavigableDirectory(entry) && entry.name !== "..") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverEntry(null);
|
||||
setIsDragOverPane(false);
|
||||
if (onUploadRef.current && pane.connection?.currentPath) {
|
||||
const targetPath = joinPath(pane.connection.currentPath, entry.name);
|
||||
void onUploadRef.current(e.dataTransfer, targetPath);
|
||||
}
|
||||
}
|
||||
},
|
||||
[draggedFiles, onReceiveFromOtherPane, side],
|
||||
[getSamePaneDragPaths, side, pane.connection?.currentPath],
|
||||
);
|
||||
|
||||
const handleRowSelect = useCallback(
|
||||
@@ -165,7 +247,7 @@ export const useSftpPaneDragAndSelect = ({
|
||||
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
|
||||
const start = Math.min(lastSelectedIndexRef.current, index);
|
||||
const end = Math.max(lastSelectedIndexRef.current, index);
|
||||
const selectedFileNames = sortedDisplayFiles
|
||||
const selectedFileNames = sortedFilesRef.current
|
||||
.slice(start, end + 1)
|
||||
.filter((f) => f.name !== "..")
|
||||
.map((f) => f.name);
|
||||
@@ -175,7 +257,7 @@ export const useSftpPaneDragAndSelect = ({
|
||||
lastSelectedIndexRef.current = index;
|
||||
}
|
||||
},
|
||||
[onRangeSelect, onToggleSelection, sortedDisplayFiles],
|
||||
[onRangeSelect, onToggleSelection],
|
||||
);
|
||||
|
||||
const handleRowOpen = useCallback(
|
||||
|
||||
@@ -2,13 +2,14 @@ import { useMemo } from "react";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import type { SftpPane } from "../../../application/state/sftp/types";
|
||||
import type { SortField, SortOrder } from "../utils";
|
||||
import { filterHiddenFiles } from "../index";
|
||||
import { filterHiddenFiles, sortSftpEntries } from "../index";
|
||||
|
||||
interface UseSftpPaneFilesParams {
|
||||
files: SftpFileEntry[];
|
||||
filter: string;
|
||||
connection: SftpPane["connection"] | null;
|
||||
showHiddenFiles: boolean;
|
||||
enableListView: boolean;
|
||||
sortField: SortField;
|
||||
sortOrder: SortOrder;
|
||||
}
|
||||
@@ -24,76 +25,62 @@ export const useSftpPaneFiles = ({
|
||||
filter,
|
||||
connection,
|
||||
showHiddenFiles,
|
||||
enableListView,
|
||||
sortField,
|
||||
sortOrder,
|
||||
}: UseSftpPaneFilesParams): UseSftpPaneFilesResult => {
|
||||
// Extract ".." once and process the remaining files through filter -> sort
|
||||
// in fewer passes, instead of repeatedly filtering/finding ".." entries.
|
||||
const filteredFiles = useMemo(() => {
|
||||
if (!enableListView) return [] as SftpFileEntry[];
|
||||
const term = filter.trim().toLowerCase();
|
||||
let nextFiles = filterHiddenFiles(files, showHiddenFiles);
|
||||
if (!term) return nextFiles;
|
||||
return nextFiles.filter(
|
||||
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
|
||||
);
|
||||
}, [files, filter, showHiddenFiles]);
|
||||
}, [enableListView, files, filter, showHiddenFiles]);
|
||||
|
||||
const { displayFiles, sortedDisplayFiles } = useMemo(() => {
|
||||
if (!connection || !enableListView) {
|
||||
return { displayFiles: [] as SftpFileEntry[], sortedDisplayFiles: [] as SftpFileEntry[] };
|
||||
}
|
||||
|
||||
const displayFiles = useMemo(() => {
|
||||
if (!connection) return [];
|
||||
const isRootPath =
|
||||
connection.currentPath === "/" ||
|
||||
/^[A-Za-z]:[\\/]?$/.test(connection.currentPath);
|
||||
if (isRootPath) return filteredFiles;
|
||||
const parentEntry: SftpFileEntry = {
|
||||
name: "..",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: 0,
|
||||
lastModifiedFormatted: "--",
|
||||
};
|
||||
return [parentEntry, ...filteredFiles.filter((f) => f.name !== "..")];
|
||||
}, [connection, filteredFiles]);
|
||||
|
||||
const sortedDisplayFiles = useMemo(() => {
|
||||
if (!displayFiles.length) return displayFiles;
|
||||
|
||||
const parentEntry = displayFiles.find((f) => f.name === "..");
|
||||
const otherFiles = displayFiles.filter((f) => f.name !== "..");
|
||||
|
||||
const sorted = [...otherFiles].sort((a, b) => {
|
||||
if (sortField !== "type") {
|
||||
if (a.type === "directory" && b.type !== "directory") return -1;
|
||||
if (a.type !== "directory" && b.type === "directory") return 1;
|
||||
// Split ".." from other files in a single pass
|
||||
let parentEntry: SftpFileEntry | undefined;
|
||||
const otherFiles: SftpFileEntry[] = [];
|
||||
for (const f of filteredFiles) {
|
||||
if (f.name === "..") {
|
||||
parentEntry = f;
|
||||
} else {
|
||||
otherFiles.push(f);
|
||||
}
|
||||
}
|
||||
|
||||
let cmp = 0;
|
||||
switch (sortField) {
|
||||
case "name":
|
||||
cmp = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case "size":
|
||||
cmp = (a.size || 0) - (b.size || 0);
|
||||
break;
|
||||
case "modified":
|
||||
cmp = (a.lastModified || 0) - (b.lastModified || 0);
|
||||
break;
|
||||
case "type": {
|
||||
const extA =
|
||||
a.type === "directory"
|
||||
? "folder"
|
||||
: a.name.split(".").pop()?.toLowerCase() || "";
|
||||
const extB =
|
||||
b.type === "directory"
|
||||
? "folder"
|
||||
: b.name.split(".").pop()?.toLowerCase() || "";
|
||||
cmp = extA.localeCompare(extB);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return sortOrder === "asc" ? cmp : -cmp;
|
||||
});
|
||||
// For non-root paths, always ensure a ".." entry exists
|
||||
if (!isRootPath && !parentEntry) {
|
||||
parentEntry = {
|
||||
name: "..",
|
||||
type: "directory",
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: 0,
|
||||
lastModifiedFormatted: "--",
|
||||
};
|
||||
}
|
||||
|
||||
return parentEntry ? [parentEntry, ...sorted] : sorted;
|
||||
}, [displayFiles, sortField, sortOrder]);
|
||||
const display = parentEntry ? [parentEntry, ...otherFiles] : otherFiles;
|
||||
const sorted = otherFiles.length
|
||||
? sortSftpEntries(otherFiles, sortField, sortOrder)
|
||||
: otherFiles;
|
||||
const sortedDisplay = parentEntry ? [parentEntry, ...sorted] : sorted;
|
||||
|
||||
return { displayFiles: display, sortedDisplayFiles: sortedDisplay };
|
||||
}, [connection, enableListView, filteredFiles, sortField, sortOrder]);
|
||||
|
||||
return { filteredFiles, displayFiles, sortedDisplayFiles };
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import type { SftpPane } from "../../../application/state/sftp/types";
|
||||
import { isNavigableDirectory } from "../index";
|
||||
import { filterHiddenFiles, isNavigableDirectory } from "../index";
|
||||
|
||||
interface UseSftpPanePathParams {
|
||||
connection: SftpPane["connection"] | null;
|
||||
filteredFiles: SftpFileEntry[];
|
||||
files: SftpFileEntry[];
|
||||
showHiddenFiles: boolean;
|
||||
onNavigateTo: (path: string) => void;
|
||||
}
|
||||
|
||||
@@ -28,7 +29,8 @@ interface UseSftpPanePathResult {
|
||||
|
||||
export const useSftpPanePath = ({
|
||||
connection,
|
||||
filteredFiles,
|
||||
files,
|
||||
showHiddenFiles,
|
||||
onNavigateTo,
|
||||
}: UseSftpPanePathParams): UseSftpPanePathResult => {
|
||||
const [isEditingPath, setIsEditingPath] = useState(false);
|
||||
@@ -43,7 +45,7 @@ export const useSftpPanePath = ({
|
||||
const currentValue = editingPathValue.trim().toLowerCase();
|
||||
const suggestions: { path: string; type: "folder" | "history" }[] = [];
|
||||
|
||||
const folders = filteredFiles.filter(
|
||||
const folders = filterHiddenFiles(files, showHiddenFiles).filter(
|
||||
(f) => isNavigableDirectory(f) && f.name !== "..",
|
||||
);
|
||||
folders.forEach((f) => {
|
||||
@@ -70,7 +72,7 @@ export const useSftpPanePath = ({
|
||||
});
|
||||
|
||||
return suggestions.slice(0, 8);
|
||||
}, [connection, editingPathValue, filteredFiles, isEditingPath]);
|
||||
}, [connection, editingPathValue, files, isEditingPath, showHiddenFiles]);
|
||||
|
||||
const handlePathDoubleClick = () => {
|
||||
if (!connection) return;
|
||||
|
||||
@@ -13,10 +13,10 @@ export const useSftpPaneSorting = (): UseSftpPaneSortingResult => {
|
||||
const [sortField, setSortField] = useState<SortField>("name");
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc");
|
||||
const [columnWidths, setColumnWidths] = useState<ColumnWidths>({
|
||||
name: 45,
|
||||
modified: 25,
|
||||
size: 15,
|
||||
type: 15,
|
||||
name: 56,
|
||||
modified: 28,
|
||||
size: 7,
|
||||
type: 9,
|
||||
});
|
||||
|
||||
const resizingRef = useRef<{
|
||||
@@ -34,24 +34,48 @@ export const useSftpPaneSorting = (): UseSftpPaneSortingResult => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleResizeMove = useCallback((e: MouseEvent) => {
|
||||
const rafIdRef = useRef<number | null>(null);
|
||||
const lastClientXRef = useRef(0);
|
||||
|
||||
const applyColumnWidth = useCallback(() => {
|
||||
if (!resizingRef.current) return;
|
||||
const diff = e.clientX - resizingRef.current.startX;
|
||||
const { field, startX, startWidth } = resizingRef.current;
|
||||
const diff = lastClientXRef.current - startX;
|
||||
const limits: Record<keyof ColumnWidths, { min: number; max: number }> = {
|
||||
name: { min: 36, max: 78 },
|
||||
modified: { min: 18, max: 42 },
|
||||
size: { min: 5, max: 16 },
|
||||
type: { min: 6, max: 18 },
|
||||
};
|
||||
const { min, max } = limits[field];
|
||||
const newWidth = Math.max(
|
||||
10,
|
||||
Math.min(60, resizingRef.current.startWidth + diff / 5),
|
||||
min,
|
||||
Math.min(max, startWidth + diff / 8),
|
||||
);
|
||||
setColumnWidths((prev) => ({
|
||||
...prev,
|
||||
[resizingRef.current!.field]: newWidth,
|
||||
[field]: newWidth,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleResizeMove = useCallback((e: MouseEvent) => {
|
||||
if (!resizingRef.current) return;
|
||||
lastClientXRef.current = e.clientX;
|
||||
if (rafIdRef.current !== null) return;
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
rafIdRef.current = null;
|
||||
applyColumnWidth();
|
||||
});
|
||||
}, [applyColumnWidth]);
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
if (rafIdRef.current !== null) cancelAnimationFrame(rafIdRef.current);
|
||||
applyColumnWidth();
|
||||
rafIdRef.current = null;
|
||||
resizingRef.current = null;
|
||||
document.removeEventListener("mousemove", handleResizeMove);
|
||||
document.removeEventListener("mouseup", handleResizeEnd);
|
||||
}, [handleResizeMove]);
|
||||
}, [applyColumnWidth, handleResizeMove]);
|
||||
|
||||
const handleResizeStart = (
|
||||
field: keyof ColumnWidths,
|
||||
@@ -59,6 +83,7 @@ export const useSftpPaneSorting = (): UseSftpPaneSortingResult => {
|
||||
) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
lastClientXRef.current = e.clientX;
|
||||
resizingRef.current = {
|
||||
field,
|
||||
startX: e.clientX,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SftpFileEntry } from "../../../types";
|
||||
|
||||
interface UseSftpPaneVirtualListParams {
|
||||
isActive: boolean;
|
||||
enabled?: boolean;
|
||||
sortedDisplayFiles: SftpFileEntry[];
|
||||
}
|
||||
|
||||
@@ -17,6 +18,7 @@ interface UseSftpPaneVirtualListResult {
|
||||
|
||||
export const useSftpPaneVirtualList = ({
|
||||
isActive,
|
||||
enabled = true,
|
||||
sortedDisplayFiles,
|
||||
}: UseSftpPaneVirtualListParams): UseSftpPaneVirtualListResult => {
|
||||
const fileListRef = useRef<HTMLDivElement>(null);
|
||||
@@ -27,7 +29,7 @@ export const useSftpPaneVirtualList = ({
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = fileListRef.current;
|
||||
if (!container || !isActive) return;
|
||||
if (!container || !isActive || !enabled) return;
|
||||
const update = () => setViewportHeight(container.clientHeight);
|
||||
update();
|
||||
const raf = window.requestAnimationFrame(update);
|
||||
@@ -37,11 +39,11 @@ export const useSftpPaneVirtualList = ({
|
||||
resizeObserver.disconnect();
|
||||
window.cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [isActive, sortedDisplayFiles.length]);
|
||||
}, [enabled, isActive, sortedDisplayFiles.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = fileListRef.current;
|
||||
if (!container || !isActive || sortedDisplayFiles.length === 0) return;
|
||||
if (!container || !isActive || !enabled || sortedDisplayFiles.length === 0) return;
|
||||
const raf = window.requestAnimationFrame(() => {
|
||||
const rowElement = container.querySelector(
|
||||
'[data-sftp-row="true"]',
|
||||
@@ -53,7 +55,7 @@ export const useSftpPaneVirtualList = ({
|
||||
}
|
||||
});
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [isActive, rowHeight, sortedDisplayFiles.length]);
|
||||
}, [enabled, isActive, rowHeight, sortedDisplayFiles.length]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -65,7 +67,7 @@ export const useSftpPaneVirtualList = ({
|
||||
|
||||
const handleFileListScroll = useCallback(
|
||||
(e: React.UIEvent<HTMLDivElement>) => {
|
||||
if (!isActive) return;
|
||||
if (!isActive || !enabled) return;
|
||||
const nextTop = e.currentTarget.scrollTop;
|
||||
if (scrollFrameRef.current !== null) return;
|
||||
scrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||
@@ -73,12 +75,12 @@ export const useSftpPaneVirtualList = ({
|
||||
setScrollTop(nextTop);
|
||||
});
|
||||
},
|
||||
[isActive],
|
||||
[enabled, isActive],
|
||||
);
|
||||
|
||||
const { shouldVirtualize, totalHeight, visibleRows } = useMemo(() => {
|
||||
const overscan = 6;
|
||||
const canVirtualize = isActive && viewportHeight > 0 && rowHeight > 0;
|
||||
const canVirtualize = enabled && isActive && viewportHeight > 0 && rowHeight > 0;
|
||||
const shouldVirtualizeLocal = canVirtualize && sortedDisplayFiles.length > 50;
|
||||
const totalHeightLocal = shouldVirtualizeLocal
|
||||
? sortedDisplayFiles.length * rowHeight
|
||||
@@ -111,7 +113,7 @@ export const useSftpPaneVirtualList = ({
|
||||
totalHeight: totalHeightLocal,
|
||||
visibleRows: visibleRowsLocal,
|
||||
};
|
||||
}, [isActive, rowHeight, scrollTop, sortedDisplayFiles, viewportHeight]);
|
||||
}, [enabled, isActive, rowHeight, scrollTop, sortedDisplayFiles, viewportHeight]);
|
||||
|
||||
return {
|
||||
fileListRef,
|
||||
|
||||
153
components/sftp/hooks/useSftpTreeSelectionStore.ts
Normal file
153
components/sftp/hooks/useSftpTreeSelectionStore.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
export interface SftpTreeSelectionItem {
|
||||
path: string;
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
sourcePath: string;
|
||||
}
|
||||
|
||||
interface SftpTreeSelectionState {
|
||||
visibleItems: SftpTreeSelectionItem[];
|
||||
visibleItemsByPath: Map<string, SftpTreeSelectionItem>;
|
||||
visibleIndexByPath: Map<string, number>;
|
||||
visiblePathsSet: Set<string>;
|
||||
selectedPaths: Set<string>;
|
||||
}
|
||||
|
||||
const EMPTY_PATHS = new Set<string>();
|
||||
|
||||
const EMPTY_STATE: SftpTreeSelectionState = {
|
||||
visibleItems: [],
|
||||
visibleItemsByPath: new Map(),
|
||||
visibleIndexByPath: new Map(),
|
||||
visiblePathsSet: new Set(),
|
||||
selectedPaths: EMPTY_PATHS,
|
||||
};
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const paneStates = new Map<string, SftpTreeSelectionState>();
|
||||
const paneListeners = new Map<string, Set<Listener>>();
|
||||
|
||||
const notifyPaneListeners = (paneId: string) => {
|
||||
paneListeners.get(paneId)?.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
const getPaneState = (paneId: string): SftpTreeSelectionState =>
|
||||
paneStates.get(paneId) ?? EMPTY_STATE;
|
||||
|
||||
const setPaneState = (
|
||||
paneId: string,
|
||||
updater: (state: SftpTreeSelectionState) => SftpTreeSelectionState,
|
||||
) => {
|
||||
const prev = getPaneState(paneId);
|
||||
const next = updater(prev);
|
||||
if (next === prev) return;
|
||||
if (next.visibleItems.length === 0 && next.selectedPaths.size === 0) {
|
||||
paneStates.delete(paneId);
|
||||
} else {
|
||||
paneStates.set(paneId, next);
|
||||
}
|
||||
notifyPaneListeners(paneId);
|
||||
};
|
||||
|
||||
export const sftpTreeSelectionStore = {
|
||||
getPaneState,
|
||||
|
||||
getSelectedItems: (paneId: string): SftpTreeSelectionItem[] => {
|
||||
const state = getPaneState(paneId);
|
||||
const result: SftpTreeSelectionItem[] = [];
|
||||
for (const path of state.selectedPaths) {
|
||||
const item = state.visibleItemsByPath.get(path);
|
||||
if (item) result.push(item);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
setVisibleItems: (paneId: string, visibleItems: SftpTreeSelectionItem[]) => {
|
||||
const visibleItemsByPath = new Map<string, SftpTreeSelectionItem>();
|
||||
const visibleIndexByPath = new Map<string, number>();
|
||||
const visiblePathsSet = new Set(visibleItems.map((item) => item.path));
|
||||
visibleItems.forEach((item, index) => {
|
||||
visibleItemsByPath.set(item.path, item);
|
||||
visibleIndexByPath.set(item.path, index);
|
||||
});
|
||||
setPaneState(paneId, (state) => {
|
||||
const newSelected = new Set([...state.selectedPaths].filter((p) => visiblePathsSet.has(p)));
|
||||
const changed =
|
||||
newSelected.size !== state.selectedPaths.size ||
|
||||
[...newSelected].some((p) => !state.selectedPaths.has(p));
|
||||
return {
|
||||
visibleItems,
|
||||
visibleItemsByPath,
|
||||
visibleIndexByPath,
|
||||
visiblePathsSet,
|
||||
selectedPaths: changed ? newSelected : state.selectedPaths,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setSelection: (paneId: string, selectedPaths: Iterable<string>) => {
|
||||
setPaneState(paneId, (state) => ({
|
||||
...state,
|
||||
selectedPaths: new Set(Array.from(selectedPaths).filter((path) => state.visiblePathsSet.has(path))),
|
||||
}));
|
||||
},
|
||||
|
||||
clearSelection: (paneId: string) => {
|
||||
setPaneState(paneId, (state) => ({ ...state, selectedPaths: EMPTY_PATHS }));
|
||||
},
|
||||
|
||||
clearAllExcept: (paneIdsToKeep?: Iterable<string>) => {
|
||||
const keep = new Set(paneIdsToKeep ?? []);
|
||||
Array.from(paneStates.keys()).forEach((paneId) => {
|
||||
if (keep.has(paneId)) return;
|
||||
setPaneState(paneId, (state) => {
|
||||
if (state.selectedPaths.size === 0) return state;
|
||||
return { ...state, selectedPaths: EMPTY_PATHS };
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
selectAllVisible: (paneId: string) => {
|
||||
setPaneState(paneId, (state) => ({
|
||||
...state,
|
||||
selectedPaths: new Set(
|
||||
state.visibleItems.map((item) => item.path),
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
clearPane: (paneId: string) => {
|
||||
if (!paneStates.has(paneId)) return;
|
||||
paneStates.delete(paneId);
|
||||
notifyPaneListeners(paneId);
|
||||
},
|
||||
|
||||
subscribe: (paneId: string, listener: Listener) => {
|
||||
const listeners = paneListeners.get(paneId) ?? new Set<Listener>();
|
||||
listeners.add(listener);
|
||||
paneListeners.set(paneId, listeners);
|
||||
return () => {
|
||||
const current = paneListeners.get(paneId);
|
||||
if (!current) return;
|
||||
current.delete(listener);
|
||||
if (current.size === 0) {
|
||||
paneListeners.delete(paneId);
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const useSftpTreeSelectionState = (paneId: string): SftpTreeSelectionState => {
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) => sftpTreeSelectionStore.subscribe(paneId, listener),
|
||||
[paneId],
|
||||
);
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => sftpTreeSelectionStore.getPaneState(paneId),
|
||||
() => sftpTreeSelectionStore.getPaneState(paneId),
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { RemoteFile, SftpFileEntry, SftpFilenameEncoding } from "../../../types";
|
||||
import { joinPath as joinFsPath } from "../../../application/state/sftp/utils";
|
||||
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../types";
|
||||
import { getParentPath, joinPath as joinFsPath } from "../../../application/state/sftp/utils";
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { toast } from "../../ui/toast";
|
||||
@@ -21,9 +21,6 @@ interface UseSftpViewFileOpsParams {
|
||||
systemApp?: SystemAppInfo,
|
||||
) => void;
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
listSftp?: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<RemoteFile[]>;
|
||||
mkdirLocal?: (path: string) => Promise<unknown>;
|
||||
deleteLocalFile?: (path: string) => Promise<unknown>;
|
||||
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
|
||||
selectDirectory?: (title?: string, defaultPath?: string) => Promise<string | null>;
|
||||
startStreamTransfer?: (
|
||||
@@ -47,9 +44,9 @@ interface UseSftpViewFileOpsParams {
|
||||
}
|
||||
|
||||
interface UseSftpViewFileOpsResult {
|
||||
permissionsState: { file: SftpFileEntry; side: "left" | "right" } | null;
|
||||
permissionsState: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
|
||||
setPermissionsState: React.Dispatch<
|
||||
React.SetStateAction<{ file: SftpFileEntry; side: "left" | "right" } | null>
|
||||
React.SetStateAction<{ file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null>
|
||||
>;
|
||||
showTextEditor: boolean;
|
||||
setShowTextEditor: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
@@ -89,20 +86,20 @@ interface UseSftpViewFileOpsResult {
|
||||
systemApp?: SystemAppInfo,
|
||||
) => Promise<void>;
|
||||
handleSelectSystemApp: () => Promise<SystemAppInfo | null>;
|
||||
onEditPermissionsLeft: (file: SftpFileEntry) => void;
|
||||
onEditPermissionsRight: (file: SftpFileEntry) => void;
|
||||
onOpenEntryLeft: (entry: SftpFileEntry) => void;
|
||||
onOpenEntryRight: (entry: SftpFileEntry) => void;
|
||||
onEditFileLeft: (file: SftpFileEntry) => void;
|
||||
onEditFileRight: (file: SftpFileEntry) => void;
|
||||
onOpenFileLeft: (file: SftpFileEntry) => void;
|
||||
onOpenFileRight: (file: SftpFileEntry) => void;
|
||||
onOpenFileWithLeft: (file: SftpFileEntry) => void;
|
||||
onOpenFileWithRight: (file: SftpFileEntry) => void;
|
||||
onDownloadFileLeft: (file: SftpFileEntry) => void;
|
||||
onDownloadFileRight: (file: SftpFileEntry) => void;
|
||||
onUploadExternalFilesLeft: (dataTransfer: DataTransfer) => void;
|
||||
onUploadExternalFilesRight: (dataTransfer: DataTransfer) => void;
|
||||
onEditPermissionsLeft: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
onEditPermissionsRight: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
onOpenEntryLeft: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
onOpenEntryRight: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
onEditFileLeft: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
onEditFileRight: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
onOpenFileLeft: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
onOpenFileRight: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
onOpenFileWithLeft: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
onOpenFileWithRight: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
onDownloadFileLeft: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
onDownloadFileRight: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
onUploadExternalFilesLeft: (dataTransfer: DataTransfer, targetPath?: string) => void;
|
||||
onUploadExternalFilesRight: (dataTransfer: DataTransfer, targetPath?: string) => void;
|
||||
}
|
||||
|
||||
export const useSftpViewFileOps = ({
|
||||
@@ -112,9 +109,6 @@ export const useSftpViewFileOps = ({
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
listSftp,
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
startStreamTransfer,
|
||||
@@ -123,6 +117,7 @@ export const useSftpViewFileOps = ({
|
||||
const [permissionsState, setPermissionsState] = useState<{
|
||||
file: SftpFileEntry;
|
||||
side: "left" | "right";
|
||||
fullPath: string;
|
||||
} | null>(null);
|
||||
|
||||
const [showTextEditor, setShowTextEditor] = useState(false);
|
||||
@@ -145,27 +140,49 @@ export const useSftpViewFileOps = ({
|
||||
fullPath: string;
|
||||
} | null>(null);
|
||||
|
||||
// Refs for frequently-changing state used inside stable callbacks
|
||||
const fileOpenerTargetRef = useRef(fileOpenerTarget);
|
||||
fileOpenerTargetRef.current = fileOpenerTarget;
|
||||
const textEditorTargetRef = useRef(textEditorTarget);
|
||||
textEditorTargetRef.current = textEditorTarget;
|
||||
|
||||
const onEditPermissionsLeft = useCallback(
|
||||
(file: SftpFileEntry) => setPermissionsState({ file, side: "left" }),
|
||||
[],
|
||||
(file: SftpFileEntry, fullPath?: string) => {
|
||||
const pane = sftpRef.current.leftPane;
|
||||
if (!pane.connection) return;
|
||||
setPermissionsState({
|
||||
file,
|
||||
side: "left",
|
||||
fullPath: fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name),
|
||||
});
|
||||
},
|
||||
[sftpRef],
|
||||
);
|
||||
const onEditPermissionsRight = useCallback(
|
||||
(file: SftpFileEntry) => setPermissionsState({ file, side: "right" }),
|
||||
[],
|
||||
(file: SftpFileEntry, fullPath?: string) => {
|
||||
const pane = sftpRef.current.rightPane;
|
||||
if (!pane.connection) return;
|
||||
setPermissionsState({
|
||||
file,
|
||||
side: "right",
|
||||
fullPath: fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name),
|
||||
});
|
||||
},
|
||||
[sftpRef],
|
||||
);
|
||||
|
||||
const handleEditFileForSide = useCallback(
|
||||
async (side: "left" | "right", file: SftpFileEntry) => {
|
||||
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
|
||||
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
|
||||
if (!pane.connection) return;
|
||||
|
||||
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
|
||||
try {
|
||||
setLoadingTextContent(true);
|
||||
setTextEditorTarget({ file, side, fullPath, hostId: pane.connection.hostId });
|
||||
setTextEditorTarget({ file, side, fullPath: resolvedFullPath, hostId: pane.connection.hostId });
|
||||
|
||||
const content = await sftpRef.current.readTextFile(side, fullPath);
|
||||
const content = await sftpRef.current.readTextFile(side, resolvedFullPath);
|
||||
|
||||
setTextEditorContent(content);
|
||||
setShowTextEditor(true);
|
||||
@@ -180,22 +197,22 @@ export const useSftpViewFileOps = ({
|
||||
);
|
||||
|
||||
const handleOpenFileForSide = useCallback(
|
||||
async (side: "left" | "right", file: SftpFileEntry) => {
|
||||
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
|
||||
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
|
||||
if (!pane.connection) return;
|
||||
|
||||
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
const savedOpener = getOpenerForFileRef.current(file.name);
|
||||
|
||||
if (savedOpener && savedOpener.openerType) {
|
||||
if (savedOpener.openerType === "builtin-editor") {
|
||||
handleEditFileForSide(side, file);
|
||||
handleEditFileForSide(side, file, resolvedFullPath);
|
||||
return;
|
||||
} else if (savedOpener.openerType === "system-app" && savedOpener.systemApp) {
|
||||
try {
|
||||
await sftpRef.current.downloadToTempAndOpen(
|
||||
side,
|
||||
fullPath,
|
||||
resolvedFullPath,
|
||||
file.name,
|
||||
savedOpener.systemApp.path,
|
||||
{ enableWatch: autoSyncRef.current },
|
||||
@@ -207,7 +224,7 @@ export const useSftpViewFileOps = ({
|
||||
}
|
||||
}
|
||||
|
||||
setFileOpenerTarget({ file, side, fullPath });
|
||||
setFileOpenerTarget({ file, side, fullPath: resolvedFullPath });
|
||||
setShowFileOpenerDialog(true);
|
||||
},
|
||||
[sftpRef, handleEditFileForSide, getOpenerForFileRef, autoSyncRef],
|
||||
@@ -215,23 +232,24 @@ export const useSftpViewFileOps = ({
|
||||
|
||||
const handleFileOpenerSelect = useCallback(
|
||||
async (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => {
|
||||
if (!fileOpenerTarget) return;
|
||||
const target = fileOpenerTargetRef.current;
|
||||
if (!target) return;
|
||||
|
||||
if (setAsDefault) {
|
||||
const ext = getFileExtension(fileOpenerTarget.file.name);
|
||||
const ext = getFileExtension(target.file.name);
|
||||
setOpenerForExtension(ext, openerType, systemApp);
|
||||
}
|
||||
|
||||
setShowFileOpenerDialog(false);
|
||||
|
||||
if (openerType === "builtin-editor") {
|
||||
handleEditFileForSide(fileOpenerTarget.side, fileOpenerTarget.file);
|
||||
handleEditFileForSide(target.side, target.file, target.fullPath);
|
||||
} else if (openerType === "system-app" && systemApp) {
|
||||
try {
|
||||
await sftpRef.current.downloadToTempAndOpen(
|
||||
fileOpenerTarget.side,
|
||||
fileOpenerTarget.fullPath,
|
||||
fileOpenerTarget.file.name,
|
||||
target.side,
|
||||
target.fullPath,
|
||||
target.file.name,
|
||||
systemApp.path,
|
||||
{ enableWatch: autoSyncRef.current },
|
||||
);
|
||||
@@ -242,7 +260,7 @@ export const useSftpViewFileOps = ({
|
||||
|
||||
setFileOpenerTarget(null);
|
||||
},
|
||||
[fileOpenerTarget, setOpenerForExtension, handleEditFileForSide, autoSyncRef, sftpRef],
|
||||
[setOpenerForExtension, handleEditFileForSide, autoSyncRef, sftpRef],
|
||||
);
|
||||
|
||||
const handleSelectSystemApp = useCallback(async (): Promise<SystemAppInfo | null> => {
|
||||
@@ -255,7 +273,8 @@ export const useSftpViewFileOps = ({
|
||||
|
||||
const handleSaveTextFile = useCallback(
|
||||
async (content: string) => {
|
||||
if (!textEditorTarget) return;
|
||||
const target = textEditorTargetRef.current;
|
||||
if (!target) return;
|
||||
|
||||
// Verify the SFTP connection hasn't switched to a different host.
|
||||
// We check hostId (not connectionId) because auto-reconnect after a
|
||||
@@ -263,64 +282,64 @@ export const useSftpViewFileOps = ({
|
||||
// endpoint. The auto-connect effect in SftpSidePanel blocks
|
||||
// host-switching while the editor is open, so a hostId mismatch here
|
||||
// reliably indicates a genuinely different endpoint.
|
||||
const currentPane = textEditorTarget.side === "left"
|
||||
const currentPane = target.side === "left"
|
||||
? sftpRef.current.leftPane
|
||||
: sftpRef.current.rightPane;
|
||||
if (textEditorTarget.hostId && currentPane.connection?.hostId !== textEditorTarget.hostId) {
|
||||
if (target.hostId && currentPane.connection?.hostId !== target.hostId) {
|
||||
throw new Error("SFTP connection changed while editing — file not saved to prevent writing to wrong host");
|
||||
}
|
||||
|
||||
await sftpRef.current.writeTextFile(
|
||||
textEditorTarget.side,
|
||||
textEditorTarget.fullPath,
|
||||
target.side,
|
||||
target.fullPath,
|
||||
content,
|
||||
);
|
||||
},
|
||||
[textEditorTarget, sftpRef],
|
||||
[sftpRef],
|
||||
);
|
||||
|
||||
const onEditFileLeft = useCallback(
|
||||
(file: SftpFileEntry) => handleEditFileForSide("left", file),
|
||||
(file: SftpFileEntry, fullPath?: string) => handleEditFileForSide("left", file, fullPath),
|
||||
[handleEditFileForSide],
|
||||
);
|
||||
const onEditFileRight = useCallback(
|
||||
(file: SftpFileEntry) => handleEditFileForSide("right", file),
|
||||
(file: SftpFileEntry, fullPath?: string) => handleEditFileForSide("right", file, fullPath),
|
||||
[handleEditFileForSide],
|
||||
);
|
||||
const onOpenFileLeft = useCallback(
|
||||
(file: SftpFileEntry) => handleOpenFileForSide("left", file),
|
||||
(file: SftpFileEntry, fullPath?: string) => handleOpenFileForSide("left", file, fullPath),
|
||||
[handleOpenFileForSide],
|
||||
);
|
||||
const onOpenFileRight = useCallback(
|
||||
(file: SftpFileEntry) => handleOpenFileForSide("right", file),
|
||||
(file: SftpFileEntry, fullPath?: string) => handleOpenFileForSide("right", file, fullPath),
|
||||
[handleOpenFileForSide],
|
||||
);
|
||||
|
||||
const handleOpenFileWithForSide = useCallback(
|
||||
(side: "left" | "right", file: SftpFileEntry) => {
|
||||
(side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
|
||||
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
|
||||
if (!pane.connection) return;
|
||||
|
||||
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
setFileOpenerTarget({ file, side, fullPath });
|
||||
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
setFileOpenerTarget({ file, side, fullPath: resolvedFullPath });
|
||||
setShowFileOpenerDialog(true);
|
||||
},
|
||||
[sftpRef],
|
||||
);
|
||||
|
||||
const onOpenFileWithLeft = useCallback(
|
||||
(file: SftpFileEntry) => handleOpenFileWithForSide("left", file),
|
||||
(file: SftpFileEntry, fullPath?: string) => handleOpenFileWithForSide("left", file, fullPath),
|
||||
[handleOpenFileWithForSide],
|
||||
);
|
||||
const onOpenFileWithRight = useCallback(
|
||||
(file: SftpFileEntry) => handleOpenFileWithForSide("right", file),
|
||||
(file: SftpFileEntry, fullPath?: string) => handleOpenFileWithForSide("right", file, fullPath),
|
||||
[handleOpenFileWithForSide],
|
||||
);
|
||||
|
||||
const handleUploadExternalFilesForSide = useCallback(
|
||||
async (side: "left" | "right", dataTransfer: DataTransfer) => {
|
||||
async (side: "left" | "right", dataTransfer: DataTransfer, targetPath?: string) => {
|
||||
try {
|
||||
const results = await sftpRef.current.uploadExternalFiles(side, dataTransfer);
|
||||
const results = await sftpRef.current.uploadExternalFiles(side, dataTransfer, targetPath);
|
||||
|
||||
// Check if upload was cancelled
|
||||
if (results.some((r) => r.cancelled)) {
|
||||
@@ -359,21 +378,21 @@ export const useSftpViewFileOps = ({
|
||||
);
|
||||
|
||||
const onUploadExternalFilesLeft = useCallback(
|
||||
(dataTransfer: DataTransfer) => handleUploadExternalFilesForSide("left", dataTransfer),
|
||||
(dataTransfer: DataTransfer, targetPath?: string) => handleUploadExternalFilesForSide("left", dataTransfer, targetPath),
|
||||
[handleUploadExternalFilesForSide],
|
||||
);
|
||||
|
||||
const onUploadExternalFilesRight = useCallback(
|
||||
(dataTransfer: DataTransfer) => handleUploadExternalFilesForSide("right", dataTransfer),
|
||||
(dataTransfer: DataTransfer, targetPath?: string) => handleUploadExternalFilesForSide("right", dataTransfer, targetPath),
|
||||
[handleUploadExternalFilesForSide],
|
||||
);
|
||||
|
||||
const handleDownloadFileForSide = useCallback(
|
||||
async (side: "left" | "right", file: SftpFileEntry) => {
|
||||
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
|
||||
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
|
||||
if (!pane.connection) return;
|
||||
|
||||
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
const isDirectory = isNavigableDirectory(file);
|
||||
|
||||
try {
|
||||
@@ -384,7 +403,7 @@ export const useSftpViewFileOps = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await sftpRef.current.readBinaryFile(side, fullPath);
|
||||
const content = await sftpRef.current.readBinaryFile(side, resolvedFullPath);
|
||||
|
||||
const blob = new Blob([content], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -412,7 +431,7 @@ export const useSftpViewFileOps = ({
|
||||
}
|
||||
|
||||
if (isDirectory) {
|
||||
if (!listSftp || !mkdirLocal || !selectDirectory) {
|
||||
if (!selectDirectory) {
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
@@ -422,402 +441,30 @@ export const useSftpViewFileOps = ({
|
||||
|
||||
const targetPath = joinFsPath(selectedDirectory, file.name);
|
||||
|
||||
const transferId = `download-dir-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
let completedBytes = 0;
|
||||
const MAX_SYMLINK_DEPTH = 32;
|
||||
const DIRECTORY_DOWNLOAD_MAX_CONCURRENCY = 10;
|
||||
const activeChildTransferIds = new Set<string>();
|
||||
const activeFileProgress = new Map<string, { transferred: number; speed: number }>();
|
||||
const activeFileSizes = new Map<string, number>();
|
||||
const visitedPaths = new Set<string>();
|
||||
const directoryTaskQueue: Array<{
|
||||
type: "directory";
|
||||
remotePath: string;
|
||||
localPath: string;
|
||||
symlinkDepth: number;
|
||||
}> = [];
|
||||
const fileTaskQueue: Array<{
|
||||
type: "file";
|
||||
remotePath: string;
|
||||
localPath: string;
|
||||
size: number;
|
||||
}> = [];
|
||||
let pendingDirectoryTasks = 0;
|
||||
let discoveredTotalBytes = 0;
|
||||
let estimatedTotalBytes = 0;
|
||||
let activeQueueTasks = 0;
|
||||
|
||||
const isTaskCancelled = () =>
|
||||
sftpRef.current.transfers.some(
|
||||
(task) => task.id === transferId && task.status === "cancelled",
|
||||
);
|
||||
|
||||
const updateAggregateProgress = () => {
|
||||
let activeTransferredBytes = 0;
|
||||
let activeSpeed = 0;
|
||||
|
||||
for (const progress of activeFileProgress.values()) {
|
||||
activeTransferredBytes += progress.transferred;
|
||||
activeSpeed += progress.speed;
|
||||
}
|
||||
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
fileName: pendingDirectoryTasks > 0 ? `${file.name} (${t("sftp.upload.scanning")})` : file.name,
|
||||
transferredBytes: completedBytes + activeTransferredBytes,
|
||||
totalBytes: estimatedTotalBytes > 0 ? estimatedTotalBytes : 0,
|
||||
speed: activeSpeed,
|
||||
});
|
||||
};
|
||||
|
||||
const cancelActiveChildTransfers = async () => {
|
||||
await Promise.all(
|
||||
Array.from(activeChildTransferIds).map((childTransferId) =>
|
||||
sftpRef.current.cancelTransfer(childTransferId).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const maybeFinalizeDiscovery = () => {
|
||||
if (pendingDirectoryTasks === 0) {
|
||||
estimatedTotalBytes = discoveredTotalBytes;
|
||||
updateAggregateProgress();
|
||||
}
|
||||
};
|
||||
|
||||
const getDynamicConcurrencyLimit = () => {
|
||||
let largeFiles = 0;
|
||||
let mediumFiles = 0;
|
||||
|
||||
for (const size of activeFileSizes.values()) {
|
||||
if (size >= 32 * 1024 * 1024) largeFiles += 1;
|
||||
else if (size >= 1 * 1024 * 1024) mediumFiles += 1;
|
||||
}
|
||||
|
||||
if (largeFiles > 0) return 2;
|
||||
if (mediumFiles >= 2) return 4;
|
||||
if (mediumFiles === 1) return 5;
|
||||
return DIRECTORY_DOWNLOAD_MAX_CONCURRENCY;
|
||||
};
|
||||
|
||||
const enqueueDirectoryTask = (task: {
|
||||
type: "directory";
|
||||
remotePath: string;
|
||||
localPath: string;
|
||||
symlinkDepth: number;
|
||||
}) => {
|
||||
directoryTaskQueue.push(task);
|
||||
};
|
||||
|
||||
const enqueueFileTask = (task: {
|
||||
type: "file";
|
||||
remotePath: string;
|
||||
localPath: string;
|
||||
size: number;
|
||||
}) => {
|
||||
const insertIndex = fileTaskQueue.findIndex((queuedTask) => queuedTask.size > task.size);
|
||||
if (insertIndex === -1) {
|
||||
fileTaskQueue.push(task);
|
||||
} else {
|
||||
fileTaskQueue.splice(insertIndex, 0, task);
|
||||
}
|
||||
};
|
||||
|
||||
const dequeueTask = () => {
|
||||
if (pendingDirectoryTasks > 0 && directoryTaskQueue.length > 0) {
|
||||
return directoryTaskQueue.shift() ?? null;
|
||||
}
|
||||
if (fileTaskQueue.length > 0) return fileTaskQueue.shift() ?? null;
|
||||
if (directoryTaskQueue.length > 0) return directoryTaskQueue.shift() ?? null;
|
||||
return null;
|
||||
};
|
||||
|
||||
const processFileTask = async (task: {
|
||||
type: "file";
|
||||
remotePath: string;
|
||||
localPath: string;
|
||||
size: number;
|
||||
}) => {
|
||||
const childTransferId = `download-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
activeChildTransferIds.add(childTransferId);
|
||||
activeFileSizes.set(childTransferId, task.size);
|
||||
activeFileProgress.set(childTransferId, { transferred: 0, speed: 0 });
|
||||
updateAggregateProgress();
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
startStreamTransfer(
|
||||
{
|
||||
transferId: childTransferId,
|
||||
sourcePath: task.remotePath,
|
||||
targetPath: task.localPath,
|
||||
sourceType: "sftp",
|
||||
targetType: "local",
|
||||
sourceSftpId: sftpId,
|
||||
totalBytes: task.size,
|
||||
sourceEncoding: pane.filenameEncoding,
|
||||
},
|
||||
(transferred, _total, speed) => {
|
||||
if (isTaskCancelled()) {
|
||||
sftpRef.current.cancelTransfer(childTransferId).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
activeFileProgress.set(childTransferId, {
|
||||
transferred,
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
|
||||
});
|
||||
updateAggregateProgress();
|
||||
},
|
||||
() => {
|
||||
completedBytes += task.size;
|
||||
activeChildTransferIds.delete(childTransferId);
|
||||
activeFileSizes.delete(childTransferId);
|
||||
activeFileProgress.delete(childTransferId);
|
||||
updateAggregateProgress();
|
||||
resolve();
|
||||
},
|
||||
(error) => {
|
||||
activeChildTransferIds.delete(childTransferId);
|
||||
activeFileSizes.delete(childTransferId);
|
||||
activeFileProgress.delete(childTransferId);
|
||||
updateAggregateProgress();
|
||||
reject(new Error(error));
|
||||
},
|
||||
)
|
||||
.then((result) => {
|
||||
if (result === undefined) {
|
||||
activeChildTransferIds.delete(childTransferId);
|
||||
activeFileSizes.delete(childTransferId);
|
||||
activeFileProgress.delete(childTransferId);
|
||||
updateAggregateProgress();
|
||||
reject(new Error("Stream transfer unavailable"));
|
||||
} else if (result.error) {
|
||||
activeChildTransferIds.delete(childTransferId);
|
||||
activeFileSizes.delete(childTransferId);
|
||||
activeFileProgress.delete(childTransferId);
|
||||
updateAggregateProgress();
|
||||
reject(new Error(result.error));
|
||||
}
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
} finally {
|
||||
activeChildTransferIds.delete(childTransferId);
|
||||
activeFileSizes.delete(childTransferId);
|
||||
activeFileProgress.delete(childTransferId);
|
||||
}
|
||||
};
|
||||
|
||||
const processDirectoryTask = async (task: {
|
||||
type: "directory";
|
||||
remotePath: string;
|
||||
localPath: string;
|
||||
symlinkDepth: number;
|
||||
}) => {
|
||||
if (visitedPaths.has(task.remotePath)) {
|
||||
pendingDirectoryTasks -= 1;
|
||||
maybeFinalizeDiscovery();
|
||||
return;
|
||||
}
|
||||
|
||||
visitedPaths.add(task.remotePath);
|
||||
|
||||
if (isTaskCancelled()) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const entries = await listSftp(sftpId, task.remotePath, pane.filenameEncoding);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name === ".." || entry.name === ".") continue;
|
||||
|
||||
if (isTaskCancelled()) {
|
||||
await cancelActiveChildTransfers();
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const remoteEntryPath = sftpRef.current.joinPath(task.remotePath, entry.name);
|
||||
const localEntryPath = joinFsPath(task.localPath, entry.name);
|
||||
const isRealDir = entry.type === "directory";
|
||||
const isSymlinkDir =
|
||||
entry.type === "symlink" && entry.linkTarget === "directory";
|
||||
|
||||
if (isRealDir || isSymlinkDir) {
|
||||
if (isSymlinkDir && task.symlinkDepth >= MAX_SYMLINK_DEPTH) {
|
||||
throw new Error(
|
||||
"Maximum symlink directory depth exceeded (possible symlink cycle)",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await mkdirLocal(localEntryPath);
|
||||
} catch (mkdirErr: unknown) {
|
||||
const isEEXIST =
|
||||
mkdirErr instanceof Error && mkdirErr.message.includes("EEXIST");
|
||||
if (!isEEXIST) throw mkdirErr;
|
||||
}
|
||||
|
||||
pendingDirectoryTasks += 1;
|
||||
enqueueDirectoryTask({
|
||||
type: "directory",
|
||||
remotePath: remoteEntryPath,
|
||||
localPath: localEntryPath,
|
||||
symlinkDepth: isSymlinkDir ? task.symlinkDepth + 1 : task.symlinkDepth,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const entrySize =
|
||||
typeof entry.size === "string"
|
||||
? parseInt(String(entry.size), 10) || 0
|
||||
: entry.size || 0;
|
||||
discoveredTotalBytes += entrySize;
|
||||
enqueueFileTask({
|
||||
type: "file",
|
||||
remotePath: remoteEntryPath,
|
||||
localPath: localEntryPath,
|
||||
size: entrySize,
|
||||
});
|
||||
}
|
||||
|
||||
pendingDirectoryTasks -= 1;
|
||||
maybeFinalizeDiscovery();
|
||||
};
|
||||
|
||||
const runQueue = async () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
const pump = () => {
|
||||
if (settled) return;
|
||||
|
||||
if (isTaskCancelled()) {
|
||||
settled = true;
|
||||
void cancelActiveChildTransfers().finally(() =>
|
||||
reject(new Error("Transfer cancelled")),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
while (
|
||||
activeQueueTasks < getDynamicConcurrencyLimit()
|
||||
) {
|
||||
const nextTask = dequeueTask();
|
||||
if (!nextTask) break;
|
||||
|
||||
activeQueueTasks += 1;
|
||||
Promise.resolve(
|
||||
nextTask.type === "directory"
|
||||
? processDirectoryTask(nextTask)
|
||||
: processFileTask(nextTask),
|
||||
)
|
||||
.then(() => {
|
||||
activeQueueTasks -= 1;
|
||||
if (
|
||||
!settled &&
|
||||
fileTaskQueue.length === 0 &&
|
||||
directoryTaskQueue.length === 0 &&
|
||||
activeQueueTasks === 0 &&
|
||||
pendingDirectoryTasks === 0
|
||||
) {
|
||||
settled = true;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
pump();
|
||||
})
|
||||
.catch((error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
void cancelActiveChildTransfers().finally(() => reject(error));
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!settled &&
|
||||
fileTaskQueue.length === 0 &&
|
||||
directoryTaskQueue.length === 0 &&
|
||||
activeQueueTasks === 0 &&
|
||||
pendingDirectoryTasks === 0
|
||||
) {
|
||||
settled = true;
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
pump();
|
||||
});
|
||||
|
||||
sftpRef.current.addExternalUpload({
|
||||
id: transferId,
|
||||
fileName: `${file.name} (${t("sftp.upload.scanning")})`,
|
||||
sourcePath: fullPath,
|
||||
targetPath,
|
||||
sourceConnectionId: pane.connection.id,
|
||||
targetConnectionId: "local",
|
||||
direction: "download",
|
||||
status: "transferring",
|
||||
totalBytes: 0,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: true,
|
||||
retryable: false,
|
||||
});
|
||||
|
||||
try {
|
||||
try {
|
||||
await mkdirLocal(targetPath);
|
||||
} catch (mkdirErr: unknown) {
|
||||
const isEEXIST =
|
||||
mkdirErr instanceof Error && mkdirErr.message.includes("EEXIST");
|
||||
if (isEEXIST && deleteLocalFile) {
|
||||
await deleteLocalFile(targetPath);
|
||||
await mkdirLocal(targetPath);
|
||||
} else {
|
||||
throw mkdirErr;
|
||||
}
|
||||
}
|
||||
|
||||
pendingDirectoryTasks = 1;
|
||||
enqueueDirectoryTask({
|
||||
type: "directory",
|
||||
remotePath: fullPath,
|
||||
localPath: targetPath,
|
||||
symlinkDepth: 0,
|
||||
});
|
||||
await runQueue();
|
||||
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
status: "completed",
|
||||
const status = await sftpRef.current.downloadToLocal({
|
||||
fileName: file.name,
|
||||
transferredBytes: completedBytes,
|
||||
totalBytes: estimatedTotalBytes > 0 ? estimatedTotalBytes : completedBytes,
|
||||
speed: 0,
|
||||
endTime: Date.now(),
|
||||
sourcePath: resolvedFullPath,
|
||||
targetPath,
|
||||
sftpId,
|
||||
connectionId: pane.connection.id,
|
||||
sourceEncoding: pane.filenameEncoding,
|
||||
isDirectory: true,
|
||||
});
|
||||
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
|
||||
if (status === "completed") {
|
||||
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
|
||||
} else if (status === "failed") {
|
||||
toast.error(`${t("sftp.error.downloadFailed")}: ${file.name}`, "SFTP");
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : t("sftp.error.downloadFailed");
|
||||
const isCancelled =
|
||||
errorMessage.includes("cancelled") || errorMessage.includes("canceled");
|
||||
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
status: isCancelled ? "cancelled" : "failed",
|
||||
error: isCancelled ? undefined : errorMessage,
|
||||
speed: 0,
|
||||
endTime: Date.now(),
|
||||
});
|
||||
|
||||
if (!isCancelled) {
|
||||
const errorMessage = error instanceof Error ? error.message : t("sftp.error.downloadFailed");
|
||||
if (!errorMessage.includes("cancelled") && !errorMessage.includes("canceled")) {
|
||||
toast.error(errorMessage, "SFTP");
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Show save dialog to get target path
|
||||
const targetPath = await showSaveDialog(file.name);
|
||||
if (!targetPath) {
|
||||
@@ -832,7 +479,7 @@ export const useSftpViewFileOps = ({
|
||||
sftpRef.current.addExternalUpload({
|
||||
id: transferId,
|
||||
fileName: file.name,
|
||||
sourcePath: fullPath,
|
||||
sourcePath: resolvedFullPath,
|
||||
targetPath,
|
||||
sourceConnectionId: pane.connection.id,
|
||||
targetConnectionId: 'local',
|
||||
@@ -851,7 +498,7 @@ export const useSftpViewFileOps = ({
|
||||
const result = await startStreamTransfer(
|
||||
{
|
||||
transferId,
|
||||
sourcePath: fullPath,
|
||||
sourcePath: resolvedFullPath,
|
||||
targetPath,
|
||||
sourceType: 'sftp',
|
||||
targetType: 'local',
|
||||
@@ -925,9 +572,6 @@ export const useSftpViewFileOps = ({
|
||||
[
|
||||
sftpRef,
|
||||
t,
|
||||
listSftp,
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
startStreamTransfer,
|
||||
@@ -936,17 +580,18 @@ export const useSftpViewFileOps = ({
|
||||
);
|
||||
|
||||
const onDownloadFileLeft = useCallback(
|
||||
(file: SftpFileEntry) => handleDownloadFileForSide("left", file),
|
||||
(file: SftpFileEntry, fullPath?: string) => handleDownloadFileForSide("left", file, fullPath),
|
||||
[handleDownloadFileForSide],
|
||||
);
|
||||
|
||||
const onDownloadFileRight = useCallback(
|
||||
(file: SftpFileEntry) => handleDownloadFileForSide("right", file),
|
||||
(file: SftpFileEntry, fullPath?: string) => handleDownloadFileForSide("right", file, fullPath),
|
||||
[handleDownloadFileForSide],
|
||||
);
|
||||
|
||||
const onOpenEntryLeft = useCallback(
|
||||
(entry: SftpFileEntry) => {
|
||||
(entry: SftpFileEntry, fullPath?: string) => {
|
||||
const pane = sftpRef.current.leftPane;
|
||||
const isDir = isNavigableDirectory(entry);
|
||||
|
||||
if (entry.name === ".." || isDir) {
|
||||
@@ -955,20 +600,28 @@ export const useSftpViewFileOps = ({
|
||||
}
|
||||
|
||||
if (behaviorRef.current === "transfer") {
|
||||
const sourcePath = fullPath ? getParentPath(fullPath) : pane.connection?.currentPath;
|
||||
const sourceConnectionId = pane.connection?.id;
|
||||
const fileData = [{
|
||||
name: entry.name,
|
||||
isDirectory: isDir,
|
||||
sourceConnectionId,
|
||||
sourcePath,
|
||||
}];
|
||||
sftpRef.current.startTransfer(fileData, "left", "right");
|
||||
sftpRef.current.startTransfer(fileData, "left", "right", {
|
||||
sourceConnectionId,
|
||||
sourcePath,
|
||||
});
|
||||
} else {
|
||||
onOpenFileLeft(entry);
|
||||
onOpenFileLeft(entry, fullPath);
|
||||
}
|
||||
},
|
||||
[sftpRef, onOpenFileLeft, behaviorRef],
|
||||
);
|
||||
|
||||
const onOpenEntryRight = useCallback(
|
||||
(entry: SftpFileEntry) => {
|
||||
(entry: SftpFileEntry, fullPath?: string) => {
|
||||
const pane = sftpRef.current.rightPane;
|
||||
const isDir = isNavigableDirectory(entry);
|
||||
|
||||
if (entry.name === ".." || isDir) {
|
||||
@@ -977,13 +630,20 @@ export const useSftpViewFileOps = ({
|
||||
}
|
||||
|
||||
if (behaviorRef.current === "transfer") {
|
||||
const sourcePath = fullPath ? getParentPath(fullPath) : pane.connection?.currentPath;
|
||||
const sourceConnectionId = pane.connection?.id;
|
||||
const fileData = [{
|
||||
name: entry.name,
|
||||
isDirectory: isDir,
|
||||
sourceConnectionId,
|
||||
sourcePath,
|
||||
}];
|
||||
sftpRef.current.startTransfer(fileData, "right", "left");
|
||||
sftpRef.current.startTransfer(fileData, "right", "left", {
|
||||
sourceConnectionId,
|
||||
sourcePath,
|
||||
});
|
||||
} else {
|
||||
onOpenFileRight(entry);
|
||||
onOpenFileRight(entry, fullPath);
|
||||
}
|
||||
},
|
||||
[sftpRef, onOpenFileRight, behaviorRef],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user