Compare commits
165 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1408b8050 | ||
|
|
9ca68561b3 | ||
|
|
c3c579b8a0 | ||
|
|
2784ecdf28 | ||
|
|
75bbd1f300 | ||
|
|
4ee4ef7b60 | ||
|
|
32f4aadab2 | ||
|
|
fc32b44d8e | ||
|
|
76cd1f2883 | ||
|
|
76d37d982a | ||
|
|
6d2f3f28c0 | ||
|
|
a1c9f5fbd0 | ||
|
|
ce5cb2afec | ||
|
|
c771979178 | ||
|
|
58c651500e | ||
|
|
bcf653dd2e | ||
|
|
0caf19af7e | ||
|
|
e8b9122270 | ||
|
|
60071424d0 | ||
|
|
51abe7da63 | ||
|
|
9667c03ddc | ||
|
|
9935eb2ed1 | ||
|
|
268b698a39 | ||
|
|
2491d1a177 | ||
|
|
2bf2220d0b | ||
|
|
683756324e | ||
|
|
80fbf0da2f | ||
|
|
556a14178c | ||
|
|
7e566efe9c | ||
|
|
1d2489b02c | ||
|
|
5ad3d0ce32 | ||
|
|
edf013164b | ||
|
|
504b576e1c | ||
|
|
890abd1c4c | ||
|
|
0827dd416f | ||
|
|
24df4b6548 | ||
|
|
7db4b18cce | ||
|
|
844c55e99d | ||
|
|
778b43ceff | ||
|
|
6b2e5041d2 | ||
|
|
1464cba6da | ||
|
|
d74d9e28a0 | ||
|
|
32b74f4fea | ||
|
|
f284fb0505 | ||
|
|
1769edb881 | ||
|
|
a7873672c5 | ||
|
|
d2fe0ecefe | ||
|
|
3261e481ee | ||
|
|
3dfc84918b | ||
|
|
3dc9581be6 | ||
|
|
4e7d69c9ff | ||
|
|
7649243021 | ||
|
|
b770dbe6f5 | ||
|
|
1e0979e441 | ||
|
|
9dbd2a5cf7 | ||
|
|
702700d93c | ||
|
|
0413e02bf0 | ||
|
|
1cccbfe5fb | ||
|
|
1c5960a054 | ||
|
|
2ae1219bb7 | ||
|
|
591b2ba010 | ||
|
|
e26f1350f5 | ||
|
|
d36fc2db1b | ||
|
|
32ebc01552 | ||
|
|
6f93a741ff | ||
|
|
d77b0531f6 | ||
|
|
0bc45417c7 | ||
|
|
fd88b3a36b | ||
|
|
6ac36be04b | ||
|
|
8ed1588fdb | ||
|
|
762255443b | ||
|
|
fdf38b0a6a | ||
|
|
be80741314 | ||
|
|
7efb6d2adb | ||
|
|
33f8221d5c | ||
|
|
f7eeb855aa | ||
|
|
a87a4ff09f | ||
|
|
fbb6cf4dd3 | ||
|
|
cceae92f97 | ||
|
|
2f314c3588 | ||
|
|
84fd2c46f6 | ||
|
|
31dd757729 | ||
|
|
cb79036d96 | ||
|
|
32a208eec5 | ||
|
|
6cbe1be5c5 | ||
|
|
c7ae51b952 | ||
|
|
df11beff8c | ||
|
|
c14da33e5b | ||
|
|
f1ce541885 | ||
|
|
07e003fe43 | ||
|
|
81f53c9a7f | ||
|
|
2d8cea2e7d | ||
|
|
b724cfc775 | ||
|
|
10ff2cc092 | ||
|
|
4124c03b80 | ||
|
|
56a3994a52 | ||
|
|
e1e730e439 | ||
|
|
bb17647954 | ||
|
|
56a0baebeb | ||
|
|
d2a6c67e4e | ||
|
|
56f70d015d | ||
|
|
cf9f84767c | ||
|
|
3a862cbd0c | ||
|
|
6af2a99680 | ||
|
|
b3d37d134a | ||
|
|
a9e561ee51 | ||
|
|
e808b1709e | ||
|
|
d75b58e4d8 | ||
|
|
e2430cdcab | ||
|
|
8e6ac8de10 | ||
|
|
5495877e5a | ||
|
|
5078b3776e | ||
|
|
f5d6b8b4d8 | ||
|
|
1c560dbc16 | ||
|
|
4b8b0ed74c | ||
|
|
308d825db7 | ||
|
|
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 |
15
.github/workflows/build.yml
vendored
15
.github/workflows/build.yml
vendored
@@ -43,6 +43,21 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Install cross-platform native binaries
|
||||
shell: bash
|
||||
run: |
|
||||
# npm ci only installs optional deps for the host platform, but
|
||||
# electron-builder produces both arm64 and x64 binaries, so we
|
||||
# need the native codex-acp binary for the other architecture too.
|
||||
# Platform-specific codex-acp packages declare cpu/os constraints,
|
||||
# so --force is needed to install the non-host-arch binary.
|
||||
CODEX_VER=$(node -e "console.log(require('./node_modules/@zed-industries/codex-acp/package.json').version)")
|
||||
if [[ "${{ matrix.name }}" == "macos" ]]; then
|
||||
npm install "@zed-industries/codex-acp-darwin-x64@${CODEX_VER}" "@zed-industries/codex-acp-darwin-arm64@${CODEX_VER}" --no-save --force
|
||||
elif [[ "${{ matrix.name }}" == "windows" ]]; then
|
||||
npm install "@zed-industries/codex-acp-win32-x64@${CODEX_VER}" "@zed-industries/codex-acp-win32-arm64@${CODEX_VER}" --no-save --force
|
||||
fi
|
||||
|
||||
- name: Set version
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -18,7 +18,7 @@ This project is wired around three layers: domain (pure logic), application stat
|
||||
- **UI** (`components/`, `App.tsx`): Presentation; depends on hooks and domain helpers only.
|
||||
|
||||
## How Things Talk
|
||||
- UI calls application hooks → hooks call domain helpers → persistence/config via infrastructure adapters.
|
||||
- UI calls application hooks -> hooks call domain helpers -> persistence/config via infrastructure adapters.
|
||||
- `App.tsx` wires hooks to components; no business logic should live in components beyond view glue.
|
||||
- Local storage keys are centralized in `infrastructure/config/storageKeys.ts`; avoid ad-hoc `localStorage` calls elsewhere.
|
||||
|
||||
@@ -44,6 +44,12 @@ This project is wired around three layers: domain (pure logic), application stat
|
||||
- Avoid direct network/fetch in components; add a service/adaptor first.
|
||||
- Maintain ASCII-only unless required by existing file content.
|
||||
|
||||
## Review Boundaries
|
||||
- Treat `electron/cli/*`, `netcatty-tool-cli`, the CLI discovery file, and the local TCP bridge as internal Netcatty integration surfaces unless a task explicitly says otherwise.
|
||||
- Do not review those surfaces as public APIs by default, and do not assume they must support third-party callers, manual launches, or non-Netcatty agents.
|
||||
- On supported first-party paths, assume Netcatty's own launcher provides required integration environment such as `NETCATTY_TOOL_CLI_DISCOVERY_FILE`.
|
||||
- If a review concern depends on external exposure, third-party compatibility, or public API stability, call it out as out of scope unless the task explicitly includes that contract.
|
||||
|
||||
---
|
||||
|
||||
## Aside Panel Design System
|
||||
@@ -54,20 +60,20 @@ VaultView subpages (Hosts, Keychain, Port Forwarding, Snippets, Known Hosts) sha
|
||||
|
||||
Import from `./ui/aside-panel`:
|
||||
```tsx
|
||||
import {
|
||||
AsidePanel,
|
||||
AsidePanelHeader,
|
||||
AsidePanelContent,
|
||||
import {
|
||||
AsidePanel,
|
||||
AsidePanelHeader,
|
||||
AsidePanelContent,
|
||||
AsidePanelFooter,
|
||||
AsideActionMenu,
|
||||
AsideActionMenuItem
|
||||
AsideActionMenuItem
|
||||
} from "./ui/aside-panel";
|
||||
```
|
||||
|
||||
### Basic Usage
|
||||
```tsx
|
||||
<AsidePanel
|
||||
open={isOpen}
|
||||
<AsidePanel
|
||||
open={isOpen}
|
||||
onClose={handleClose}
|
||||
title="Panel Title"
|
||||
subtitle="Optional subtitle"
|
||||
301
App.tsx
301
App.tsx
@@ -14,6 +14,7 @@ import { initializeFonts } from './application/state/fontStore';
|
||||
import { initializeUIFonts } from './application/state/uiFontStore';
|
||||
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
|
||||
import { matchesKeyBinding } from './domain/models';
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
@@ -23,18 +24,21 @@ import { applySyncPayload } from './application/syncPayload';
|
||||
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
|
||||
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
|
||||
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
|
||||
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
|
||||
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';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './components/ui/dialog';
|
||||
import { Input } from './components/ui/input';
|
||||
import { Label } from './components/ui/label';
|
||||
import { ToastProvider, toast } from './components/ui/toast';
|
||||
import { VaultView, VaultSection } from './components/VaultView';
|
||||
import { QuickAddSnippetDialog } from './components/QuickAddSnippetDialog';
|
||||
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
|
||||
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
|
||||
import { cn } from './lib/utils';
|
||||
import { classifyLocalShellType } from './lib/localShell';
|
||||
import { useDiscoveredShells, resolveShellSetting } from './lib/useDiscoveredShells';
|
||||
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';
|
||||
@@ -175,16 +179,19 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
|
||||
|
||||
const {
|
||||
theme,
|
||||
setTheme,
|
||||
resolvedTheme,
|
||||
terminalThemeId,
|
||||
setTerminalThemeId,
|
||||
followAppTerminalTheme,
|
||||
currentTerminalTheme,
|
||||
terminalFontFamilyId,
|
||||
setTerminalFontFamilyId,
|
||||
terminalFontSize,
|
||||
setTerminalFontSize,
|
||||
terminalSettings,
|
||||
updateTerminalSetting,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
isHotkeyRecording,
|
||||
@@ -200,10 +207,11 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
reapplyCurrentTheme,
|
||||
immersiveMode,
|
||||
workspaceFocusStyle,
|
||||
} = settings;
|
||||
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
|
||||
// Sync workspace focus indicator style to DOM for CSS targeting
|
||||
useEffect(() => {
|
||||
if (workspaceFocusStyle === 'border') {
|
||||
@@ -239,8 +247,11 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
deleteConnectionLog,
|
||||
clearUnsavedConnectionLogs,
|
||||
updateHostDistro,
|
||||
updateHostLastConnected,
|
||||
convertKnownHostToHost,
|
||||
importDataFromString,
|
||||
groupConfigs,
|
||||
updateGroupConfigs,
|
||||
} = useVaultState();
|
||||
|
||||
const {
|
||||
@@ -305,6 +316,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
() => new Map(sessions.map((session) => [session.id, session])),
|
||||
[sessions],
|
||||
);
|
||||
const sessionByIdRef = useRef(sessionById);
|
||||
sessionByIdRef.current = sessionById;
|
||||
const workspaceById = useMemo(
|
||||
() => new Map(workspaces.map((workspace) => [workspace.id, workspace])),
|
||||
[workspaces],
|
||||
@@ -317,6 +330,11 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
|
||||
|
||||
const resolveTheme = (s: TerminalSession): TerminalTheme => {
|
||||
// When "Follow Application Theme" is on, the UI-matched terminal
|
||||
// theme overrides everything — including per-host theme overrides.
|
||||
// This ensures all terminals match the app chrome regardless of
|
||||
// individual host settings.
|
||||
if (followAppTerminalTheme) return currentTerminalTheme;
|
||||
const host = hostById.get(s.hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
return themeById.get(themeId) || currentTerminalTheme;
|
||||
@@ -349,10 +367,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const session = sessionById.get(activeTabId);
|
||||
if (!session) return null;
|
||||
return resolveTheme(session);
|
||||
}, [activeTabId, currentTerminalTheme, hostById, sessionById, themeById, workspaceById]);
|
||||
}, [activeTabId, currentTerminalTheme, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
|
||||
|
||||
useImmersiveMode({
|
||||
isImmersive: immersiveMode,
|
||||
activeTabId,
|
||||
activeTerminalTheme,
|
||||
restoreOriginalTheme: reapplyCurrentTheme,
|
||||
@@ -373,7 +390,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
);
|
||||
|
||||
// Auto-sync hook for cloud sync
|
||||
const { syncNow: handleSyncNow } = useAutoSync({
|
||||
const { syncNow: handleSyncNow, emptyVaultConflict, resolveEmptyVaultConflict } = useAutoSync({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
@@ -382,6 +399,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
snippetPackages,
|
||||
portForwardingRules: portForwardingRulesForSync,
|
||||
knownHosts,
|
||||
groupConfigs,
|
||||
settingsVersion: settings.settingsVersion,
|
||||
onApplyPayload: (payload) => {
|
||||
applySyncPayload(payload, {
|
||||
@@ -426,7 +444,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
|
||||
if (start) {
|
||||
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
void startTunnel(rule, effectiveHost, hosts, keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
return;
|
||||
@@ -441,10 +460,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
|
||||
const { username, hostname: localHost } = systemInfoRef.current;
|
||||
if (host.protocol === 'serial') {
|
||||
if (effectiveHost.protocol === 'serial') {
|
||||
const portName = host.hostname.split('/').pop() || host.hostname;
|
||||
const sessionId = connectToHost(host);
|
||||
const sessionId = connectToHost(effectiveHost);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
@@ -460,9 +481,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
|
||||
const resolvedAuth = resolveHostAuth({ host, keys, identities });
|
||||
const sessionId = connectToHost(host);
|
||||
const protocol = effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
|
||||
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
|
||||
const sessionId = connectToHost(effectiveHost);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
@@ -479,6 +500,25 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => {
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
const target = e.target as HTMLElement;
|
||||
const isCloseTabHotkey = closeTabKeyStr ? matchesKeyBinding(e, closeTabKeyStr, isMac) : false;
|
||||
const dialogHotkeyScope = target.closest?.('[data-hotkey-close-tab="true"]');
|
||||
|
||||
if (isCloseTabHotkey && dialogHotkeyScope) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCloseTabHotkey) {
|
||||
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
|
||||
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
|
||||
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
|
||||
if (topmostDialogClose) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
topmostDialogClose.click();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
|
||||
const isMonacoElement =
|
||||
target instanceof HTMLElement &&
|
||||
@@ -611,6 +651,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
groupConfigs,
|
||||
});
|
||||
|
||||
// Sync tray menu data + handle tray actions
|
||||
@@ -690,6 +731,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Add to queue instead of replacing - supports multiple concurrent sessions
|
||||
setKeyboardInteractiveQueue(prev => [...prev, {
|
||||
requestId: request.requestId,
|
||||
sessionId: request.sessionId,
|
||||
name: request.name,
|
||||
instructions: request.instructions,
|
||||
prompts: request.prompts,
|
||||
@@ -704,14 +746,29 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, []);
|
||||
|
||||
// Handle keyboard-interactive submit
|
||||
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[]) => {
|
||||
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondKeyboardInteractive) {
|
||||
void bridge.respondKeyboardInteractive(requestId, responses, false);
|
||||
}
|
||||
// Save password to host if requested
|
||||
if (savePassword) {
|
||||
const request = keyboardInteractiveQueue.find(r => r.requestId === requestId);
|
||||
if (request?.sessionId) {
|
||||
const session = sessions.find(s => s.id === request.sessionId);
|
||||
// Only save when the prompting hostname matches the session's host,
|
||||
// to avoid overwriting the destination host's password with a jump host's password
|
||||
if (session?.hostId && (!request.hostname || request.hostname === session.hostname)) {
|
||||
const host = hosts.find(h => h.id === session.hostId);
|
||||
if (host) {
|
||||
updateHosts(hosts.map(h => h.id === host.id ? { ...h, password: savePassword } : h));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove from queue by requestId
|
||||
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
}, [keyboardInteractiveQueue, sessions, hosts, updateHosts]);
|
||||
|
||||
// Handle keyboard-interactive cancel
|
||||
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => {
|
||||
@@ -802,22 +859,37 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
addConnectionLogRef.current = addConnectionLog;
|
||||
|
||||
const createLocalTerminalWithCurrentShell = useCallback(() => {
|
||||
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
const matchedShell = discoveredShells.find(s => s.id === terminalSettings.localShell);
|
||||
return createLocalTerminal({
|
||||
shellType: classifyLocalShellType(terminalSettings.localShell, navigator.userAgent),
|
||||
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
|
||||
shell: resolved?.command,
|
||||
shellArgs: resolved?.args,
|
||||
shellName: matchedShell?.name,
|
||||
shellIcon: matchedShell?.icon,
|
||||
});
|
||||
}, [createLocalTerminal, terminalSettings.localShell]);
|
||||
}, [createLocalTerminal, terminalSettings.localShell, discoveredShells]);
|
||||
|
||||
const splitSessionWithCurrentShell = useCallback((sessionId: string, direction: 'horizontal' | 'vertical') => {
|
||||
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
return splitSession(sessionId, direction, {
|
||||
localShellType: classifyLocalShellType(terminalSettings.localShell, navigator.userAgent),
|
||||
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
|
||||
});
|
||||
}, [splitSession, terminalSettings.localShell]);
|
||||
}, [splitSession, terminalSettings.localShell, discoveredShells]);
|
||||
|
||||
const copySessionWithCurrentShell = useCallback((sessionId: string) => {
|
||||
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
return copySession(sessionId, {
|
||||
localShellType: classifyLocalShellType(terminalSettings.localShell, navigator.userAgent),
|
||||
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
|
||||
});
|
||||
}, [copySession, terminalSettings.localShell]);
|
||||
}, [copySession, terminalSettings.localShell, discoveredShells]);
|
||||
|
||||
const closeTabKeyStr = useMemo(() => {
|
||||
if (hotkeyScheme === 'disabled') return null;
|
||||
const closeTabBinding = keyBindings.find((binding) => binding.action === 'closeTab');
|
||||
if (!closeTabBinding) return null;
|
||||
return hotkeyScheme === 'mac' ? closeTabBinding.mac : closeTabBinding.pc;
|
||||
}, [hotkeyScheme, keyBindings]);
|
||||
|
||||
// Shared hotkey action handler - used by both global handler and terminal callback
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
|
||||
@@ -922,32 +994,32 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
break;
|
||||
}
|
||||
case 'splitHorizontal': {
|
||||
// Split current terminal horizontally (top/bottom)
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
// Check if it's a standalone session or we're in a workspace
|
||||
const activeSession = sessions.find(s => s.id === currentId);
|
||||
const activeWs = workspaces.find(w => w.id === currentId);
|
||||
if (activeSession && !activeSession.workspaceId) {
|
||||
// Standalone session - split it
|
||||
splitSessionWithCurrentShell(activeSession.id, 'horizontal');
|
||||
} else if (activeWs) {
|
||||
// In a workspace - need to determine focused session
|
||||
// For now, we'll need the terminal to handle this via context menu
|
||||
if (IS_DEV) console.log('[Hotkey] Split horizontal in workspace - use context menu on specific terminal');
|
||||
const liveIds = collectSessionIds(activeWs.root);
|
||||
const targetId = (activeWs.focusedSessionId && liveIds.includes(activeWs.focusedSessionId))
|
||||
? activeWs.focusedSessionId
|
||||
: liveIds[0];
|
||||
if (targetId) splitSessionWithCurrentShell(targetId, 'horizontal');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'splitVertical': {
|
||||
// Split current terminal vertically (left/right)
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const activeSession = sessions.find(s => s.id === currentId);
|
||||
const activeWs = workspaces.find(w => w.id === currentId);
|
||||
if (activeSession && !activeSession.workspaceId) {
|
||||
// Standalone session - split it
|
||||
splitSessionWithCurrentShell(activeSession.id, 'vertical');
|
||||
} else if (activeWs) {
|
||||
// In a workspace - need to determine focused session
|
||||
if (IS_DEV) console.log('[Hotkey] Split vertical in workspace - use context menu on specific terminal');
|
||||
const liveIds = collectSessionIds(activeWs.root);
|
||||
const targetId = (activeWs.focusedSessionId && liveIds.includes(activeWs.focusedSessionId))
|
||||
? activeWs.focusedSessionId
|
||||
: liveIds[0];
|
||||
if (targetId) splitSessionWithCurrentShell(targetId, 'vertical');
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1032,6 +1104,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, [hosts, updateHosts, t]);
|
||||
|
||||
// System info for connection logs
|
||||
const hostsRef = useRef(hosts);
|
||||
hostsRef.current = hosts;
|
||||
|
||||
const systemInfoRef = useRef<{ username: string; hostname: string }>({
|
||||
username: 'user',
|
||||
hostname: 'localhost',
|
||||
@@ -1053,13 +1128,24 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, []);
|
||||
|
||||
// Wrapper to create local terminal with logging
|
||||
const handleCreateLocalTerminal = useCallback(() => {
|
||||
const handleCreateLocalTerminal = useCallback((shell?: { command: string; args?: string[]; name?: string; icon?: string }) => {
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
const sessionId = createLocalTerminalWithCurrentShell();
|
||||
const resolved = shell ?? resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
// Match by ID (not command) to avoid WSL distros all sharing wsl.exe
|
||||
const matchedShell = !shell ? discoveredShells.find(s => s.id === terminalSettings.localShell) : undefined;
|
||||
const shellName = shell?.name ?? matchedShell?.name;
|
||||
const shellIcon = shell?.icon ?? matchedShell?.icon;
|
||||
const sessionId = createLocalTerminal({
|
||||
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
|
||||
shell: resolved?.command,
|
||||
shellArgs: resolved?.args,
|
||||
shellName,
|
||||
shellIcon,
|
||||
});
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: '',
|
||||
hostLabel: 'Local Terminal',
|
||||
hostLabel: shellName || 'Local Terminal',
|
||||
hostname: 'localhost',
|
||||
username: username,
|
||||
protocol: 'local',
|
||||
@@ -1068,16 +1154,24 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
localHostname: hostname,
|
||||
saved: false,
|
||||
});
|
||||
}, [addConnectionLog, createLocalTerminalWithCurrentShell]);
|
||||
}, [addConnectionLog, createLocalTerminal, terminalSettings.localShell, discoveredShells]);
|
||||
|
||||
const resolveEffectiveHost = useCallback((host: Host): Host => {
|
||||
if (!host.group) return host;
|
||||
const groupDefaults = resolveGroupDefaults(host.group, groupConfigs);
|
||||
return applyGroupDefaults(host, groupDefaults);
|
||||
}, [groupConfigs]);
|
||||
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host) => {
|
||||
const { username, hostname: localHost } = systemInfoRef.current;
|
||||
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
|
||||
// Handle serial hosts separately
|
||||
if (host.protocol === 'serial') {
|
||||
if (effectiveHost.protocol === 'serial') {
|
||||
const portName = host.hostname.split('/').pop() || host.hostname;
|
||||
const sessionId = connectToHost(host);
|
||||
const sessionId = connectToHost(effectiveHost);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
@@ -1093,9 +1187,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
|
||||
const resolvedAuth = resolveHostAuth({ host, keys, identities });
|
||||
const sessionId = connectToHost(host);
|
||||
const protocol = effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
|
||||
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
|
||||
const sessionId = connectToHost(effectiveHost);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
@@ -1108,7 +1202,18 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
}, [addConnectionLog, connectToHost, identities, keys]);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
|
||||
|
||||
// Wrap updateSessionStatus to track lastConnectedAt on successful connection
|
||||
const handleSessionStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
|
||||
updateSessionStatus(sessionId, status);
|
||||
if (status === 'connected') {
|
||||
const session = sessionByIdRef.current.get(sessionId);
|
||||
if (session?.hostId) {
|
||||
updateHostLastConnected(session.hostId);
|
||||
}
|
||||
}
|
||||
}, [updateSessionStatus, updateHostLastConnected]);
|
||||
|
||||
// Wrapper to create serial session with logging
|
||||
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
|
||||
@@ -1162,24 +1267,25 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
}, [sessions, connectionLogs, updateConnectionLog]);
|
||||
|
||||
// Check if host has multiple protocols enabled
|
||||
// Check if host has multiple protocols enabled (using effective/resolved host)
|
||||
const hasMultipleProtocols = useCallback((host: Host) => {
|
||||
const effective = resolveEffectiveHost(host);
|
||||
let count = 0;
|
||||
// SSH is always available as base protocol (unless explicitly set to something else)
|
||||
if (host.protocol === 'ssh' || !host.protocol) count++;
|
||||
if (effective.protocol === 'ssh' || !effective.protocol) count++;
|
||||
// Mosh adds another option
|
||||
if (host.moshEnabled) count++;
|
||||
if (effective.moshEnabled) count++;
|
||||
// Telnet adds another option
|
||||
if (host.telnetEnabled) count++;
|
||||
if (effective.telnetEnabled) count++;
|
||||
// If protocol is explicitly telnet (not ssh), count it
|
||||
if (host.protocol === 'telnet' && !host.telnetEnabled) count++;
|
||||
if (effective.protocol === 'telnet' && !effective.telnetEnabled) count++;
|
||||
return count > 1;
|
||||
}, []);
|
||||
}, [resolveEffectiveHost]);
|
||||
|
||||
// Handle host connect with protocol selection (used by QuickSwitcher)
|
||||
const handleHostConnectWithProtocolCheck = useCallback((host: Host) => {
|
||||
if (hasMultipleProtocols(host)) {
|
||||
setProtocolSelectHost(host);
|
||||
setProtocolSelectHost(resolveEffectiveHost(host));
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
} else {
|
||||
@@ -1187,7 +1293,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
}
|
||||
}, [hasMultipleProtocols, handleConnectToHost]);
|
||||
}, [hasMultipleProtocols, handleConnectToHost, resolveEffectiveHost]);
|
||||
|
||||
// Handle protocol selection from dialog
|
||||
const handleProtocolSelect = useCallback((protocol: HostProtocol, port: number) => {
|
||||
@@ -1204,10 +1310,24 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, [protocolSelectHost, handleConnectToHost]);
|
||||
|
||||
const handleToggleTheme = useCallback(() => {
|
||||
// Toggle based on the actual rendered theme so clicking always produces a visible change,
|
||||
// even when the stored preference is 'system'.
|
||||
if (theme === 'system') {
|
||||
toast.info(
|
||||
t('topTabs.toggleTheme.systemExitMessage'),
|
||||
{
|
||||
title: t('topTabs.toggleTheme.systemExitTitle'),
|
||||
actionLabel: t('topTabs.toggleTheme.openSettings'),
|
||||
onClick: () => {
|
||||
void (async () => {
|
||||
const opened = await openSettingsWindow();
|
||||
if (!opened) toast.error(t('toast.settingsUnavailable'), t('common.settings'));
|
||||
})();
|
||||
},
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
||||
}, [resolvedTheme, setTheme]);
|
||||
}, [openSettingsWindow, resolvedTheme, setTheme, t, theme]);
|
||||
|
||||
const handleOpenQuickSwitcher = useCallback(() => {
|
||||
setIsQuickSwitcherOpen(true);
|
||||
@@ -1278,9 +1398,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", immersiveMode && activeTerminalTheme && "immersive-transition")} onContextMenu={handleRootContextMenu}>
|
||||
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", activeTerminalTheme && "immersive-transition")} onContextMenu={handleRootContextMenu}>
|
||||
<TopTabs
|
||||
theme={resolvedTheme}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
hosts={hosts}
|
||||
sessions={sessions}
|
||||
orphanSessions={orphanSessions}
|
||||
@@ -1299,7 +1420,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onToggleTheme={handleToggleTheme}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onSyncNow={handleSyncNowManual}
|
||||
isImmersiveActive={immersiveMode && activeTerminalTheme !== null}
|
||||
isImmersiveActive={activeTerminalTheme !== null}
|
||||
onStartSessionDrag={setDraggingSessionId}
|
||||
onEndSessionDrag={handleEndSessionDrag}
|
||||
onReorderTabs={reorderTabs}
|
||||
@@ -1329,6 +1450,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onConnectSerial={handleConnectSerial}
|
||||
onDeleteHost={handleDeleteHost}
|
||||
onConnect={handleConnectToHost}
|
||||
groupConfigs={groupConfigs}
|
||||
onUpdateGroupConfigs={updateGroupConfigs}
|
||||
onUpdateHosts={updateHosts}
|
||||
onUpdateKeys={updateKeys}
|
||||
onUpdateIdentities={updateIdentities}
|
||||
@@ -1355,6 +1478,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
groupConfigs={groupConfigs}
|
||||
updateHosts={updateHosts}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
@@ -1369,6 +1493,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
<TerminalLayerMount
|
||||
hosts={hosts}
|
||||
groupConfigs={groupConfigs}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
snippets={snippets}
|
||||
@@ -1378,6 +1503,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
knownHosts={knownHosts}
|
||||
draggingSessionId={draggingSessionId}
|
||||
terminalTheme={currentTerminalTheme}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
terminalSettings={terminalSettings}
|
||||
terminalFontFamilyId={terminalFontFamilyId}
|
||||
fontSize={terminalFontSize}
|
||||
@@ -1387,8 +1513,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onUpdateTerminalThemeId={setTerminalThemeId}
|
||||
onUpdateTerminalFontFamilyId={setTerminalFontFamilyId}
|
||||
onUpdateTerminalFontSize={setTerminalFontSize}
|
||||
onUpdateTerminalFontWeight={(w) => updateTerminalSetting('fontWeight', w)}
|
||||
onCloseSession={closeSession}
|
||||
onUpdateSessionStatus={updateSessionStatus}
|
||||
onUpdateSessionStatus={handleSessionStatusChange}
|
||||
onUpdateHostDistro={updateHostDistro}
|
||||
onUpdateHost={(host) => updateHosts(hosts.map(h => h.id === host.id ? host : h))}
|
||||
onAddKnownHost={(kh) => updateKnownHosts([...knownHosts, kh])}
|
||||
@@ -1436,6 +1563,17 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Global "quick add snippet" dialog, triggered by the
|
||||
netcatty:snippets:add window event (from ScriptsSidePanel "+"). */}
|
||||
<QuickAddSnippetDialog
|
||||
snippets={snippets}
|
||||
packages={snippetPackages}
|
||||
onCreateSnippet={(snippet) => updateSnippets([...snippets, snippet])}
|
||||
onCreatePackage={(pkg) =>
|
||||
updateSnippetPackages(Array.from(new Set([...snippetPackages, pkg])))
|
||||
}
|
||||
/>
|
||||
|
||||
{isQuickSwitcherOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<LazyQuickSwitcher
|
||||
@@ -1451,8 +1589,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
}}
|
||||
onCreateLocalTerminal={() => {
|
||||
handleCreateLocalTerminal();
|
||||
onCreateLocalTerminal={(shell) => {
|
||||
handleCreateLocalTerminal(shell);
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
}}
|
||||
@@ -1565,6 +1703,59 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onCancel={handlePassphraseCancel}
|
||||
onSkip={handlePassphraseSkip}
|
||||
/>
|
||||
|
||||
{/* Empty vault vs cloud data confirmation dialog (#679).
|
||||
This dialog intentionally cannot be dismissed — the user MUST
|
||||
choose "Restore" or "Keep Empty" before the sync flow can
|
||||
proceed. hideCloseButton removes the X button, onOpenChange
|
||||
is a no-op so ESC also does nothing, and onInteractOutside
|
||||
prevents click-away. */}
|
||||
<Dialog open={!!emptyVaultConflict} onOpenChange={() => { /* intentionally non-dismissable */ }}>
|
||||
<DialogContent className="max-w-md" hideCloseButton onInteractOutside={(e) => e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
||||
{t('sync.autoSync.emptyVaultConflict.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('sync.autoSync.emptyVaultConflict.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{emptyVaultConflict && (
|
||||
<div className="bg-muted/30 rounded-lg p-3 text-sm">
|
||||
<div className="font-medium text-muted-foreground mb-1">{t('sync.autoSync.emptyVaultConflict.cloudLabel')}</div>
|
||||
<div>{t('sync.autoSync.emptyVaultConflict.cloudSummary', {
|
||||
hosts: emptyVaultConflict.hostCount,
|
||||
keys: emptyVaultConflict.keyCount,
|
||||
snippets: emptyVaultConflict.snippetCount,
|
||||
})}</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="flex-col gap-2 sm:flex-col">
|
||||
<Button
|
||||
onClick={() => resolveEmptyVaultConflict('restore')}
|
||||
className="w-full justify-start gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>
|
||||
{t('sync.autoSync.emptyVaultConflict.restore')}
|
||||
<span className="text-xs opacity-70 ml-1">— {t('sync.autoSync.emptyVaultConflict.restoreDesc')}</span>
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => resolveEmptyVaultConflict('keep-empty')}
|
||||
className="w-full justify-start gap-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span>
|
||||
{t('sync.autoSync.emptyVaultConflict.keepEmpty')}
|
||||
<span className="text-xs opacity-70 ml-1">— {t('sync.autoSync.emptyVaultConflict.keepEmptyDesc')}</span>
|
||||
</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ const en: Messages = {
|
||||
'common.clear': 'Clear',
|
||||
'common.optional': 'Optional',
|
||||
'common.selectPlaceholder': 'Select...',
|
||||
'common.add': 'Add',
|
||||
'common.rename': 'Rename',
|
||||
'common.refresh': 'Refresh',
|
||||
'common.continue': 'Continue',
|
||||
@@ -196,6 +197,11 @@ const en: Messages = {
|
||||
'settings.application.github.subtitle': 'Source code',
|
||||
'settings.application.whatsNew': "What's new",
|
||||
'settings.application.whatsNew.subtitle': 'Show release notes',
|
||||
'settings.application.openExternal.failedTitle': 'Cannot open link',
|
||||
'settings.application.openExternal.failedBody': 'The link could not be opened in either the system browser or the built-in browser window.',
|
||||
'settings.vault.title': 'Vault',
|
||||
'settings.vault.showRecentHosts': 'Show recently connected hosts',
|
||||
'settings.vault.showRecentHostsDesc': 'Display a section of recently connected hosts at the top of the vault',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': 'Update Available',
|
||||
@@ -231,14 +237,11 @@ 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.',
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar, top-tabs.',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Example: */\n.terminal { background: #1a1a2e !important; }\n:root { --radius: 0.25rem; }',
|
||||
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Make snippet sidebar text larger */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* Custom terminal background */\n.terminal { background: #1a1a2e !important; }\n\n/* Tweak global border radius */\n:root { --radius: 0.25rem; }',
|
||||
'settings.appearance.language': 'Language',
|
||||
'settings.appearance.language.desc': 'Choose the UI language',
|
||||
'settings.appearance.uiFont': 'Interface Font',
|
||||
@@ -250,6 +253,8 @@ const en: Messages = {
|
||||
'settings.terminal.themeModal.darkThemes': 'Dark Themes',
|
||||
'settings.terminal.themeModal.lightThemes': 'Light Themes',
|
||||
'settings.terminal.theme.selectButton': 'Select Theme',
|
||||
'settings.terminal.theme.followApp': 'Follow Application Theme',
|
||||
'settings.terminal.theme.followApp.desc': 'Automatically match the terminal background to the current app theme for a seamless look.',
|
||||
'settings.terminal.section.font': 'Font',
|
||||
'settings.terminal.section.cursor': 'Cursor',
|
||||
'settings.terminal.section.keyboard': 'Keyboard',
|
||||
@@ -327,6 +332,14 @@ const en: Messages = {
|
||||
'settings.terminal.scrollback.rows': 'Number of rows *',
|
||||
'settings.terminal.keywordHighlight.title': 'Keyword highlighting',
|
||||
'settings.terminal.keywordHighlight.resetColors': 'Reset to default colors',
|
||||
'settings.terminal.keywordHighlight.addCustom': 'Add Custom Rule',
|
||||
'settings.terminal.keywordHighlight.editCustom': 'Edit Rule',
|
||||
'settings.terminal.keywordHighlight.labelField': 'Label & Color',
|
||||
'settings.terminal.keywordHighlight.labelPlaceholder': 'Label (e.g., Down)',
|
||||
'settings.terminal.keywordHighlight.patternField': 'Regex Pattern',
|
||||
'settings.terminal.keywordHighlight.patternPlaceholder': 'Regex (e.g., \\bdown\\b)',
|
||||
'settings.terminal.keywordHighlight.invalidPattern': 'Invalid regex pattern',
|
||||
'settings.terminal.keywordHighlight.preview': 'Preview',
|
||||
'settings.terminal.section.localShell': 'Local Shell',
|
||||
'settings.terminal.localShell.shell': 'Shell executable',
|
||||
'settings.terminal.localShell.shell.desc': 'Path to the shell executable (e.g., /bin/zsh, pwsh.exe). Leave empty for system default.',
|
||||
@@ -334,6 +347,11 @@ const en: Messages = {
|
||||
'settings.terminal.localShell.shell.detected': 'Detected',
|
||||
'settings.terminal.localShell.shell.notFound': 'Shell executable not found',
|
||||
'settings.terminal.localShell.shell.isDirectory': 'Path is a directory, not an executable',
|
||||
'settings.terminal.localShell.shell.default': 'System Default',
|
||||
'settings.terminal.localShell.shell.custom': 'Custom...',
|
||||
'settings.terminal.localShell.shell.customPath': 'Shell executable path',
|
||||
'settings.terminal.localShell.shell.commonPaths': 'Common paths',
|
||||
'settings.terminal.localShell.shell.pathValid': 'Path valid',
|
||||
'settings.terminal.localShell.startDir': 'Starting directory',
|
||||
'settings.terminal.localShell.startDir.desc': 'Directory to start in when opening a local terminal. Leave empty for home directory.',
|
||||
'settings.terminal.localShell.startDir.placeholder': 'Home directory',
|
||||
@@ -352,7 +370,7 @@ const en: Messages = {
|
||||
// Settings > Terminal > Rendering
|
||||
'settings.terminal.section.rendering': 'Rendering',
|
||||
'settings.terminal.rendering.renderer': 'Renderer',
|
||||
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use Canvas on low-memory devices. Changes take effect on new terminal sessions.',
|
||||
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use DOM on low-memory devices. Changes take effect on new terminal sessions.',
|
||||
'settings.terminal.rendering.auto': 'Auto',
|
||||
|
||||
// Settings > Terminal > Workspace Focus Indicator
|
||||
@@ -428,6 +446,18 @@ const en: Messages = {
|
||||
'sync.autoSync.vaultLocked': 'Vault is locked. Open Settings → Sync & Cloud to unlock.',
|
||||
'sync.autoSync.conflictDetected': 'Sync conflict detected. Open Settings → Sync & Cloud to resolve.',
|
||||
'sync.autoSync.syncFailed': 'Sync failed',
|
||||
'sync.autoSync.restoredTitle': 'Vault restored',
|
||||
'sync.autoSync.restoredMessage': 'Your vault has been restored from the cloud.',
|
||||
'sync.autoSync.keptLocalTitle': 'Kept local vault',
|
||||
'sync.autoSync.keptLocalMessage': 'Your empty local vault was kept. Cloud data was not applied.',
|
||||
'sync.autoSync.emptyVaultConflict.title': 'Empty Vault Detected',
|
||||
'sync.autoSync.emptyVaultConflict.description': 'Your local vault is empty, but the cloud has data. This usually happens after an update or storage reset. What would you like to do?',
|
||||
'sync.autoSync.emptyVaultConflict.cloudLabel': 'Cloud',
|
||||
'sync.autoSync.emptyVaultConflict.restore': 'Restore from Cloud',
|
||||
'sync.autoSync.emptyVaultConflict.restoreDesc': 'Recommended — recover your hosts, keys, and snippets from the cloud backup',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmpty': 'Keep Empty',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': 'Start fresh with an empty vault',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} hosts, {keys} keys, {snippets} snippets',
|
||||
'sync.credentialsUnavailable': 'This device cannot decrypt some saved credentials. Re-enter credentials locally before syncing.',
|
||||
'time.never': 'Never',
|
||||
'time.justNow': 'Just now',
|
||||
@@ -461,8 +491,24 @@ const en: Messages = {
|
||||
'vault.groups.placeholder.example': 'e.g. Production',
|
||||
'vault.groups.parentLabel': 'Parent',
|
||||
'vault.groups.pathLabel': 'Path',
|
||||
'vault.groups.settings': 'Group Settings',
|
||||
'vault.groups.details': 'Group Details',
|
||||
'vault.groups.details.general': 'General',
|
||||
'vault.groups.details.ssh': 'SSH',
|
||||
'vault.groups.details.telnet': 'Telnet',
|
||||
'vault.groups.details.advanced': 'Advanced',
|
||||
'vault.groups.details.appearance': 'Appearance',
|
||||
'vault.groups.details.mosh': 'Mosh',
|
||||
'vault.groups.details.parentGroup': 'Parent Group',
|
||||
'vault.groups.details.none': 'None',
|
||||
'vault.groups.details.inherited': 'Inherited from group',
|
||||
'vault.groups.details.addProtocol': 'Add Protocol',
|
||||
'vault.groups.details.removeProtocol': 'Remove Protocol',
|
||||
'vault.groups.details.fontFamily': 'Font Family',
|
||||
'vault.groups.details.fontSize': 'Font Size',
|
||||
'vault.groups.errors.required': 'Group name is required.',
|
||||
'vault.groups.errors.invalidChars': "Group name cannot include '/' or '\\\\'.",
|
||||
'vault.groups.errors.duplicatePath': 'A group with this name already exists at this location.',
|
||||
|
||||
'vault.managedSource.unmanage': 'Unmanage',
|
||||
'vault.managedSource.unmanageSuccess': 'Successfully unmanaged group',
|
||||
@@ -486,6 +532,10 @@ const en: Messages = {
|
||||
'vault.hosts.export.toast.successWithSkipped': 'Exported {count} hosts to CSV ({skipped} unsupported hosts skipped)',
|
||||
'vault.hosts.export.toast.noHosts': 'No hosts to export',
|
||||
'vault.hosts.allHosts': 'All hosts',
|
||||
'vault.hosts.pinned': 'Pinned',
|
||||
'vault.hosts.recentlyConnected': 'Recently Connected',
|
||||
'vault.hosts.pinToTop': 'Pin to Top',
|
||||
'vault.hosts.unpin': 'Unpin',
|
||||
'vault.hosts.copyCredentials': 'Copy Credentials',
|
||||
'vault.hosts.copyCredentials.toast.success': 'Credentials copied to clipboard',
|
||||
'vault.hosts.copyCredentials.toast.noPassword': 'No password saved for this host',
|
||||
@@ -495,6 +545,7 @@ const en: Messages = {
|
||||
'vault.hosts.deselectAll': 'Deselect All',
|
||||
'vault.hosts.deleteSelected': 'Delete ({count})',
|
||||
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
|
||||
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
|
||||
|
||||
@@ -699,6 +750,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',
|
||||
@@ -879,6 +933,8 @@ const en: Messages = {
|
||||
'qs.search.placeholder': 'Search hosts or tabs',
|
||||
'qs.jumpTo': 'Jump To',
|
||||
'qs.localTerminal': 'Local Terminal',
|
||||
'qs.localShells': 'Local Shells',
|
||||
'qs.default': 'Default',
|
||||
|
||||
// Select Host panel
|
||||
'selectHost.title': 'Select Host',
|
||||
@@ -944,6 +1000,14 @@ const en: Messages = {
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': 'Cisco',
|
||||
'hostDetails.distro.option.juniper': 'Juniper Networks',
|
||||
'hostDetails.distro.option.huawei': 'Huawei',
|
||||
'hostDetails.distro.option.hpe': 'HPE / H3C',
|
||||
'hostDetails.distro.option.mikrotik': 'MikroTik',
|
||||
'hostDetails.distro.option.fortinet': 'Fortinet',
|
||||
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
|
||||
'hostDetails.distro.option.zyxel': 'ZyXEL',
|
||||
'hostDetails.section.mosh': 'Mosh',
|
||||
'hostDetails.username.placeholder': 'Username',
|
||||
'hostDetails.password.placeholder': 'Password',
|
||||
@@ -976,6 +1040,8 @@ const en: Messages = {
|
||||
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
|
||||
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
|
||||
'hostDetails.backspaceBehavior': 'Backspace Behavior',
|
||||
'hostDetails.backspaceBehavior.default': 'Default',
|
||||
'hostDetails.jumpHosts': 'Proxy via Hosts',
|
||||
'hostDetails.jumpHosts.hops': '{count} hop(s)',
|
||||
'hostDetails.jumpHosts.direct': 'Direct',
|
||||
@@ -1183,8 +1249,14 @@ const en: Messages = {
|
||||
'terminal.themeModal.globalTheme': 'Global Theme',
|
||||
'terminal.themeModal.globalFont': 'Global Font',
|
||||
'terminal.themeModal.fontSize': 'Font Size',
|
||||
'terminal.themeModal.fontWeight': 'Font Weight',
|
||||
'terminal.themeModal.livePreview': 'Live Preview',
|
||||
'terminal.themeModal.themeType': '{type} theme',
|
||||
'terminal.hiddenTheme.title': 'Current hidden theme',
|
||||
'terminal.hiddenTheme.desc': 'This theme is hidden from manual picks and will be replaced when you choose another theme.',
|
||||
'topTabs.toggleTheme.systemExitTitle': 'System theme is active',
|
||||
'topTabs.toggleTheme.systemExitMessage': 'Open Settings to choose a fixed Light or Dark theme.',
|
||||
'topTabs.toggleTheme.openSettings': 'Open Settings',
|
||||
|
||||
// Custom Themes
|
||||
'terminal.customTheme.section': 'Custom Themes',
|
||||
@@ -1314,6 +1386,22 @@ const en: Messages = {
|
||||
'cloudSync.history.download': 'Download',
|
||||
'cloudSync.history.resolved': 'Resolved',
|
||||
'cloudSync.history.error': 'Error',
|
||||
'cloudSync.revisionHistory.viewButton': 'History',
|
||||
'cloudSync.revisionHistory.title': 'Vault Version History',
|
||||
'cloudSync.revisionHistory.description': 'Browse and restore previous versions of your vault from the Gist revision history.',
|
||||
'cloudSync.revisionHistory.empty': 'No revisions found.',
|
||||
'cloudSync.revisionHistory.current': 'Current',
|
||||
'cloudSync.revisionHistory.revision': 'Revision',
|
||||
'cloudSync.revisionHistory.revisionPreview': 'Revision Contents',
|
||||
'cloudSync.revisionHistory.device': 'Device',
|
||||
'cloudSync.revisionHistory.hosts': 'Hosts',
|
||||
'cloudSync.revisionHistory.keys': 'Keys',
|
||||
'cloudSync.revisionHistory.snippets': 'Snippets',
|
||||
'cloudSync.revisionHistory.identities': 'Identities',
|
||||
'cloudSync.revisionHistory.restoreButton': 'Restore This Version',
|
||||
'cloudSync.revisionHistory.restored': 'Vault restored from selected revision.',
|
||||
'cloudSync.revisionHistory.revisionNotFound': 'Revision not found or does not contain vault data.',
|
||||
'cloudSync.revisionHistory.decryptFailed': 'Cannot decrypt this revision. It may have been encrypted with a different master password.',
|
||||
'cloudSync.changeKey.title': 'Change Master Key',
|
||||
'cloudSync.changeKey.current': 'Current Master Key',
|
||||
'cloudSync.changeKey.new': 'New Master Key',
|
||||
@@ -1601,10 +1689,7 @@ const en: Messages = {
|
||||
'keyboard.interactive.enterResponse': 'Enter response',
|
||||
'keyboard.interactive.submit': 'Submit',
|
||||
'keyboard.interactive.verifying': 'Verifying...',
|
||||
'keyboard.interactive.fill': 'Fill',
|
||||
'keyboard.interactive.fillSaved': 'Fill with saved password',
|
||||
'keyboard.interactive.useSaved': 'Use saved',
|
||||
'keyboard.interactive.useSavedPassword': 'Use saved password',
|
||||
'keyboard.interactive.savePassword': 'Save password',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH Key Passphrase',
|
||||
@@ -1684,10 +1769,26 @@ 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',
|
||||
'ai.defaultAgent.catty': 'Catty (Built-in)',
|
||||
'ai.toolAccess.title': 'Tool Access',
|
||||
'ai.toolAccess.mode': 'Netcatty Access Mode',
|
||||
'ai.toolAccess.description': 'Choose how external ACP agents access Netcatty sessions. MCP exposes the built-in server, while Skills + CLI points agents to the local Netcatty skill and CLI commands.',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
|
||||
@@ -1764,7 +1865,7 @@ const en: Messages = {
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': 'Safety',
|
||||
'ai.safety.permissionMode': 'Permission Mode',
|
||||
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your terminals. Observer mode blocks all write operations via MCP Server, enforced for both built-in and ACP agents. Confirm mode is advisory for ACP agents (they control their own tool approval flow).',
|
||||
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your terminals. Observer mode blocks all write operations through Netcatty, enforced for both built-in and ACP agents. Confirm mode is advisory for ACP agents (they control their own tool approval flow).',
|
||||
'ai.safety.permissionMode.observer': 'Observer - Read only, no actions',
|
||||
'ai.safety.permissionMode.confirm': 'Confirm - Ask before actions',
|
||||
'ai.safety.permissionMode.autonomous': 'Autonomous - Execute freely',
|
||||
@@ -1774,7 +1875,7 @@ const en: Messages = {
|
||||
'ai.safety.maxIterations': 'Max Iterations',
|
||||
'ai.safety.maxIterations.description': 'Maximum number of AI tool-use loops to prevent runaway execution. ACP agents may have their own internal iteration limits that take precedence.',
|
||||
'ai.safety.blocklist': 'Command Blocklist',
|
||||
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents via MCP Server.',
|
||||
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents through Netcatty execution.',
|
||||
'ai.safety.blocklist.placeholder': 'Regex pattern...',
|
||||
'ai.safety.blocklist.reset': 'Reset to defaults',
|
||||
'ai.safety.blocklist.add': 'Add pattern',
|
||||
|
||||
@@ -13,6 +13,7 @@ const zhCN: Messages = {
|
||||
'common.connect': '连接',
|
||||
'common.terminal': '终端',
|
||||
'common.create': '创建',
|
||||
'common.add': '添加',
|
||||
'common.rename': '重命名',
|
||||
'common.refresh': '刷新',
|
||||
'common.continue': '继续',
|
||||
@@ -180,6 +181,11 @@ const zhCN: Messages = {
|
||||
'settings.application.github.subtitle': '源代码',
|
||||
'settings.application.whatsNew': '更新内容',
|
||||
'settings.application.whatsNew.subtitle': '查看发布说明',
|
||||
'settings.application.openExternal.failedTitle': '无法打开链接',
|
||||
'settings.application.openExternal.failedBody': '系统浏览器和内置浏览器窗口都无法打开该链接。',
|
||||
'settings.vault.title': '主机库',
|
||||
'settings.vault.showRecentHosts': '显示最近连接的主机',
|
||||
'settings.vault.showRecentHostsDesc': '在主机列表顶部显示最近连接过的主机',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': '发现新版本',
|
||||
@@ -215,13 +221,11 @@ 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.desc':
|
||||
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位,比如:snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebar、top-tabs。',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* 示例:*/\n.terminal { background: #1a1a2e !important; }\n:root { --radius: 0.25rem; }',
|
||||
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 放大代码片段侧边栏字号 */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* 自定义终端背景色 */\n.terminal { background: #1a1a2e !important; }\n\n/* 调整全局圆角 */\n:root { --radius: 0.25rem; }',
|
||||
'settings.appearance.language': '语言',
|
||||
'settings.appearance.language.desc': '选择界面语言',
|
||||
'settings.appearance.uiFont': '界面字体',
|
||||
@@ -261,6 +265,18 @@ const zhCN: Messages = {
|
||||
'sync.autoSync.vaultLocked': 'Vault 处于锁定状态。请打开 设置 → Sync & Cloud 解锁。',
|
||||
'sync.autoSync.conflictDetected': '检测到同步冲突。请打开 设置 → Sync & Cloud 处理。',
|
||||
'sync.autoSync.syncFailed': '同步失败',
|
||||
'sync.autoSync.restoredTitle': '已恢复',
|
||||
'sync.autoSync.restoredMessage': '已从云端恢复主机库数据。',
|
||||
'sync.autoSync.keptLocalTitle': '已保留本地数据',
|
||||
'sync.autoSync.keptLocalMessage': '保留了空的本地主机库,未应用云端数据。',
|
||||
'sync.autoSync.emptyVaultConflict.title': '检测到空主机库',
|
||||
'sync.autoSync.emptyVaultConflict.description': '本地主机库为空,但云端有数据。这通常发生在应用更新或存储重置之后。请选择如何处理:',
|
||||
'sync.autoSync.emptyVaultConflict.cloudLabel': '云端',
|
||||
'sync.autoSync.emptyVaultConflict.restore': '从云端恢复',
|
||||
'sync.autoSync.emptyVaultConflict.restoreDesc': '推荐 — 从云端备份恢复主机、密钥和代码片段',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmpty': '保持为空',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': '从头开始,使用空的主机库',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段',
|
||||
'sync.credentialsUnavailable': '当前设备无法解密部分已保存凭据。请先在本地重新输入凭据后再同步。',
|
||||
'time.never': '从未',
|
||||
'time.justNow': '刚刚',
|
||||
@@ -294,8 +310,24 @@ const zhCN: Messages = {
|
||||
'vault.groups.placeholder.example': '例如:Production',
|
||||
'vault.groups.parentLabel': '父级',
|
||||
'vault.groups.pathLabel': '路径',
|
||||
'vault.groups.settings': '分组设置',
|
||||
'vault.groups.details': '分组详情',
|
||||
'vault.groups.details.general': '常规',
|
||||
'vault.groups.details.ssh': 'SSH',
|
||||
'vault.groups.details.telnet': 'Telnet',
|
||||
'vault.groups.details.advanced': '高级',
|
||||
'vault.groups.details.appearance': '外观',
|
||||
'vault.groups.details.mosh': 'Mosh',
|
||||
'vault.groups.details.parentGroup': '父分组',
|
||||
'vault.groups.details.none': '无',
|
||||
'vault.groups.details.inherited': '继承自分组',
|
||||
'vault.groups.details.addProtocol': '添加协议',
|
||||
'vault.groups.details.removeProtocol': '移除协议',
|
||||
'vault.groups.details.fontFamily': '字体',
|
||||
'vault.groups.details.fontSize': '字号',
|
||||
'vault.groups.errors.required': '分组名称不能为空。',
|
||||
'vault.groups.errors.invalidChars': "分组名称不能包含 '/' 或 '\\\\'.",
|
||||
'vault.groups.errors.duplicatePath': '该位置已存在同名分组。',
|
||||
|
||||
'vault.managedSource.unmanage': '取消托管',
|
||||
'vault.managedSource.unmanageSuccess': '已取消托管分组',
|
||||
@@ -319,6 +351,10 @@ const zhCN: Messages = {
|
||||
'vault.hosts.export.toast.successWithSkipped': '已导出 {count} 个主机到 CSV(跳过 {skipped} 个不支持的主机)',
|
||||
'vault.hosts.export.toast.noHosts': '没有主机可导出',
|
||||
'vault.hosts.allHosts': '全部主机',
|
||||
'vault.hosts.pinned': '已置顶',
|
||||
'vault.hosts.recentlyConnected': '最近连接',
|
||||
'vault.hosts.pinToTop': '置顶',
|
||||
'vault.hosts.unpin': '取消置顶',
|
||||
'vault.hosts.copyCredentials': '复制账密信息',
|
||||
'vault.hosts.copyCredentials.toast.success': '账密信息已复制到剪贴板',
|
||||
'vault.hosts.copyCredentials.toast.noPassword': '该主机未保存密码',
|
||||
@@ -328,6 +364,7 @@ const zhCN: Messages = {
|
||||
'vault.hosts.deselectAll': '取消全选',
|
||||
'vault.hosts.deleteSelected': '删除 ({count})',
|
||||
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
|
||||
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
|
||||
|
||||
@@ -507,6 +544,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': '上传失败',
|
||||
@@ -539,6 +579,8 @@ const zhCN: Messages = {
|
||||
'qs.search.placeholder': '搜索主机或标签页',
|
||||
'qs.jumpTo': '跳转到',
|
||||
'qs.localTerminal': '本地终端',
|
||||
'qs.localShells': '本地 Shell',
|
||||
'qs.default': '默认',
|
||||
|
||||
// Select Host panel
|
||||
'selectHost.title': '选择主机',
|
||||
@@ -600,6 +642,14 @@ const zhCN: Messages = {
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': '思科',
|
||||
'hostDetails.distro.option.juniper': '瞻博网络',
|
||||
'hostDetails.distro.option.huawei': '华为',
|
||||
'hostDetails.distro.option.hpe': '慧与 / H3C',
|
||||
'hostDetails.distro.option.mikrotik': 'MikroTik',
|
||||
'hostDetails.distro.option.fortinet': '飞塔',
|
||||
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
|
||||
'hostDetails.distro.option.zyxel': '合勤',
|
||||
'hostDetails.section.mosh': 'Mosh',
|
||||
'hostDetails.username.placeholder': '用户名',
|
||||
'hostDetails.password.placeholder': '密码',
|
||||
@@ -632,6 +682,8 @@ const zhCN: Messages = {
|
||||
'hostDetails.legacyAlgorithms': '允许旧版算法',
|
||||
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法(diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
|
||||
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
|
||||
'hostDetails.backspaceBehavior': 'Backspace 行为',
|
||||
'hostDetails.backspaceBehavior.default': '默认',
|
||||
'hostDetails.jumpHosts': '通过主机代理',
|
||||
'hostDetails.jumpHosts.hops': '{count} 跳',
|
||||
'hostDetails.jumpHosts.direct': '直连',
|
||||
@@ -811,8 +863,14 @@ const zhCN: Messages = {
|
||||
'terminal.themeModal.globalTheme': '全局主题',
|
||||
'terminal.themeModal.globalFont': '全局字体',
|
||||
'terminal.themeModal.fontSize': '字体大小',
|
||||
'terminal.themeModal.fontWeight': '字体粗细',
|
||||
'terminal.themeModal.livePreview': '实时预览',
|
||||
'terminal.themeModal.themeType': '{type} 主题',
|
||||
'terminal.hiddenTheme.title': '当前隐藏主题',
|
||||
'terminal.hiddenTheme.desc': '这个主题已从手动选择列表中隐藏;当你选择其他可见主题后,它会被替换。',
|
||||
'topTabs.toggleTheme.systemExitTitle': '当前正在跟随系统主题',
|
||||
'topTabs.toggleTheme.systemExitMessage': '请到设置里选择固定的浅色或深色主题。',
|
||||
'topTabs.toggleTheme.openSettings': '打开设置',
|
||||
|
||||
// Custom Themes
|
||||
'terminal.customTheme.section': '自定义主题',
|
||||
@@ -941,6 +999,22 @@ const zhCN: Messages = {
|
||||
'cloudSync.history.download': '下载',
|
||||
'cloudSync.history.resolved': '已解决',
|
||||
'cloudSync.history.error': '错误',
|
||||
'cloudSync.revisionHistory.viewButton': '历史版本',
|
||||
'cloudSync.revisionHistory.title': '主机库版本历史',
|
||||
'cloudSync.revisionHistory.description': '浏览并恢复 Gist 修订历史中的旧版主机库数据。',
|
||||
'cloudSync.revisionHistory.empty': '未找到修订记录。',
|
||||
'cloudSync.revisionHistory.current': '当前版本',
|
||||
'cloudSync.revisionHistory.revision': '修订',
|
||||
'cloudSync.revisionHistory.revisionPreview': '修订内容',
|
||||
'cloudSync.revisionHistory.device': '设备',
|
||||
'cloudSync.revisionHistory.hosts': '主机',
|
||||
'cloudSync.revisionHistory.keys': '密钥',
|
||||
'cloudSync.revisionHistory.snippets': '代码片段',
|
||||
'cloudSync.revisionHistory.identities': '身份',
|
||||
'cloudSync.revisionHistory.restoreButton': '恢复此版本',
|
||||
'cloudSync.revisionHistory.restored': '已从选中的修订恢复主机库数据。',
|
||||
'cloudSync.revisionHistory.revisionNotFound': '修订未找到或不包含主机库数据。',
|
||||
'cloudSync.revisionHistory.decryptFailed': '无法解密此修订。可能是使用了不同的主密钥加密的。',
|
||||
'cloudSync.changeKey.title': '更改主密钥',
|
||||
'cloudSync.changeKey.current': '当前主密钥',
|
||||
'cloudSync.changeKey.new': '新的主密钥',
|
||||
@@ -1210,6 +1284,8 @@ const zhCN: Messages = {
|
||||
'settings.terminal.themeModal.darkThemes': '深色主题',
|
||||
'settings.terminal.themeModal.lightThemes': '浅色主题',
|
||||
'settings.terminal.theme.selectButton': '选择主题',
|
||||
'settings.terminal.theme.followApp': '跟随应用主题',
|
||||
'settings.terminal.theme.followApp.desc': '终端背景色自动匹配当前应用主题,保持视觉一致性。',
|
||||
'settings.terminal.section.font': '字体',
|
||||
'settings.terminal.section.cursor': '光标',
|
||||
'settings.terminal.section.keyboard': '键盘',
|
||||
@@ -1280,6 +1356,14 @@ const zhCN: Messages = {
|
||||
'settings.terminal.scrollback.rows': '行数 *',
|
||||
'settings.terminal.keywordHighlight.title': '关键字高亮',
|
||||
'settings.terminal.keywordHighlight.resetColors': '重置为默认颜色',
|
||||
'settings.terminal.keywordHighlight.addCustom': '添加自定义规则',
|
||||
'settings.terminal.keywordHighlight.editCustom': '编辑规则',
|
||||
'settings.terminal.keywordHighlight.labelField': '标签与颜色',
|
||||
'settings.terminal.keywordHighlight.labelPlaceholder': '标签(如 Down)',
|
||||
'settings.terminal.keywordHighlight.patternField': '正则表达式',
|
||||
'settings.terminal.keywordHighlight.patternPlaceholder': '正则表达式(如 \\bdown\\b)',
|
||||
'settings.terminal.keywordHighlight.invalidPattern': '无效的正则表达式',
|
||||
'settings.terminal.keywordHighlight.preview': '预览',
|
||||
'settings.terminal.section.localShell': '本地 Shell',
|
||||
'settings.terminal.localShell.shell': 'Shell 可执行文件',
|
||||
'settings.terminal.localShell.shell.desc': 'Shell 可执行文件的路径(例如 /bin/zsh、pwsh.exe)。留空使用系统默认。',
|
||||
@@ -1287,6 +1371,11 @@ const zhCN: Messages = {
|
||||
'settings.terminal.localShell.shell.detected': '检测到',
|
||||
'settings.terminal.localShell.shell.notFound': '未找到 Shell 可执行文件',
|
||||
'settings.terminal.localShell.shell.isDirectory': '路径是目录,不是可执行文件',
|
||||
'settings.terminal.localShell.shell.default': '系统默认',
|
||||
'settings.terminal.localShell.shell.custom': '自定义...',
|
||||
'settings.terminal.localShell.shell.customPath': 'Shell 可执行文件路径',
|
||||
'settings.terminal.localShell.shell.commonPaths': '常用路径',
|
||||
'settings.terminal.localShell.shell.pathValid': '路径有效',
|
||||
'settings.terminal.localShell.startDir': '起始目录',
|
||||
'settings.terminal.localShell.startDir.desc': '打开本地终端时的起始目录。留空使用用户主目录。',
|
||||
'settings.terminal.localShell.startDir.placeholder': '用户主目录',
|
||||
@@ -1305,7 +1394,7 @@ const zhCN: Messages = {
|
||||
// Settings > Terminal > Rendering
|
||||
'settings.terminal.section.rendering': '渲染',
|
||||
'settings.terminal.rendering.renderer': '渲染器',
|
||||
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 Canvas。更改将在新终端会话中生效。',
|
||||
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 DOM 渲染。更改将在新终端会话中生效。',
|
||||
'settings.terminal.rendering.auto': '自动',
|
||||
|
||||
// Settings > Terminal > Autocomplete
|
||||
@@ -1608,10 +1697,7 @@ const zhCN: Messages = {
|
||||
'keyboard.interactive.enterResponse': '输入响应',
|
||||
'keyboard.interactive.submit': '提交',
|
||||
'keyboard.interactive.verifying': '验证中...',
|
||||
'keyboard.interactive.fill': '填入',
|
||||
'keyboard.interactive.fillSaved': '填入已保存的密码',
|
||||
'keyboard.interactive.useSaved': '使用已保存',
|
||||
'keyboard.interactive.useSavedPassword': '使用已保存的密码',
|
||||
'keyboard.interactive.savePassword': '保存密码',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH 密钥密码',
|
||||
@@ -1691,10 +1777,26 @@ 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',
|
||||
'ai.defaultAgent.catty': 'Catty(内置)',
|
||||
'ai.toolAccess.title': '工具接入',
|
||||
'ai.toolAccess.mode': 'Netcatty 接入模式',
|
||||
'ai.toolAccess.description': '选择外部 ACP Agent 访问 Netcatty 会话的方式。MCP 会暴露内置服务器,Skills + CLI 会引导 Agent 读取本地 Skill 并调用 Netcatty CLI。',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
|
||||
@@ -1771,7 +1873,7 @@ const zhCN: Messages = {
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': '安全',
|
||||
'ai.safety.permissionMode': '权限模式',
|
||||
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式通过 MCP Server 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性(ACP Agent 有自己的工具审批流程)。',
|
||||
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式会通过 Netcatty 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性(ACP Agent 有自己的工具审批流程)。',
|
||||
'ai.safety.permissionMode.observer': '观察者 - 只读,禁止操作',
|
||||
'ai.safety.permissionMode.confirm': '确认 - 操作前询问',
|
||||
'ai.safety.permissionMode.autonomous': '自主 - 自由执行',
|
||||
@@ -1781,7 +1883,7 @@ const zhCN: Messages = {
|
||||
'ai.safety.maxIterations': '最大迭代次数',
|
||||
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。ACP Agent 可能有自己的内部迭代限制,以其为准。',
|
||||
'ai.safety.blocklist': '命令黑名单',
|
||||
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 MCP Server 对内置和 ACP Agent 均生效。',
|
||||
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 Netcatty 执行层对内置和 ACP Agent 均生效。',
|
||||
'ai.safety.blocklist.placeholder': '正则表达式...',
|
||||
'ai.safety.blocklist.reset': '恢复默认',
|
||||
'ai.safety.blocklist.add': '添加规则',
|
||||
|
||||
@@ -75,7 +75,6 @@ class CustomThemeStore {
|
||||
if (payload.key === STORAGE_KEY_CUSTOM_THEMES) {
|
||||
// Another window changed custom themes — reload from localStorage
|
||||
this.loadFromStorage();
|
||||
this.notify();
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
@@ -129,6 +128,13 @@ class CustomThemeStore {
|
||||
this.notify();
|
||||
this.broadcastChange();
|
||||
};
|
||||
|
||||
replaceThemes = (themes: TerminalTheme[]) => {
|
||||
this.themes = themes.map((theme) => ({ ...theme, colors: { ...theme.colors }, isCustom: true }));
|
||||
this.saveToStorage();
|
||||
this.notify();
|
||||
this.broadcastChange();
|
||||
};
|
||||
}
|
||||
|
||||
// Singleton
|
||||
@@ -172,5 +178,9 @@ export const useCustomThemeActions = () => {
|
||||
customThemeStore.deleteTheme(id);
|
||||
}, []);
|
||||
|
||||
return { addTheme, updateTheme, deleteTheme };
|
||||
const replaceThemes = useCallback((themes: TerminalTheme[]) => {
|
||||
customThemeStore.replaceThemes(themes);
|
||||
}, []);
|
||||
|
||||
return { addTheme, updateTheme, deleteTheme, replaceThemes };
|
||||
};
|
||||
|
||||
@@ -68,8 +68,14 @@ class FontStore {
|
||||
// Add default fonts first
|
||||
TERMINAL_FONTS.forEach(font => fontMap.set(font.id, font));
|
||||
|
||||
// Add local fonts with a distinct ID namespace to avoid collisions
|
||||
// Build a set of built-in font family names for dedup (case-insensitive)
|
||||
const builtinFamilyNames = new Set(
|
||||
TERMINAL_FONTS.map(f => f.name.toLowerCase())
|
||||
);
|
||||
|
||||
// Add local fonts, skipping those already covered by built-in fonts
|
||||
localFonts.forEach(font => {
|
||||
if (builtinFamilyNames.has(font.name.toLowerCase())) return;
|
||||
const localId = font.id.startsWith('local-') ? font.id : `local-${font.id}`;
|
||||
fontMap.set(localId, { ...font, id: localId });
|
||||
});
|
||||
|
||||
@@ -28,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;
|
||||
}
|
||||
|
||||
@@ -78,6 +79,7 @@ export const useSftpPaneActions = ({
|
||||
listRemoteFiles,
|
||||
handleSessionError,
|
||||
isSessionError,
|
||||
clearSelectionsExcept,
|
||||
dirCacheTtlMs,
|
||||
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
|
||||
const normalizePathForCompare = useCallback((path: string): string => {
|
||||
@@ -465,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)) {
|
||||
@@ -475,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 !== "..") {
|
||||
@@ -489,7 +499,7 @@ export const useSftpPaneActions = ({
|
||||
|
||||
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: newSelection }));
|
||||
},
|
||||
[updateActiveTab],
|
||||
[updateActiveTab, clearSelectionsExcept, leftTabsRef, rightTabsRef],
|
||||
);
|
||||
|
||||
const clearSelection = useCallback((side: "left" | "right") => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
||||
STORAGE_KEY_AI_ACTIVE_MODEL,
|
||||
STORAGE_KEY_AI_PERMISSION_MODE,
|
||||
STORAGE_KEY_AI_TOOL_INTEGRATION_MODE,
|
||||
STORAGE_KEY_AI_HOST_PERMISSIONS,
|
||||
STORAGE_KEY_AI_EXTERNAL_AGENTS,
|
||||
STORAGE_KEY_AI_DEFAULT_AGENT,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
import type {
|
||||
AISession,
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
ProviderConfig,
|
||||
HostAIPermission,
|
||||
ExternalAgentConfig,
|
||||
@@ -29,8 +31,17 @@ import type {
|
||||
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
|
||||
|
||||
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
|
||||
interface AIBridge {
|
||||
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
|
||||
aiMcpSetToolIntegrationMode?: (mode: AIToolIntegrationMode) => Promise<unknown> | unknown;
|
||||
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
|
||||
aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown;
|
||||
aiMcpSetMaxIterations?: (maxIterations: number) => Promise<unknown> | unknown;
|
||||
}
|
||||
|
||||
function getAIBridge() {
|
||||
return (window as unknown as { netcatty?: Record<string, (...args: unknown[]) => unknown> }).netcatty;
|
||||
return (window as unknown as { netcatty?: AIBridge }).netcatty;
|
||||
}
|
||||
|
||||
const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
|
||||
@@ -192,6 +203,10 @@ export function useAIState() {
|
||||
if (stored === 'observer' || stored === 'confirm' || stored === 'autonomous') return stored;
|
||||
return 'confirm';
|
||||
});
|
||||
const [toolIntegrationMode, setToolIntegrationModeRaw] = useState<AIToolIntegrationMode>(() => {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE);
|
||||
return stored === 'skills' ? 'skills' : 'mcp';
|
||||
});
|
||||
const [hostPermissions, setHostPermissionsRaw] = useState<HostAIPermission[]>(() =>
|
||||
localStorageAdapter.read<HostAIPermission[]>(STORAGE_KEY_AI_HOST_PERMISSIONS) ?? []
|
||||
);
|
||||
@@ -252,7 +267,7 @@ export function useAIState() {
|
||||
let changed = false;
|
||||
const nextActiveSessionIdMap: Record<string, string | null> = {};
|
||||
|
||||
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap) as Array<[string, string | null]>) {
|
||||
const nextSessionId = sessionId && validSessionIds.has(sessionId) ? sessionId : null;
|
||||
nextActiveSessionIdMap[scopeKey] = nextSessionId;
|
||||
if (nextSessionId !== sessionId) {
|
||||
@@ -330,6 +345,13 @@ export function useAIState() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setToolIntegrationMode = useCallback((mode: AIToolIntegrationMode) => {
|
||||
setToolIntegrationModeRaw(mode);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE, mode);
|
||||
const bridge = getAIBridge();
|
||||
bridge?.aiMcpSetToolIntegrationMode?.(mode);
|
||||
}, []);
|
||||
|
||||
const setExternalAgents = useCallback((value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => {
|
||||
setExternalAgentsRaw(prev => {
|
||||
const next = typeof value === 'function' ? value(prev) : value;
|
||||
@@ -396,6 +418,15 @@ export function useAIState() {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_TOOL_INTEGRATION_MODE:
|
||||
{
|
||||
const mode = localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE) === 'skills'
|
||||
? 'skills'
|
||||
: 'mcp';
|
||||
setToolIntegrationModeRaw(mode);
|
||||
getAIBridge()?.aiMcpSetToolIntegrationMode?.(mode);
|
||||
}
|
||||
break;
|
||||
case STORAGE_KEY_AI_EXTERNAL_AGENTS: {
|
||||
const agents = localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS);
|
||||
if (agents != null && !Array.isArray(agents)) {
|
||||
@@ -511,8 +542,17 @@ export function useAIState() {
|
||||
bridge?.aiMcpSetCommandTimeout?.(initialTimeout);
|
||||
const initialMaxIter = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
|
||||
bridge?.aiMcpSetMaxIterations?.(initialMaxIter);
|
||||
const initialPermMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE) ?? 'confirm';
|
||||
const storedPermMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
|
||||
const initialPermMode: AIPermissionMode =
|
||||
storedPermMode === 'observer' || storedPermMode === 'confirm' || storedPermMode === 'autonomous'
|
||||
? storedPermMode
|
||||
: 'confirm';
|
||||
bridge?.aiMcpSetPermissionMode?.(initialPermMode);
|
||||
const initialToolMode: AIToolIntegrationMode =
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE) === 'skills'
|
||||
? 'skills'
|
||||
: 'mcp';
|
||||
bridge?.aiMcpSetToolIntegrationMode?.(initialToolMode);
|
||||
}, []);
|
||||
|
||||
// ── Session CRUD ──
|
||||
@@ -819,6 +859,8 @@ export function useAIState() {
|
||||
// Permission model
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
toolIntegrationMode,
|
||||
setToolIntegrationMode,
|
||||
hostPermissions,
|
||||
setHostPermissions,
|
||||
|
||||
|
||||
@@ -15,15 +15,15 @@ export type SshAgentStatus = {
|
||||
|
||||
export const useApplicationBackend = () => {
|
||||
const openExternal = useCallback(async (url: string) => {
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.openExternal) {
|
||||
await bridge.openExternal(url);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore and fall back below
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.openExternal) {
|
||||
// Bridge resolves on success (either via system browser or in-app
|
||||
// fallback window) and rejects only when both paths fail. Let the
|
||||
// rejection propagate so callers can present a user-facing message.
|
||||
await bridge.openExternal(url);
|
||||
return;
|
||||
}
|
||||
// Fallback for non-Electron environments (tests, dev server, etc.).
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -22,6 +22,32 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
|
||||
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
|
||||
import { notify } from '../notification';
|
||||
|
||||
/**
|
||||
* Check whether a sync payload has any meaningful user data. Covers all
|
||||
* synced entity arrays so that edge cases (e.g. user has 0 hosts but 1
|
||||
* port forwarding rule) are not mistakenly treated as "empty".
|
||||
*/
|
||||
function isPayloadEffectivelyEmpty(payload: SyncPayload): boolean {
|
||||
// Check all synced entity arrays.
|
||||
const hasEntities =
|
||||
(payload.hosts?.length ?? 0) > 0 ||
|
||||
(payload.keys?.length ?? 0) > 0 ||
|
||||
(payload.snippets?.length ?? 0) > 0 ||
|
||||
(payload.identities?.length ?? 0) > 0 ||
|
||||
(payload.customGroups?.length ?? 0) > 0 ||
|
||||
(payload.snippetPackages?.length ?? 0) > 0 ||
|
||||
(payload.portForwardingRules?.length ?? 0) > 0 ||
|
||||
(payload.knownHosts?.length ?? 0) > 0 ||
|
||||
(payload.groupConfigs?.length ?? 0) > 0;
|
||||
if (hasEntities) return false;
|
||||
// Also consider settings: if any key has a defined value, the user has
|
||||
// customized something worth preserving.
|
||||
if (payload.settings && Object.values(payload.settings).some((v) => v !== undefined)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface AutoSyncConfig {
|
||||
// Data to sync
|
||||
hosts: SyncPayload['hosts'];
|
||||
@@ -32,6 +58,7 @@ interface AutoSyncConfig {
|
||||
snippetPackages?: SyncPayload['snippetPackages'];
|
||||
portForwardingRules?: SyncPayload['portForwardingRules'];
|
||||
knownHosts?: SyncPayload['knownHosts'];
|
||||
groupConfigs?: SyncPayload['groupConfigs'];
|
||||
/** Opaque token that changes whenever a synced setting changes. */
|
||||
settingsVersion?: number;
|
||||
|
||||
@@ -56,10 +83,27 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastSyncedDataRef = useRef<string>('');
|
||||
const hasCheckedRemoteRef = useRef(false);
|
||||
/** True once checkRemoteVersion has completed (success or failure). Until
|
||||
* this is set, the debounced auto-sync effect will not fire, preventing
|
||||
* an empty local vault from racing ahead and overwriting a non-empty
|
||||
* cloud vault before the startup pull has run. See #679. */
|
||||
const remoteCheckDoneRef = useRef(false);
|
||||
const isInitializedRef = useRef(false);
|
||||
const isSyncRunningRef = useRef(false);
|
||||
const skipNextSyncRef = useRef(false);
|
||||
|
||||
// State for the empty-vault-vs-cloud confirmation dialog (Fix D).
|
||||
// When checkRemoteVersion detects that the local vault is empty but
|
||||
// the cloud has data, it pauses and exposes this state so the root
|
||||
// component can render a confirmation dialog.
|
||||
const [emptyVaultConflict, setEmptyVaultConflict] = useState<{
|
||||
remotePayload: SyncPayload;
|
||||
hostCount: number;
|
||||
keyCount: number;
|
||||
snippetCount: number;
|
||||
} | null>(null);
|
||||
const emptyVaultResolveRef = useRef<((action: 'restore' | 'keep-empty') => void) | null>(null);
|
||||
|
||||
// Listen for SFTP bookmark changes to trigger auto-sync
|
||||
const [bookmarksVersion, setBookmarksVersion] = useState(0);
|
||||
useEffect(() => {
|
||||
@@ -95,6 +139,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
snippetPackages: config.snippetPackages,
|
||||
portForwardingRules: effectivePFRules,
|
||||
knownHosts: effectiveKnownHosts,
|
||||
groupConfigs: config.groupConfigs,
|
||||
};
|
||||
}, [
|
||||
config.hosts,
|
||||
@@ -105,6 +150,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
config.snippetPackages,
|
||||
config.portForwardingRules,
|
||||
config.knownHosts,
|
||||
config.groupConfigs,
|
||||
]);
|
||||
|
||||
// Build sync payload
|
||||
@@ -170,6 +216,16 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
throw new Error(t('sync.credentialsUnavailable'));
|
||||
}
|
||||
|
||||
// Prevent pushing an empty vault to cloud. This is almost always
|
||||
// a sign that the local state was lost (update, import failure,
|
||||
// storage corruption) rather than a deliberate "delete everything".
|
||||
// We only block auto-sync — manual trigger from Settings can still
|
||||
// push if the user explicitly wants to.
|
||||
if (isPayloadEffectivelyEmpty(payload) && trigger === 'auto') {
|
||||
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await sync.syncNow(payload);
|
||||
|
||||
// Apply merged payloads first (before checking for failures) so local
|
||||
@@ -231,18 +287,53 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const remotePayload = await sync.downloadFromProvider(connectedProvider);
|
||||
|
||||
if (remotePayload && remotePayload.syncedAt > state.localUpdatedAt) {
|
||||
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
|
||||
const localPayload = buildPayload();
|
||||
const localIsEmpty = isPayloadEffectivelyEmpty(localPayload);
|
||||
const remoteHasData = !isPayloadEffectivelyEmpty(remotePayload);
|
||||
|
||||
// If local vault is empty but cloud has data, this almost certainly
|
||||
// means the user's data was lost (update, storage corruption, etc.).
|
||||
// Pause and ask the user what to do instead of silently merging.
|
||||
if (localIsEmpty && remoteHasData) {
|
||||
const userAction = await new Promise<'restore' | 'keep-empty'>((resolve) => {
|
||||
emptyVaultResolveRef.current = resolve;
|
||||
setEmptyVaultConflict({
|
||||
remotePayload,
|
||||
hostCount: remotePayload.hosts?.length ?? 0,
|
||||
keyCount: remotePayload.keys?.length ?? 0,
|
||||
snippetCount: remotePayload.snippets?.length ?? 0,
|
||||
});
|
||||
});
|
||||
setEmptyVaultConflict(null);
|
||||
emptyVaultResolveRef.current = null;
|
||||
|
||||
if (userAction === 'restore') {
|
||||
config.onApplyPayload(remotePayload);
|
||||
skipNextSyncRef.current = true;
|
||||
notify.success(t('sync.autoSync.restoredMessage'), t('sync.autoSync.restoredTitle'));
|
||||
} else {
|
||||
// User chose to keep the empty vault. Don't apply remote data.
|
||||
// The next auto-sync will eventually push the empty state if
|
||||
// the user makes another edit.
|
||||
notify.info(t('sync.autoSync.keptLocalMessage'), t('sync.autoSync.keptLocalTitle'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
|
||||
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
|
||||
|
||||
config.onApplyPayload(mergeResult.payload);
|
||||
// Don't save base or skip auto-sync — let the data-change effect
|
||||
// naturally trigger an upload of the merged payload (which will
|
||||
// go through syncAllProviders and save base on success).
|
||||
// Prevent the data-change effect from immediately re-uploading the
|
||||
// merged payload — the merge already incorporated both sides. The
|
||||
// next deliberate edit by the user will trigger a normal sync.
|
||||
skipNextSyncRef.current = true;
|
||||
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AutoSync] Failed to check remote version:', error);
|
||||
} finally {
|
||||
remoteCheckDoneRef.current = true;
|
||||
}
|
||||
}, [sync, config, buildPayload, t]);
|
||||
|
||||
@@ -252,7 +343,15 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Don't auto-sync until the startup remote check has completed.
|
||||
// Without this gate, an empty local vault can push to the cloud
|
||||
// before checkRemoteVersion even runs, overwriting a non-empty
|
||||
// remote vault — the exact bug described in #679.
|
||||
if (!remoteCheckDoneRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip initial render
|
||||
if (!isInitializedRef.current) {
|
||||
isInitializedRef.current = true;
|
||||
@@ -310,19 +409,32 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, checkRemoteVersion]);
|
||||
|
||||
// Reset check flag when provider disconnects
|
||||
// Reset check flags when provider disconnects
|
||||
useEffect(() => {
|
||||
if (!sync.hasAnyConnectedProvider) {
|
||||
hasCheckedRemoteRef.current = false;
|
||||
remoteCheckDoneRef.current = false;
|
||||
}
|
||||
}, [sync.hasAnyConnectedProvider]);
|
||||
|
||||
const resolveEmptyVaultConflict = useCallback((action: 'restore' | 'keep-empty') => {
|
||||
// Guard: resolve only once (prevents double-click from entering an
|
||||
// inconsistent state). The ref is nulled immediately so subsequent
|
||||
// calls are no-ops.
|
||||
const resolve = emptyVaultResolveRef.current;
|
||||
if (!resolve) return;
|
||||
emptyVaultResolveRef.current = null;
|
||||
resolve(action);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
syncNow,
|
||||
buildPayload,
|
||||
isSyncing: sync.isSyncing,
|
||||
isConnected: sync.hasAnyConnectedProvider,
|
||||
autoSyncEnabled: sync.autoSyncEnabled,
|
||||
emptyVaultConflict,
|
||||
resolveEmptyVaultConflict,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -90,7 +90,21 @@ export interface CloudSyncHook {
|
||||
syncToProvider: (provider: CloudProvider, payload: SyncPayload) => Promise<SyncResult>;
|
||||
downloadFromProvider: (provider: CloudProvider) => Promise<SyncPayload | null>;
|
||||
resolveConflict: (resolution: ConflictResolution) => Promise<SyncPayload | null>;
|
||||
|
||||
|
||||
// Gist Revision History
|
||||
getGistRevisionHistory: () => Promise<Array<{ version: string; date: Date }>>;
|
||||
downloadGistRevision: (sha: string) => Promise<{
|
||||
payload: SyncPayload;
|
||||
meta: import('../../domain/sync').SyncFileMeta;
|
||||
preview: {
|
||||
hostCount: number;
|
||||
keyCount: number;
|
||||
snippetCount: number;
|
||||
identityCount: number;
|
||||
portForwardingRuleCount: number;
|
||||
};
|
||||
} | null>;
|
||||
|
||||
// Settings
|
||||
setAutoSync: (enabled: boolean, intervalMinutes?: number) => void;
|
||||
setDeviceName: (name: string) => void;
|
||||
@@ -475,6 +489,10 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
syncToProvider: syncToProviderWithUnlock,
|
||||
downloadFromProvider: downloadFromProviderWithUnlock,
|
||||
resolveConflict: resolveConflictWithUnlock,
|
||||
|
||||
// Gist Revision History (#679)
|
||||
getGistRevisionHistory: manager.getGistRevisionHistory.bind(manager),
|
||||
downloadGistRevision: manager.downloadGistRevision.bind(manager),
|
||||
|
||||
// Settings
|
||||
setAutoSync,
|
||||
|
||||
@@ -144,6 +144,7 @@ function applyImmersiveStyle(css: string, isDark: boolean, bg: string) {
|
||||
|
||||
function removeImmersiveStyle() {
|
||||
document.getElementById(STYLE_ID)?.remove();
|
||||
delete document.documentElement.dataset.immersiveTheme;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -151,12 +152,10 @@ function removeImmersiveStyle() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useImmersiveMode({
|
||||
isImmersive,
|
||||
activeTabId,
|
||||
activeTerminalTheme,
|
||||
restoreOriginalTheme,
|
||||
}: {
|
||||
isImmersive: boolean;
|
||||
activeTabId: string;
|
||||
activeTerminalTheme: TerminalTheme | null;
|
||||
restoreOriginalTheme: () => void;
|
||||
@@ -170,18 +169,19 @@ export function useImmersiveMode({
|
||||
|
||||
// APPLY: useLayoutEffect — runs before paint, O(1) Map lookup, single DOM write
|
||||
useLayoutEffect(() => {
|
||||
if (isImmersive && isTerminalTab && activeTerminalTheme) {
|
||||
if (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);
|
||||
document.documentElement.dataset.immersiveTheme = fp;
|
||||
}
|
||||
}, [isImmersive, isTerminalTab, activeTerminalTheme]);
|
||||
}, [isTerminalTab, activeTerminalTheme]);
|
||||
|
||||
// RESTORE: useEffect — runs after paint, with fade overlay
|
||||
useEffect(() => {
|
||||
if (isImmersive && isTerminalTab && activeTerminalTheme) return;
|
||||
if (isTerminalTab && activeTerminalTheme) return;
|
||||
if (!overrideActiveRef.current) return;
|
||||
overrideActiveRef.current = false;
|
||||
appliedFpRef.current = null;
|
||||
@@ -198,7 +198,7 @@ export function useImmersiveMode({
|
||||
});
|
||||
const fallback = setTimeout(() => { if (overlay.parentNode) overlay.remove(); }, 400);
|
||||
return () => { clearTimeout(fallback); if (overlay.parentNode) overlay.remove(); };
|
||||
}, [isImmersive, isTerminalTab, activeTerminalTheme, restoreOriginalTheme]);
|
||||
}, [isTerminalTab, activeTerminalTheme, restoreOriginalTheme]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* when the application starts, not when the user navigates to the port forwarding page.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
|
||||
import { GroupConfig, Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../../domain/groupConfig";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
@@ -19,6 +20,7 @@ export interface UsePortForwardingAutoStartOptions {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
groupConfigs: GroupConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,11 +31,13 @@ export const usePortForwardingAutoStart = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
groupConfigs,
|
||||
}: UsePortForwardingAutoStartOptions): void => {
|
||||
const autoStartExecutedRef = useRef(false);
|
||||
const hostsRef = useRef<Host[]>(hosts);
|
||||
const keysRef = useRef<SSHKey[]>(keys);
|
||||
const identitiesRef = useRef<Identity[]>(identities);
|
||||
const groupConfigsRef = useRef<GroupConfig[]>(groupConfigs);
|
||||
|
||||
const isHostAuthReady = useCallback((host: Host, seen = new Set<string>()): boolean => {
|
||||
if (!host || seen.has(host.id)) return true;
|
||||
@@ -73,6 +77,16 @@ export const usePortForwardingAutoStart = ({
|
||||
identitiesRef.current = identities;
|
||||
}, [identities]);
|
||||
|
||||
useEffect(() => {
|
||||
groupConfigsRef.current = groupConfigs;
|
||||
}, [groupConfigs]);
|
||||
|
||||
const resolveEffectiveHost = useCallback((host: Host): Host => {
|
||||
if (!host.group) return host;
|
||||
const defaults = resolveGroupDefaults(host.group, groupConfigsRef.current);
|
||||
return applyGroupDefaults(host, defaults);
|
||||
}, []);
|
||||
|
||||
// Set up the reconnect callback
|
||||
useEffect(() => {
|
||||
const handleReconnect = async (
|
||||
@@ -89,11 +103,12 @@ export const usePortForwardingAutoStart = ({
|
||||
return { success: false, error: "Rule or host not found" };
|
||||
}
|
||||
|
||||
const host = hostsRef.current.find((h) => h.id === rule.hostId);
|
||||
if (!host) {
|
||||
const rawHost = hostsRef.current.find((h) => h.id === rule.hostId);
|
||||
if (!rawHost) {
|
||||
return { success: false, error: "Host not found" };
|
||||
}
|
||||
|
||||
const host = resolveEffectiveHost(rawHost);
|
||||
return startPortForward(rule, host, hostsRef.current, keysRef.current, identitiesRef.current, onStatusChange, true);
|
||||
};
|
||||
|
||||
@@ -101,7 +116,7 @@ export const usePortForwardingAutoStart = ({
|
||||
return () => {
|
||||
setReconnectCallback(null);
|
||||
};
|
||||
}, []);
|
||||
}, [resolveEffectiveHost]);
|
||||
|
||||
// Auto-start rules on app launch
|
||||
useEffect(() => {
|
||||
@@ -146,8 +161,9 @@ export const usePortForwardingAutoStart = ({
|
||||
|
||||
// Start each auto-start rule
|
||||
for (const rule of autoStartRules) {
|
||||
const host = hosts.find((h) => h.id === rule.hostId);
|
||||
if (host) {
|
||||
const rawHost = hosts.find((h) => h.id === rule.hostId);
|
||||
if (rawHost) {
|
||||
const host = resolveEffectiveHost(rawHost);
|
||||
void startPortForward(
|
||||
rule,
|
||||
host,
|
||||
@@ -180,5 +196,5 @@ export const usePortForwardingAutoStart = ({
|
||||
};
|
||||
|
||||
void runAutoStart();
|
||||
}, [hosts, identities, isHostAuthReady, keys]);
|
||||
}, [hosts, identities, isHostAuthReady, keys, resolveEffectiveHost]);
|
||||
};
|
||||
|
||||
@@ -40,18 +40,26 @@ export const useSessionState = () => {
|
||||
|
||||
const createLocalTerminal = useCallback((options?: {
|
||||
shellType?: TerminalSession['shellType'];
|
||||
shell?: string;
|
||||
shellArgs?: string[];
|
||||
shellName?: string;
|
||||
shellIcon?: string;
|
||||
}) => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
const localHostId = `local-${sessionId}`;
|
||||
const newSession: TerminalSession = {
|
||||
id: sessionId,
|
||||
hostId: localHostId,
|
||||
hostLabel: 'Local Terminal',
|
||||
hostLabel: options?.shellName || 'Local Terminal',
|
||||
hostname: 'localhost',
|
||||
username: 'local',
|
||||
status: 'connecting',
|
||||
protocol: 'local',
|
||||
shellType: options?.shellType,
|
||||
localShell: options?.shell,
|
||||
localShellArgs: options?.shellArgs,
|
||||
localShellName: options?.shellName,
|
||||
localShellIcon: options?.shellIcon,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
@@ -451,6 +459,10 @@ export const useSessionState = () => {
|
||||
moshEnabled: session.moshEnabled,
|
||||
shellType: nextShellType,
|
||||
charset: session.charset,
|
||||
localShell: session.localShell,
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
};
|
||||
|
||||
// Add pane to existing workspace
|
||||
@@ -483,6 +495,10 @@ export const useSessionState = () => {
|
||||
moshEnabled: session.moshEnabled,
|
||||
shellType: nextShellType,
|
||||
charset: session.charset,
|
||||
localShell: session.localShell,
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
};
|
||||
|
||||
const hint: SplitHint = {
|
||||
@@ -659,6 +675,10 @@ export const useSessionState = () => {
|
||||
shellType: nextShellType,
|
||||
charset: session.charset,
|
||||
serialConfig: session.serialConfig,
|
||||
localShell: session.localShell,
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
};
|
||||
|
||||
setActiveTabId(newSession.id);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_SYNC,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
@@ -33,10 +34,11 @@ import {
|
||||
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';
|
||||
import { getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
|
||||
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
|
||||
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
@@ -125,7 +127,7 @@ const applyThemeTokens = (
|
||||
accentOverride: string,
|
||||
) => {
|
||||
const root = window.document.documentElement;
|
||||
// If immersive mode is active (style tag present), it owns the dark/light class — don't override
|
||||
// If immersive override 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);
|
||||
@@ -195,6 +197,17 @@ export const useSettingsState = () => {
|
||||
});
|
||||
const [syncConfig, setSyncConfig] = useState<SyncConfig | null>(() => localStorageAdapter.read<SyncConfig>(STORAGE_KEY_SYNC));
|
||||
const [terminalThemeId, setTerminalThemeId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME) || DEFAULT_TERMINAL_THEME);
|
||||
const [followAppTerminalTheme, setFollowAppTerminalThemeState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_TERM_FOLLOW_APP_THEME);
|
||||
if (stored !== null) return stored === 'true';
|
||||
// First time seeing this key. For genuinely fresh installs (no existing
|
||||
// terminal theme in storage) default ON so the terminal matches the app
|
||||
// theme out of the box. For upgrades from an older version (existing
|
||||
// terminal theme present) default OFF to avoid silently overriding the
|
||||
// user's manual choice.
|
||||
const isUpgrade = !!localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
|
||||
return !isUpgrade;
|
||||
});
|
||||
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY) || DEFAULT_FONT_FAMILY);
|
||||
const [terminalFontSize, setTerminalFontSize] = useState<number>(() => localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE) || DEFAULT_FONT_SIZE);
|
||||
const [uiLanguage, setUiLanguage] = useState<UILanguage>(() => {
|
||||
@@ -340,20 +353,6 @@ 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)));
|
||||
@@ -465,21 +464,13 @@ export const useSettingsState = () => {
|
||||
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, notifySettingsChanged]);
|
||||
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
@@ -561,6 +552,10 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_TERM_THEME && typeof value === 'string') {
|
||||
setTerminalThemeId(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_FOLLOW_APP_THEME) {
|
||||
const next = value === true || value === 'true';
|
||||
setFollowAppTerminalThemeState((prev) => (prev === next ? prev : next));
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_FONT_FAMILY && typeof value === 'string') {
|
||||
setTerminalFontFamilyId(value);
|
||||
}
|
||||
@@ -625,9 +620,6 @@ export const useSettingsState = () => {
|
||||
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));
|
||||
}
|
||||
@@ -667,20 +659,20 @@ export const useSettingsState = () => {
|
||||
const settingsSnapshotRef = useRef({
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, immersiveMode,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
});
|
||||
settingsSnapshotRef.current = {
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, immersiveMode,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
};
|
||||
|
||||
// Listen for storage changes from other windows (cross-window sync)
|
||||
@@ -757,6 +749,13 @@ export const useSettingsState = () => {
|
||||
setTerminalThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync follow-app-theme toggle from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_FOLLOW_APP_THEME && e.newValue) {
|
||||
const next = e.newValue === 'true';
|
||||
if (next !== s.followAppTerminalTheme) {
|
||||
setFollowAppTerminalThemeState(next);
|
||||
}
|
||||
}
|
||||
// Sync terminal font family from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_FONT_FAMILY && e.newValue) {
|
||||
if (e.newValue !== s.terminalFontFamilyId) {
|
||||
@@ -849,13 +848,6 @@ export const useSettingsState = () => {
|
||||
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') {
|
||||
@@ -881,6 +873,12 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
}, [terminalThemeId, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(followAppTerminalTheme));
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(followAppTerminalTheme));
|
||||
}, [followAppTerminalTheme, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
|
||||
if (!persistMountedRef.current) return;
|
||||
@@ -1148,12 +1146,21 @@ export const useSettingsState = () => {
|
||||
// Subscribe to custom theme changes so editing in-place triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
const currentTerminalTheme = useMemo(
|
||||
() => TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|
||||
const currentTerminalTheme = useMemo(() => {
|
||||
// When "Follow Application Theme" is enabled, pick the terminal theme
|
||||
// whose background matches the active UI theme preset.
|
||||
if (followAppTerminalTheme) {
|
||||
const activeUiThemeId = resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId;
|
||||
const mapped = getTerminalThemeForUiTheme(activeUiThemeId);
|
||||
if (mapped) {
|
||||
const found = TERMINAL_THEMES.find(t => t.id === mapped);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|
||||
|| customThemes.find(t => t.id === terminalThemeId)
|
||||
|| TERMINAL_THEMES[0],
|
||||
[terminalThemeId, customThemes]
|
||||
);
|
||||
|| TERMINAL_THEMES[0];
|
||||
}, [terminalThemeId, customThemes, followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId]);
|
||||
|
||||
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
|
||||
key: K,
|
||||
@@ -1188,6 +1195,8 @@ export const useSettingsState = () => {
|
||||
setUiLanguage,
|
||||
terminalThemeId,
|
||||
setTerminalThemeId,
|
||||
followAppTerminalTheme,
|
||||
setFollowAppTerminalTheme: setFollowAppTerminalThemeState,
|
||||
currentTerminalTheme,
|
||||
terminalFontFamilyId,
|
||||
setTerminalFontFamilyId,
|
||||
@@ -1247,8 +1256,6 @@ export const useSettingsState = () => {
|
||||
setGlobalHotkeyEnabled,
|
||||
rehydrateAllFromStorage,
|
||||
reapplyCurrentTheme,
|
||||
immersiveMode,
|
||||
setImmersiveMode,
|
||||
workspaceFocusStyle,
|
||||
setWorkspaceFocusStyle,
|
||||
// Opaque version that changes when any synced setting changes, used by useAutoSync.
|
||||
@@ -1259,7 +1266,7 @@ export const useSettingsState = () => {
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
customThemes, immersiveMode, workspaceFocusStyle,
|
||||
customThemes, workspaceFocusStyle,
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -57,6 +57,7 @@ export const useSftpState = (
|
||||
getActivePane,
|
||||
updateTab,
|
||||
updateActiveTab,
|
||||
clearSelectionsExcept,
|
||||
setTabShowHiddenFiles,
|
||||
addTab,
|
||||
closeTab,
|
||||
@@ -235,6 +236,7 @@ export const useSftpState = (
|
||||
listRemoteFiles,
|
||||
handleSessionError,
|
||||
isSessionError,
|
||||
clearSelectionsExcept,
|
||||
dirCacheTtlMs: DIR_CACHE_TTL_MS,
|
||||
});
|
||||
|
||||
@@ -339,6 +341,7 @@ export const useSftpState = (
|
||||
toggleSelection,
|
||||
rangeSelect,
|
||||
clearSelection,
|
||||
clearSelectionsExcept,
|
||||
selectAll,
|
||||
setFilter,
|
||||
setFilenameEncoding,
|
||||
@@ -392,6 +395,7 @@ export const useSftpState = (
|
||||
toggleSelection,
|
||||
rangeSelect,
|
||||
clearSelection,
|
||||
clearSelectionsExcept,
|
||||
selectAll,
|
||||
setFilter,
|
||||
setFilenameEncoding,
|
||||
@@ -448,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>) =>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
/**
|
||||
* Hook for persisting a boolean value to localStorage.
|
||||
* Syncs across components in the same window via a custom event,
|
||||
* and across windows via the native storage event.
|
||||
* @param storageKey - The key to use for localStorage
|
||||
* @param fallback - The default value if no stored value exists (defaults to false)
|
||||
* @returns A tuple of [value, setValue] similar to useState
|
||||
@@ -16,9 +18,38 @@ export const useStoredBoolean = (
|
||||
return stored ?? fallback;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeBoolean(storageKey, value);
|
||||
}, [storageKey, value]);
|
||||
const setAndPersist = useCallback((next: boolean | ((prev: boolean) => boolean)) => {
|
||||
setValue((prev) => {
|
||||
const resolved = typeof next === "function" ? next(prev) : next;
|
||||
localStorageAdapter.writeBoolean(storageKey, resolved);
|
||||
// Notify other same-window consumers
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("stored-boolean-change", { detail: { key: storageKey, value: resolved } }),
|
||||
);
|
||||
return resolved;
|
||||
});
|
||||
}, [storageKey]);
|
||||
|
||||
return [value, setValue] as const;
|
||||
useEffect(() => {
|
||||
// Sync from other components in the same window
|
||||
const handleCustom = (e: Event) => {
|
||||
const { key, value: newValue } = (e as CustomEvent).detail;
|
||||
if (key === storageKey) setValue(newValue);
|
||||
};
|
||||
// Sync from other windows
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (e.key === storageKey) {
|
||||
const stored = localStorageAdapter.readBoolean(storageKey);
|
||||
setValue(stored ?? fallback);
|
||||
}
|
||||
};
|
||||
window.addEventListener("stored-boolean-change", handleCustom);
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => {
|
||||
window.removeEventListener("stored-boolean-change", handleCustom);
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
};
|
||||
}, [storageKey, fallback]);
|
||||
|
||||
return [value, setAndPersist] as const;
|
||||
};
|
||||
|
||||
@@ -128,6 +128,22 @@ export const useTerminalBackend = () => {
|
||||
return bridge.getSessionPwd(sessionId);
|
||||
}, []);
|
||||
|
||||
const getSessionRemoteInfo = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSessionRemoteInfo) {
|
||||
return { success: false, error: 'getSessionRemoteInfo unavailable' };
|
||||
}
|
||||
return bridge.getSessionRemoteInfo(sessionId);
|
||||
}, []);
|
||||
|
||||
const getSessionDistroInfo = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSessionDistroInfo) {
|
||||
return { success: false, error: 'getSessionDistroInfo unavailable' };
|
||||
}
|
||||
return bridge.getSessionDistroInfo(sessionId);
|
||||
}, []);
|
||||
|
||||
const getServerStats = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getServerStats) return { success: false, error: 'getServerStats unavailable' };
|
||||
@@ -150,6 +166,8 @@ export const useTerminalBackend = () => {
|
||||
listSerialPorts,
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
getSessionRemoteInfo,
|
||||
getSessionDistroInfo,
|
||||
getServerStats,
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
|
||||
import {
|
||||
ConnectionLog,
|
||||
GroupConfig,
|
||||
Host,
|
||||
Identity,
|
||||
KeyCategory,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from "../../infrastructure/config/defaultData";
|
||||
import {
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
STORAGE_KEY_GROUP_CONFIGS,
|
||||
STORAGE_KEY_GROUPS,
|
||||
STORAGE_KEY_HOSTS,
|
||||
STORAGE_KEY_IDENTITIES,
|
||||
@@ -30,9 +32,11 @@ import {
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
decryptGroupConfigs,
|
||||
decryptHosts,
|
||||
decryptIdentities,
|
||||
decryptKeys,
|
||||
encryptGroupConfigs,
|
||||
encryptHosts,
|
||||
encryptIdentities,
|
||||
encryptKeys,
|
||||
@@ -46,6 +50,7 @@ type ExportableVaultData = {
|
||||
customGroups: string[];
|
||||
snippetPackages?: string[];
|
||||
knownHosts?: KnownHost[];
|
||||
groupConfigs?: GroupConfig[];
|
||||
};
|
||||
|
||||
type LegacyKeyRecord = Record<string, unknown> & { id?: string; source?: string };
|
||||
@@ -107,6 +112,7 @@ export const useVaultState = () => {
|
||||
const [shellHistory, setShellHistory] = useState<ShellHistoryEntry[]>([]);
|
||||
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
|
||||
const [managedSources, setManagedSources] = useState<ManagedSource[]>([]);
|
||||
const [groupConfigs, setGroupConfigs] = useState<GroupConfig[]>([]);
|
||||
|
||||
// Write-version counters prevent out-of-order async writes from overwriting
|
||||
// newer data. Each update bumps the counter; the .then() callback only
|
||||
@@ -114,6 +120,7 @@ export const useVaultState = () => {
|
||||
const hostsWriteVersion = useRef(0);
|
||||
const keysWriteVersion = useRef(0);
|
||||
const identitiesWriteVersion = useRef(0);
|
||||
const groupConfigsWriteVersion = useRef(0);
|
||||
|
||||
// Read-sequence counters for cross-window storage events. Each incoming
|
||||
// event bumps the counter; the async decrypt callback only applies state if
|
||||
@@ -122,6 +129,7 @@ export const useVaultState = () => {
|
||||
const hostsReadSeq = useRef(0);
|
||||
const keysReadSeq = useRef(0);
|
||||
const identitiesReadSeq = useRef(0);
|
||||
const groupConfigsReadSeq = useRef(0);
|
||||
|
||||
const updateHosts = useCallback((data: Host[]) => {
|
||||
const cleaned = data.map(sanitizeHost);
|
||||
@@ -176,6 +184,15 @@ export const useVaultState = () => {
|
||||
localStorageAdapter.write(STORAGE_KEY_MANAGED_SOURCES, data);
|
||||
}, []);
|
||||
|
||||
const updateGroupConfigs = useCallback((data: GroupConfig[]) => {
|
||||
setGroupConfigs(data);
|
||||
const ver = ++groupConfigsWriteVersion.current;
|
||||
encryptGroupConfigs(data).then((enc) => {
|
||||
if (ver === groupConfigsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearVaultData = useCallback(() => {
|
||||
updateHosts([]);
|
||||
updateKeys([]);
|
||||
@@ -185,6 +202,7 @@ export const useVaultState = () => {
|
||||
updateCustomGroups([]);
|
||||
updateKnownHosts([]);
|
||||
updateManagedSources([]);
|
||||
updateGroupConfigs([]);
|
||||
localStorageAdapter.remove(STORAGE_KEY_LEGACY_KEYS);
|
||||
}, [
|
||||
updateHosts,
|
||||
@@ -195,6 +213,7 @@ export const useVaultState = () => {
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
updateGroupConfigs,
|
||||
]);
|
||||
|
||||
const addShellHistoryEntry = useCallback(
|
||||
@@ -430,6 +449,20 @@ export const useVaultState = () => {
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
);
|
||||
if (savedManagedSources) setManagedSources(savedManagedSources);
|
||||
|
||||
// Load group configs
|
||||
const savedGroupConfigs = localStorageAdapter.read<GroupConfig[]>(STORAGE_KEY_GROUP_CONFIGS);
|
||||
if (savedGroupConfigs) {
|
||||
const gcVer = ++groupConfigsWriteVersion.current;
|
||||
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
|
||||
if (gcVer === groupConfigsWriteVersion.current) {
|
||||
setGroupConfigs(decryptedGC);
|
||||
encryptGroupConfigs(decryptedGC).then((enc) => {
|
||||
if (gcVer === groupConfigsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
@@ -529,6 +562,19 @@ export const useVaultState = () => {
|
||||
if (key === STORAGE_KEY_MANAGED_SOURCES) {
|
||||
const next = safeParse<ManagedSource[]>(event.newValue) ?? [];
|
||||
setManagedSources(next);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_GROUP_CONFIGS) {
|
||||
const next = safeParse<GroupConfig[]>(event.newValue) ?? [];
|
||||
++groupConfigsWriteVersion.current;
|
||||
const seq = ++groupConfigsReadSeq.current;
|
||||
const writeAtStart = groupConfigsWriteVersion.current;
|
||||
decryptGroupConfigs(next).then((dec) => {
|
||||
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current)
|
||||
setGroupConfigs(dec);
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -536,6 +582,20 @@ export const useVaultState = () => {
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
}, []);
|
||||
|
||||
const updateHostLastConnected = useCallback((hostId: string) => {
|
||||
setHosts((prev) => {
|
||||
const next = prev.map((h) =>
|
||||
h.id === hostId ? { ...h, lastConnectedAt: Date.now() } : h,
|
||||
);
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
encryptHosts(next).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateHostDistro = useCallback((hostId: string, distro: string) => {
|
||||
const normalized = normalizeDistroId(distro);
|
||||
setHosts((prev) => {
|
||||
@@ -560,8 +620,9 @@ export const useVaultState = () => {
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
knownHosts,
|
||||
groupConfigs,
|
||||
}),
|
||||
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts],
|
||||
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
|
||||
);
|
||||
|
||||
const importData = useCallback(
|
||||
@@ -573,6 +634,7 @@ export const useVaultState = () => {
|
||||
if (payload.customGroups) updateCustomGroups(payload.customGroups);
|
||||
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
|
||||
if (payload.knownHosts) updateKnownHosts(payload.knownHosts);
|
||||
if (Array.isArray(payload.groupConfigs)) updateGroupConfigs(payload.groupConfigs);
|
||||
},
|
||||
[
|
||||
updateHosts,
|
||||
@@ -582,6 +644,7 @@ export const useVaultState = () => {
|
||||
updateCustomGroups,
|
||||
updateSnippetPackages,
|
||||
updateKnownHosts,
|
||||
updateGroupConfigs,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -604,6 +667,7 @@ export const useVaultState = () => {
|
||||
shellHistory,
|
||||
connectionLogs,
|
||||
managedSources,
|
||||
groupConfigs,
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
updateIdentities,
|
||||
@@ -612,6 +676,7 @@ export const useVaultState = () => {
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
updateGroupConfigs,
|
||||
addShellHistoryEntry,
|
||||
clearShellHistory,
|
||||
addConnectionLog,
|
||||
@@ -620,6 +685,7 @@ export const useVaultState = () => {
|
||||
deleteConnectionLog,
|
||||
clearUnsavedConnectionLogs,
|
||||
updateHostDistro,
|
||||
updateHostLastConnected,
|
||||
convertKnownHostToHost,
|
||||
exportData,
|
||||
importDataFromString,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
GroupConfig,
|
||||
Host,
|
||||
Identity,
|
||||
KnownHost,
|
||||
@@ -41,7 +42,7 @@ import {
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
|
||||
STORAGE_KEY_CUSTOM_THEMES,
|
||||
STORAGE_KEY_IMMERSIVE_MODE,
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -57,6 +58,7 @@ export interface SyncableVaultData {
|
||||
customGroups: string[];
|
||||
snippetPackages?: string[];
|
||||
knownHosts: KnownHost[];
|
||||
groupConfigs?: GroupConfig[];
|
||||
}
|
||||
|
||||
/** Callbacks used by `applySyncPayload` to import data into local state. */
|
||||
@@ -168,9 +170,9 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
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';
|
||||
|
||||
const showRecent = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
|
||||
if (showRecent != null) settings.showRecentHosts = showRecent;
|
||||
|
||||
return Object.keys(settings).length > 0 ? settings : undefined;
|
||||
}
|
||||
@@ -234,8 +236,8 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
// 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));
|
||||
// Immersive mode (legacy — always enabled, ignore incoming value)
|
||||
if (settings.showRecentHosts != null) localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS, settings.showRecentHosts);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -261,6 +263,7 @@ export function buildSyncPayload(
|
||||
customGroups: vault.customGroups,
|
||||
snippetPackages: vault.snippetPackages,
|
||||
knownHosts: vault.knownHosts,
|
||||
groupConfigs: vault.groupConfigs,
|
||||
portForwardingRules,
|
||||
settings: collectSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
@@ -294,6 +297,9 @@ export function applySyncPayload(
|
||||
if (payload.knownHosts !== undefined) {
|
||||
vaultImport.knownHosts = payload.knownHosts;
|
||||
}
|
||||
if (Array.isArray(payload.groupConfigs)) {
|
||||
vaultImport.groupConfigs = payload.groupConfigs;
|
||||
}
|
||||
|
||||
importers.importVaultData(JSON.stringify(vaultImport));
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import { useFileUpload } from '../application/state/useFileUpload';
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
AISession,
|
||||
AISessionScope,
|
||||
ChatMessage,
|
||||
@@ -38,11 +39,29 @@ import AgentSelector from './ai/AgentSelector';
|
||||
import ChatInput from './ai/ChatInput';
|
||||
import ChatMessageList from './ai/ChatMessageList';
|
||||
import ConversationExport from './ai/ConversationExport';
|
||||
import { useAIChatStreaming, getNetcattyBridge } from './ai/hooks/useAIChatStreaming';
|
||||
import {
|
||||
useAIChatStreaming,
|
||||
getNetcattyBridge,
|
||||
type DefaultTargetSessionHint,
|
||||
} from './ai/hooks/useAIChatStreaming';
|
||||
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
|
||||
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
|
||||
// -------------------------------------------------------------------
|
||||
@@ -74,6 +93,7 @@ interface AIChatSidePanelProps {
|
||||
|
||||
// Agent info
|
||||
defaultAgentId: string;
|
||||
toolIntegrationMode: AIToolIntegrationMode;
|
||||
externalAgents: ExternalAgentConfig[];
|
||||
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
|
||||
agentModelMap: Record<string, string>;
|
||||
@@ -195,6 +215,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
activeProviderId,
|
||||
activeModelId,
|
||||
defaultAgentId,
|
||||
toolIntegrationMode,
|
||||
externalAgents,
|
||||
setExternalAgents,
|
||||
agentModelMap,
|
||||
@@ -226,6 +247,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [currentAgentId, setCurrentAgentId] = useState(defaultAgentId);
|
||||
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, ReturnType<typeof getAgentModelPresets>>>({});
|
||||
|
||||
const { files, addFiles, removeFile, clearFiles } = useFileUpload();
|
||||
const { openSettingsWindow } = useWindowControls();
|
||||
@@ -290,6 +312,29 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
return historySessions[0] ?? null;
|
||||
}, [sessions, activeSessionIdForScope, historySessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);
|
||||
|
||||
const defaultTargetSession = useMemo<DefaultTargetSessionHint | undefined>(() => {
|
||||
const connectedSessions = terminalSessions.filter((session) => session.connected !== false);
|
||||
|
||||
if (scopeType === 'terminal' && scopeTargetId) {
|
||||
const target = terminalSessions.find((session) => session.sessionId === scopeTargetId);
|
||||
if (target) {
|
||||
return {
|
||||
...target,
|
||||
source: 'scope-target',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (connectedSessions.length === 1) {
|
||||
return {
|
||||
...connectedSessions[0],
|
||||
source: 'only-connected-in-scope',
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [terminalSessions, scopeType, scopeTargetId]);
|
||||
|
||||
const activeSessionId = activeSession?.id ?? activeSessionIdForScope;
|
||||
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
|
||||
|
||||
@@ -431,9 +476,53 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
() => currentAgentId !== 'catty' ? externalAgents.find(a => a.id === currentAgentId) : undefined,
|
||||
[currentAgentId, externalAgents],
|
||||
);
|
||||
const isCopilotExternalAgent = useMemo(
|
||||
() => isCopilotAgentConfig(currentAgentConfig),
|
||||
[currentAgentConfig],
|
||||
);
|
||||
|
||||
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),
|
||||
[currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets],
|
||||
);
|
||||
|
||||
// Per-agent model: recall last selection or use first preset as default
|
||||
@@ -593,7 +682,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,
|
||||
});
|
||||
|
||||
@@ -613,8 +704,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
updateExternalSessionId: updateSessionExternalSessionId,
|
||||
historyMessages: buildAcpHistoryMessages(currentSession?.messages ?? []),
|
||||
terminalSessions,
|
||||
defaultTargetSession,
|
||||
providers,
|
||||
selectedAgentModel,
|
||||
toolIntegrationMode,
|
||||
});
|
||||
} catch (err) {
|
||||
reportStreamError(sessionId, abortController.signal, err);
|
||||
@@ -650,8 +743,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
ensureSession, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
setStreamingForScope, setInputValue, clearFiles,
|
||||
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
|
||||
abortControllersRef, terminalSessions, providers, selectedAgentModel, updateSessionExternalSessionId,
|
||||
abortControllersRef, terminalSessions, defaultTargetSession, providers, selectedAgentModel, updateSessionExternalSessionId,
|
||||
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope,
|
||||
toolIntegrationMode,
|
||||
]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
@@ -711,7 +805,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
<div className="flex flex-col h-full bg-background" data-section="ai-chat-panel">
|
||||
{/* ── Header ── */}
|
||||
<div className="px-2.5 py-1.5 flex items-center justify-between border-b border-border/50 shrink-0">
|
||||
<AgentSelector
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Github,
|
||||
Key,
|
||||
Loader2,
|
||||
History,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Server,
|
||||
@@ -285,6 +286,7 @@ interface ProviderCardProps {
|
||||
onCancelConnect?: () => void;
|
||||
onDisconnect: () => void;
|
||||
onSync: () => void;
|
||||
extraActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
@@ -303,6 +305,7 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
onCancelConnect,
|
||||
onDisconnect,
|
||||
onSync,
|
||||
extraActions,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const formatLastSync = (timestamp?: number): string => {
|
||||
@@ -395,6 +398,7 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
)}
|
||||
{t('cloudSync.provider.sync')}
|
||||
</Button>
|
||||
{extraActions}
|
||||
{onEdit && (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -715,6 +719,20 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// Conflict modal
|
||||
const [showConflictModal, setShowConflictModal] = useState(false);
|
||||
|
||||
// Gist revision history (#679)
|
||||
const [showHistoryModal, setShowHistoryModal] = useState(false);
|
||||
const [historyRevisions, setHistoryRevisions] = useState<Array<{ version: string; date: Date }>>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [historyPreview, setHistoryPreview] = useState<{
|
||||
sha: string;
|
||||
payload: SyncPayload;
|
||||
preview: { hostCount: number; keyCount: number; snippetCount: number; identityCount: number; portForwardingRuleCount: number };
|
||||
deviceName?: string;
|
||||
version?: number;
|
||||
} | null>(null);
|
||||
const [historyPreviewLoading, setHistoryPreviewLoading] = useState(false);
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
|
||||
// Change master key dialog
|
||||
const [showChangeKeyDialog, setShowChangeKeyDialog] = useState(false);
|
||||
const [currentMasterKey, setCurrentMasterKey] = useState('');
|
||||
@@ -1030,6 +1048,60 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// -- Gist revision history handlers --
|
||||
|
||||
const handleOpenHistory = async () => {
|
||||
setShowHistoryModal(true);
|
||||
setHistoryLoading(true);
|
||||
setHistoryError(null);
|
||||
setHistoryPreview(null);
|
||||
setHistoryRevisions([]);
|
||||
try {
|
||||
const revisions = await sync.getGistRevisionHistory();
|
||||
setHistoryRevisions(revisions);
|
||||
} catch (err) {
|
||||
setHistoryError(err instanceof Error ? err.message : t('common.unknownError'));
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewRevision = async (sha: string) => {
|
||||
setHistoryPreviewLoading(true);
|
||||
setHistoryError(null);
|
||||
try {
|
||||
const result = await sync.downloadGistRevision(sha);
|
||||
if (result) {
|
||||
setHistoryPreview({
|
||||
sha,
|
||||
payload: result.payload,
|
||||
preview: result.preview,
|
||||
deviceName: result.meta.deviceName,
|
||||
version: result.meta.version,
|
||||
});
|
||||
} else {
|
||||
setHistoryError(t('cloudSync.revisionHistory.revisionNotFound'));
|
||||
}
|
||||
} catch {
|
||||
// Decrypt failures can manifest as various error types:
|
||||
// "Decryption failed", OperationError, "unable to authenticate
|
||||
// data", AES-GCM tag mismatch, etc. Show the friendly message
|
||||
// for any error originating from the decrypt step; network
|
||||
// errors would have been caught by the fetch layer already.
|
||||
setHistoryError(t('cloudSync.revisionHistory.decryptFailed'));
|
||||
} finally {
|
||||
setHistoryPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreRevision = () => {
|
||||
if (!historyPreview) return;
|
||||
onApplyPayload(historyPreview.payload);
|
||||
toast.success(t('cloudSync.revisionHistory.restored'));
|
||||
setShowHistoryModal(false);
|
||||
setHistoryPreview(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with status */}
|
||||
@@ -1091,6 +1163,14 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
onConnect={handleConnectGitHub}
|
||||
onDisconnect={() => sync.disconnectProvider('github')}
|
||||
onSync={() => handleSync('github')}
|
||||
extraActions={
|
||||
isProviderReadyForSync(sync.providers.github) ? (
|
||||
<Button size="sm" variant="ghost" onClick={handleOpenHistory} className="gap-1">
|
||||
<History size={14} />
|
||||
{t('cloudSync.revisionHistory.viewButton')}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<ProviderCard
|
||||
@@ -1291,6 +1371,113 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
onClose={() => setShowConflictModal(false)}
|
||||
/>
|
||||
|
||||
{/* Gist Revision History Modal (#679) */}
|
||||
<Dialog open={showHistoryModal} onOpenChange={setShowHistoryModal}>
|
||||
<DialogContent className="sm:max-w-[520px] max-h-[80vh] overflow-hidden flex flex-col z-[70]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<History size={18} />
|
||||
{t('cloudSync.revisionHistory.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t('cloudSync.revisionHistory.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{historyError && (
|
||||
<div className="rounded-lg bg-red-500/10 border border-red-500/20 p-3 text-sm text-red-500">
|
||||
{historyError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{historyLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : historyPreview ? (
|
||||
// Preview of a selected revision
|
||||
<div className="space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<div className="text-sm font-medium">{t('cloudSync.revisionHistory.revisionPreview')}</div>
|
||||
{historyPreview.deviceName && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('cloudSync.revisionHistory.device')}: {historyPreview.deviceName}
|
||||
{historyPreview.version != null && ` · v${historyPreview.version}`}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
|
||||
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.hosts')}</span>
|
||||
<span className="font-medium">{historyPreview.preview.hostCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
|
||||
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.keys')}</span>
|
||||
<span className="font-medium">{historyPreview.preview.keyCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
|
||||
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.snippets')}</span>
|
||||
<span className="font-medium">{historyPreview.preview.snippetCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
|
||||
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.identities')}</span>
|
||||
<span className="font-medium">{historyPreview.preview.identityCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => setHistoryPreview(null)}>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Button onClick={handleRestoreRevision} className="gap-1">
|
||||
<Download size={14} />
|
||||
{t('cloudSync.revisionHistory.restoreButton')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
// Revision list
|
||||
<div className="overflow-y-auto flex-1 min-h-0 -mx-1">
|
||||
{historyRevisions.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground text-center py-8">
|
||||
{t('cloudSync.revisionHistory.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 px-1">
|
||||
{historyRevisions.map((rev, index) => (
|
||||
<button
|
||||
key={rev.version}
|
||||
onClick={() => handlePreviewRevision(rev.version)}
|
||||
disabled={historyPreviewLoading}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between p-2.5 rounded-lg text-left text-sm transition-colors",
|
||||
"hover:bg-accent border border-transparent hover:border-border",
|
||||
index === 0 && "bg-primary/5 border-primary/20",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{index === 0 ? t('cloudSync.revisionHistory.current') : `${t('cloudSync.revisionHistory.revision')} #${historyRevisions.length - index}`}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{rev.date.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{rev.version.slice(0, 7)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{historyPreviewLoading && (
|
||||
<div className="absolute inset-0 bg-background/50 flex items-center justify-center rounded-lg">
|
||||
<Loader2 size={24} className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showWebdavDialog} onOpenChange={setShowWebdavDialog}>
|
||||
<DialogContent className="sm:max-w-[460px] max-h-[80vh] overflow-y-auto z-[70]">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -22,6 +22,16 @@ export const DISTRO_LOGOS: Record<string, string> = {
|
||||
macos: "/distro/macos.svg",
|
||||
windows: "/distro/windows.svg",
|
||||
linux: "/distro/linux.svg",
|
||||
// Network device vendors — auto-detected from the SSH server
|
||||
// identification string (see domain/host.ts `detectVendorFromSshVersion`).
|
||||
cisco: "/distro/cisco.svg",
|
||||
juniper: "/distro/juniper.svg",
|
||||
huawei: "/distro/huawei.svg",
|
||||
hpe: "/distro/hpe.svg",
|
||||
mikrotik: "/distro/mikrotik.svg",
|
||||
fortinet: "/distro/fortinet.svg",
|
||||
paloalto: "/distro/paloalto.svg",
|
||||
zyxel: "/distro/zyxel.svg",
|
||||
};
|
||||
|
||||
export const DISTRO_COLORS: Record<string, string> = {
|
||||
@@ -42,6 +52,15 @@ export const DISTRO_COLORS: Record<string, string> = {
|
||||
macos: "bg-[#333333]",
|
||||
windows: "bg-[#0078D4]",
|
||||
linux: "bg-[#333333]",
|
||||
// Network device vendor brand colors
|
||||
cisco: "bg-[#1BA0D7]",
|
||||
juniper: "bg-[#0A6EB4]",
|
||||
huawei: "bg-[#CF0A2C]",
|
||||
hpe: "bg-[#01A982]",
|
||||
mikrotik: "bg-[#293239]",
|
||||
fortinet: "bg-[#EE3124]",
|
||||
paloalto: "bg-[#FA582D]",
|
||||
zyxel: "bg-[#00497A]",
|
||||
default: "bg-slate-600",
|
||||
};
|
||||
|
||||
@@ -65,8 +84,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 +117,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,
|
||||
)}
|
||||
|
||||
1167
components/GroupDetailsPanel.tsx
Normal file
1167
components/GroupDetailsPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,12 @@ import {
|
||||
import React, { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useApplicationBackend } from "../application/state/useApplicationBackend";
|
||||
import { getEffectiveHostDistro, LINUX_DISTRO_OPTIONS } from "../domain/host";
|
||||
import { resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/groupConfig";
|
||||
import {
|
||||
getEffectiveHostDistro,
|
||||
LINUX_DISTRO_OPTIONS,
|
||||
NETWORK_DEVICE_OPTIONS,
|
||||
} from "../domain/host";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import {
|
||||
clearHostFontSizeOverride,
|
||||
@@ -43,7 +48,7 @@ import {
|
||||
} from "../domain/terminalAppearance";
|
||||
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
|
||||
import { cn } from "../lib/utils";
|
||||
import { EnvVar, Host, Identity, ManagedSource, ProxyConfig, SSHKey } from "../types";
|
||||
import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, SSHKey } from "../types";
|
||||
import { DISTRO_COLORS, DISTRO_LOGOS } from "./DistroAvatar";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import ThemeSelectPanel from "./ThemeSelectPanel";
|
||||
@@ -51,6 +56,7 @@ import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
AsidePanelFooter,
|
||||
type AsidePanelLayout,
|
||||
} from "./ui/aside-panel";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
|
||||
@@ -82,7 +88,10 @@ type SubPanel =
|
||||
| "theme-select"
|
||||
| "telnet-theme-select";
|
||||
|
||||
const LINUX_DISTRO_OPTION_IDS = [...LINUX_DISTRO_OPTIONS];
|
||||
const LINUX_DISTRO_OPTION_IDS = [
|
||||
...LINUX_DISTRO_OPTIONS,
|
||||
...NETWORK_DEVICE_OPTIONS,
|
||||
];
|
||||
|
||||
interface HostDetailsPanelProps {
|
||||
initialData?: Host | null;
|
||||
@@ -99,6 +108,9 @@ interface HostDetailsPanelProps {
|
||||
onCancel: () => void;
|
||||
onCreateGroup?: (groupPath: string) => void; // Callback to create a new group
|
||||
onCreateTag?: (tag: string) => void; // Callback to create a new tag
|
||||
groupDefaults?: Partial<import('../domain/models').GroupConfig>;
|
||||
groupConfigs?: GroupConfig[];
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
@@ -116,6 +128,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onCancel,
|
||||
onCreateGroup,
|
||||
onCreateTag,
|
||||
groupDefaults,
|
||||
groupConfigs = [],
|
||||
layout = "overlay",
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
@@ -126,13 +141,13 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
id: crypto.randomUUID(),
|
||||
label: "",
|
||||
hostname: "",
|
||||
port: 22,
|
||||
username: "root",
|
||||
port: groupDefaults?.port ? undefined : 22,
|
||||
username: groupDefaults?.username ? "" : "root",
|
||||
protocol: "ssh",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
authMethod: "password",
|
||||
charset: "UTF-8",
|
||||
charset: groupDefaults?.charset ? undefined : "UTF-8",
|
||||
distroMode: "auto",
|
||||
createdAt: Date.now(),
|
||||
group: defaultGroup || undefined, // Pre-fill with current navigation group
|
||||
@@ -199,9 +214,17 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const effectiveGroupDefaults = useMemo(() => {
|
||||
const currentGroupPath = form.group || defaultGroup;
|
||||
if (currentGroupPath && groupConfigs.length > 0) {
|
||||
return resolveGroupDefaults(currentGroupPath, groupConfigs);
|
||||
}
|
||||
return groupDefaults;
|
||||
}, [defaultGroup, form.group, groupConfigs, groupDefaults]);
|
||||
|
||||
const effectiveThemeId = useMemo(
|
||||
() => resolveHostTerminalThemeId(form, terminalThemeId),
|
||||
[form, terminalThemeId],
|
||||
() => resolveHostTerminalThemeId(form, resolveGroupTerminalThemeId(effectiveGroupDefaults, terminalThemeId)),
|
||||
[effectiveGroupDefaults, form, terminalThemeId],
|
||||
);
|
||||
const effectiveFontSize = useMemo(
|
||||
() => resolveHostTerminalFontSize(form, terminalFontSize),
|
||||
@@ -282,12 +305,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
};
|
||||
|
||||
const removeHostFromChain = (index: number) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
hostChain: {
|
||||
hostIds: (prev.hostChain?.hostIds || []).filter((_, i) => i !== index),
|
||||
},
|
||||
}));
|
||||
setForm((prev) => {
|
||||
const ids = (prev.hostChain?.hostIds || []).filter((_, i) => i !== index);
|
||||
return { ...prev, hostChain: ids.length > 0 ? { hostIds: ids } : undefined };
|
||||
});
|
||||
};
|
||||
|
||||
const clearHostChain = useCallback(() => {
|
||||
@@ -313,12 +334,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
};
|
||||
|
||||
const removeEnvVar = (index: number) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
environmentVariables: (prev.environmentVariables || []).filter(
|
||||
(_, i) => i !== index,
|
||||
),
|
||||
}));
|
||||
setForm((prev) => {
|
||||
const filtered = (prev.environmentVariables || []).filter((_, i) => i !== index);
|
||||
return { ...prev, environmentVariables: filtered.length > 0 ? filtered : undefined };
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
@@ -363,7 +382,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
label: finalLabel,
|
||||
group: finalGroup,
|
||||
tags: form.tags || [],
|
||||
port: form.port || 22,
|
||||
port: form.port ?? (groupDefaults?.port ? undefined : 22),
|
||||
// Clear password if savePassword is explicitly set to false
|
||||
password: form.savePassword === false ? undefined : form.password,
|
||||
managedSourceId: finalManagedSourceId,
|
||||
@@ -504,6 +523,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onSave={handleCreateGroup}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -516,6 +536,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onClearProxy={clearProxyConfig}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -533,6 +554,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onClearChain={clearHostChain}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -561,6 +583,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
}}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -572,12 +595,17 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
open={true}
|
||||
selectedThemeId={effectiveThemeId}
|
||||
onSelect={(themeId) => {
|
||||
if (themeId === effectiveThemeId && !hasEffectiveThemeOverride) {
|
||||
setActiveSubPanel("none");
|
||||
return;
|
||||
}
|
||||
setForm((prev) => ({ ...prev, theme: themeId, themeOverride: true }));
|
||||
setActiveSubPanel("none");
|
||||
}}
|
||||
onClose={onCancel}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
showBackButton={true}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -616,6 +644,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onClose={onCancel}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
showBackButton={true}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -626,6 +655,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
open={true}
|
||||
onClose={onCancel}
|
||||
width="w-[420px]"
|
||||
layout={layout}
|
||||
dataSection="host-details-panel"
|
||||
title={
|
||||
initialData ? t("hostDetails.title.details") : t("hostDetails.title.new")
|
||||
}
|
||||
@@ -752,8 +783,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<div className="ml-auto w-1/2 min-w-0 flex items-center gap-2 justify-end">
|
||||
<Input
|
||||
type="number"
|
||||
value={form.port}
|
||||
onChange={(e) => update("port", Number(e.target.value))}
|
||||
value={form.port ?? ""}
|
||||
onChange={(e) => update("port", e.target.value ? Number(e.target.value) : undefined)}
|
||||
placeholder={groupDefaults?.port ? String(groupDefaults.port) : "22"}
|
||||
className="h-8 flex-1 min-w-0 text-center"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -805,7 +837,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
if (!hasIdentities) {
|
||||
return (
|
||||
<Input
|
||||
placeholder={t("hostDetails.username.placeholder")}
|
||||
placeholder={groupDefaults?.username || t("hostDetails.username.placeholder")}
|
||||
value={form.username}
|
||||
onChange={(e) => update("username", e.target.value)}
|
||||
className="h-10"
|
||||
@@ -824,7 +856,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<PopoverTrigger asChild>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t("hostDetails.username.placeholder")}
|
||||
placeholder={groupDefaults?.username || t("hostDetails.username.placeholder")}
|
||||
value={form.username}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
@@ -1263,18 +1295,20 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
{t("hostDetails.sftp.sudo.passwordWarning")}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">
|
||||
{t("hostDetails.sftp.encoding")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.sftp.encoding.desc")}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium">
|
||||
{t("hostDetails.sftp.encoding")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.sftp.encoding.desc")}
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
value={form.sftpEncoding || "auto"}
|
||||
onValueChange={(val) => update("sftpEncoding", val as Host["sftpEncoding"])}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectTrigger className="h-8 w-28">
|
||||
<SelectValue placeholder={t("sftp.encoding.label")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1286,6 +1320,111 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{form.os === "linux" && (
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/distro/linux.svg" alt="Linux" className="h-3.5 w-3.5 opacity-70 dark:invert" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.distro.title")}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.distro.desc")}</p>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.mode")}</span>
|
||||
<Select
|
||||
value={form.distroMode || "auto"}
|
||||
onValueChange={(val) => handleDistroModeChange(val as "auto" | "manual")}
|
||||
>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.mode")}>
|
||||
<span className="truncate whitespace-nowrap pr-2 text-left">
|
||||
{form.distroMode === "manual"
|
||||
? t("hostDetails.distro.mode.manual")
|
||||
: t("hostDetails.distro.mode.auto")}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("hostDetails.distro.mode.auto")}</SelectItem>
|
||||
<SelectItem value="manual">{t("hostDetails.distro.mode.manual")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{form.distroMode === "manual" ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.manualLabel")}</span>
|
||||
<Select
|
||||
value={form.manualDistro}
|
||||
onValueChange={(val) => update("manualDistro", val)}
|
||||
>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.manualLabel")}>
|
||||
{(() => {
|
||||
const selectedOption = distroOptions.find((option) => option.value === form.manualDistro);
|
||||
return selectedOption ? (
|
||||
<div className="flex min-w-0 items-center gap-2 pr-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
|
||||
selectedOption.bgClass,
|
||||
)}
|
||||
>
|
||||
{selectedOption.icon ? (
|
||||
<img
|
||||
src={selectedOption.icon}
|
||||
alt={selectedOption.label}
|
||||
className="h-3 w-3 object-contain invert brightness-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-white/70" />
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate whitespace-nowrap">{selectedOption.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder={t("hostDetails.distro.unknown")} />
|
||||
);
|
||||
})()}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-[14rem]">
|
||||
{distroOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
|
||||
option.bgClass,
|
||||
)}
|
||||
>
|
||||
{option.icon ? (
|
||||
<img
|
||||
src={option.icon}
|
||||
alt={option.label}
|
||||
className="h-3 w-3 object-contain invert brightness-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-white/70" />
|
||||
)}
|
||||
</div>
|
||||
<span className="whitespace-nowrap">{option.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.detectedLabel")}</span>
|
||||
<div className="flex h-8 items-center rounded-md border border-border/60 bg-background/50 px-3 text-sm">
|
||||
{effectiveFormDistro
|
||||
? getDistroOptionLabel(effectiveFormDistro)
|
||||
: t("hostDetails.distro.unknown")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette size={14} className="text-muted-foreground" />
|
||||
@@ -1294,113 +1433,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{form.os === "linux" && (
|
||||
<div className="space-y-2 rounded-lg border border-border/70 bg-secondary/30 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Globe size={14} className="mt-0.5 text-muted-foreground" />
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs font-semibold">{t("hostDetails.distro.title")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.distro.desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.mode")}</span>
|
||||
<Select
|
||||
value={form.distroMode || "auto"}
|
||||
onValueChange={(val) => handleDistroModeChange(val as "auto" | "manual")}
|
||||
>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.mode")}>
|
||||
<span className="truncate whitespace-nowrap pr-2 text-left">
|
||||
{form.distroMode === "manual"
|
||||
? t("hostDetails.distro.mode.manual")
|
||||
: t("hostDetails.distro.mode.auto")}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("hostDetails.distro.mode.auto")}</SelectItem>
|
||||
<SelectItem value="manual">{t("hostDetails.distro.mode.manual")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{form.distroMode === "manual" ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.manualLabel")}</span>
|
||||
<Select
|
||||
value={form.manualDistro}
|
||||
onValueChange={(val) => update("manualDistro", val)}
|
||||
>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.manualLabel")}>
|
||||
{(() => {
|
||||
const selectedOption = distroOptions.find((option) => option.value === form.manualDistro);
|
||||
return selectedOption ? (
|
||||
<div className="flex min-w-0 items-center gap-2 pr-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
|
||||
selectedOption.bgClass,
|
||||
)}
|
||||
>
|
||||
{selectedOption.icon ? (
|
||||
<img
|
||||
src={selectedOption.icon}
|
||||
alt={selectedOption.label}
|
||||
className="h-3 w-3 object-contain invert brightness-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-white/70" />
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate whitespace-nowrap">{selectedOption.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder={t("hostDetails.distro.unknown")} />
|
||||
);
|
||||
})()}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-[14rem]">
|
||||
{distroOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
|
||||
option.bgClass,
|
||||
)}
|
||||
>
|
||||
{option.icon ? (
|
||||
<img
|
||||
src={option.icon}
|
||||
alt={option.label}
|
||||
className="h-3 w-3 object-contain invert brightness-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-white/70" />
|
||||
)}
|
||||
</div>
|
||||
<span className="whitespace-nowrap">{option.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.detectedLabel")}</span>
|
||||
<div className="flex h-8 items-center rounded-md border border-border/60 bg-background/50 px-3 text-sm">
|
||||
{effectiveFormDistro
|
||||
? getDistroOptionLabel(effectiveFormDistro)
|
||||
: t("hostDetails.distro.unknown")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SSH Theme Selection */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1606,6 +1638,17 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.backspaceBehavior")}</p>
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
value={form.backspaceBehavior ?? ""}
|
||||
onChange={(e) => update("backspaceBehavior", e.target.value || undefined)}
|
||||
>
|
||||
<option value="">{t("hostDetails.backspaceBehavior.default")}</option>
|
||||
<option value="ctrl-h">^H (0x08)</option>
|
||||
</select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
|
||||
@@ -1755,7 +1798,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
className="text-muted-foreground hover:text-destructive flex-shrink-0 ml-auto"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setForm((prev) => ({ ...prev, environmentVariables: [] }));
|
||||
setForm((prev) => ({ ...prev, environmentVariables: undefined }));
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
@@ -1778,7 +1821,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<p className="text-xs font-semibold">{t("hostDetails.startupCommand")}</p>
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder={t("hostDetails.startupCommand.placeholder")}
|
||||
placeholder={groupDefaults?.startupCommand || t("hostDetails.startupCommand.placeholder")}
|
||||
value={form.startupCommand || ""}
|
||||
onChange={(e) => update("startupCommand", e.target.value)}
|
||||
className="min-h-[80px] font-mono text-sm"
|
||||
@@ -1842,7 +1885,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
|
||||
{/* Telnet Charset */}
|
||||
<Input
|
||||
placeholder={t("hostDetails.charset.placeholder")}
|
||||
placeholder={groupDefaults?.charset || t("hostDetails.charset.placeholder")}
|
||||
value={form.charset || "UTF-8"}
|
||||
onChange={(e) => update("charset", e.target.value)}
|
||||
className="h-10"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CheckSquare, ChevronRight, FileSymlink, Folder, FolderOpen, Monitor, Server, Square, Expand, Minimize2 } from 'lucide-react';
|
||||
import { CheckSquare, ChevronRight, Edit2, FileSymlink, Folder, FolderOpen, Monitor, Server, Square, Expand, Minimize2 } from 'lucide-react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
|
||||
@@ -32,9 +32,12 @@ interface HostTreeViewProps {
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
|
||||
isMultiSelectMode?: boolean;
|
||||
selectedHostIds?: Set<string>;
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
getDropTargetClasses?: (target: string) => string;
|
||||
setDragOverDropTarget?: (target: string | null) => void;
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
@@ -56,9 +59,12 @@ interface TreeNodeProps {
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
|
||||
isMultiSelectMode?: boolean;
|
||||
selectedHostIds?: Set<string>;
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
getDropTargetClasses?: (target: string) => string;
|
||||
setDragOverDropTarget?: (target: string | null) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -81,9 +87,12 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
toggleHostSelection,
|
||||
getDropTargetClasses,
|
||||
setDragOverDropTarget,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
@@ -137,6 +146,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
|
||||
getDropTargetClasses?.(node.path),
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
draggable
|
||||
@@ -144,10 +154,19 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverDropTarget?.(node.path);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
const nextTarget = e.relatedTarget;
|
||||
if (nextTarget instanceof Node && e.currentTarget.contains(nextTarget)) {
|
||||
return;
|
||||
}
|
||||
setDragOverDropTarget?.(null);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverDropTarget?.(null);
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, node.path);
|
||||
@@ -176,6 +195,15 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
{hostsCountInNode}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="h-7 w-7 flex items-center justify-center rounded-md hover:bg-secondary/80 transition-colors opacity-0 group-hover:opacity-100 shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditGroup(node.path);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
</ContextMenuTrigger>
|
||||
@@ -226,9 +254,12 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
getDropTargetClasses={getDropTargetClasses}
|
||||
setDragOverDropTarget={setDragOverDropTarget}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -244,6 +275,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
@@ -264,6 +296,7 @@ interface HostTreeItemProps {
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
|
||||
isMultiSelectMode?: boolean;
|
||||
selectedHostIds?: Set<string>;
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
@@ -278,6 +311,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
moveHostToGroup: _moveHostToGroup,
|
||||
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
toggleHostSelection,
|
||||
@@ -348,6 +382,15 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
{tags.length > 2 && '...'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="h-7 w-7 flex items-center justify-center rounded-md hover:bg-secondary/80 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditHost(host);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
@@ -364,7 +407,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
<ContextMenuItem onClick={() => onCopyCredentials(host)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.copyCredentials")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
<ContextMenuItem
|
||||
onClick={() => onDeleteHost(host)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
@@ -396,12 +439,15 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
toggleHostSelection,
|
||||
getDropTargetClasses,
|
||||
setDragOverDropTarget,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
|
||||
// Use external state if provided, otherwise use local persistent state
|
||||
const localTreeState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
|
||||
|
||||
@@ -522,6 +568,8 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
getDropTargetClasses={getDropTargetClasses}
|
||||
setDragOverDropTarget={setDragOverDropTarget}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -552,4 +600,4 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* This modal displays prompts from the SSH server and collects user responses.
|
||||
*/
|
||||
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
@@ -24,6 +24,7 @@ export interface KeyboardInteractivePrompt {
|
||||
|
||||
export interface KeyboardInteractiveRequest {
|
||||
requestId: string;
|
||||
sessionId?: string;
|
||||
name: string;
|
||||
instructions: string;
|
||||
prompts: KeyboardInteractivePrompt[];
|
||||
@@ -31,9 +32,18 @@ export interface KeyboardInteractiveRequest {
|
||||
savedPassword?: string | null;
|
||||
}
|
||||
|
||||
const isAPasswordPrompt = (prompt: KeyboardInteractivePrompt) => {
|
||||
if (prompt.echo) return false;
|
||||
const lower = prompt.prompt.toLowerCase();
|
||||
if (!lower.includes("password")) return false;
|
||||
// Exclude OTP / one-time password / verification code prompts
|
||||
if (lower.includes("one-time") || lower.includes("otp") || lower.includes("verification") || lower.includes("token") || lower.includes("code")) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
interface KeyboardInteractiveModalProps {
|
||||
request: KeyboardInteractiveRequest | null;
|
||||
onSubmit: (requestId: string, responses: string[]) => void;
|
||||
onSubmit: (requestId: string, responses: string[], savePassword?: string) => void;
|
||||
onCancel: (requestId: string) => void;
|
||||
}
|
||||
|
||||
@@ -46,15 +56,28 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
const [responses, setResponses] = useState<string[]>([]);
|
||||
const [showPasswords, setShowPasswords] = useState<boolean[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [savePassword, setSavePassword] = useState(false);
|
||||
|
||||
// Index of the first password prompt (if any)
|
||||
const passwordPromptIndex = useMemo(() => {
|
||||
if (!request) return -1;
|
||||
return request.prompts.findIndex(p => isAPasswordPrompt(p));
|
||||
}, [request]);
|
||||
|
||||
// Reset state when request changes
|
||||
useEffect(() => {
|
||||
if (request) {
|
||||
setResponses(request.prompts.map(() => ""));
|
||||
const initial = request.prompts.map(() => "");
|
||||
// Auto-fill saved password into the password prompt
|
||||
if (request.savedPassword && passwordPromptIndex >= 0) {
|
||||
initial[passwordPromptIndex] = request.savedPassword;
|
||||
}
|
||||
setResponses(initial);
|
||||
setShowPasswords(request.prompts.map(() => false));
|
||||
setIsSubmitting(false);
|
||||
setSavePassword(false);
|
||||
}
|
||||
}, [request]);
|
||||
}, [request, passwordPromptIndex]);
|
||||
|
||||
const handleResponseChange = useCallback((index: number, value: string) => {
|
||||
setResponses((prev) => {
|
||||
@@ -75,8 +98,11 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!request || isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
onSubmit(request.requestId, responses);
|
||||
}, [request, responses, onSubmit, isSubmitting]);
|
||||
const passwordToSave = savePassword && passwordPromptIndex >= 0
|
||||
? responses[passwordPromptIndex]
|
||||
: undefined;
|
||||
onSubmit(request.requestId, responses, passwordToSave);
|
||||
}, [request, responses, onSubmit, isSubmitting, savePassword, passwordPromptIndex]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (!request) return;
|
||||
@@ -154,19 +180,20 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Use saved password button - shown below input, right-aligned */}
|
||||
{isPassword && request.savedPassword && !responses[index] && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 disabled:opacity-50"
|
||||
onClick={() => handleResponseChange(index, request.savedPassword!)}
|
||||
{/* Save password checkbox - shown only for the first password prompt */}
|
||||
{index === passwordPromptIndex && (
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={savePassword}
|
||||
onChange={(e) => setSavePassword(e.target.checked)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<KeyRound size={12} />
|
||||
<span>{t("keyboard.interactive.useSavedPassword")}</span>
|
||||
</button>
|
||||
</div>
|
||||
className="accent-primary"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("keyboard.interactive.savePassword")}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -515,12 +515,12 @@ echo $3 >> "$FILE"`);
|
||||
{/* Main Content */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 overflow-y-auto transition-all duration-200",
|
||||
"flex-1 flex flex-col min-h-0 transition-all duration-200",
|
||||
panel.type !== "closed" && "mr-[380px]",
|
||||
)}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3 bg-secondary/60 border-b border-border/70 px-3 py-1.5">
|
||||
<div className="flex flex-wrap items-center gap-3 bg-secondary/60 border-b border-border/70 px-3 py-1.5 shrink-0">
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* KEY button with split interaction: left=switch view, right=dropdown */}
|
||||
@@ -684,8 +684,10 @@ echo $3 >> "$FILE"`);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keys Section */}
|
||||
<div className="space-y-3 p-3">
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Keys Section */}
|
||||
<div className="space-y-3 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-muted-foreground">
|
||||
{t("keychain.section.keys")}
|
||||
@@ -817,6 +819,7 @@ echo $3 >> "$FILE"`);
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slide-out Panel */}
|
||||
|
||||
@@ -36,19 +36,35 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [previewTheme, setPreviewTheme] = useState<TerminalTheme | null>(null);
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
const explicitThemeId = useMemo(() => {
|
||||
if (!log.themeId) return undefined;
|
||||
const exists = TERMINAL_THEMES.some((theme) => theme.id === log.themeId)
|
||||
|| customThemes.some((theme) => theme.id === log.themeId);
|
||||
return exists ? log.themeId : undefined;
|
||||
}, [customThemes, log.themeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (log.themeId && !explicitThemeId) {
|
||||
onUpdateLog(log.id, { themeId: undefined });
|
||||
}
|
||||
}, [explicitThemeId, log.id, log.themeId, onUpdateLog]);
|
||||
|
||||
// Use log's saved theme/fontSize or fall back to defaults
|
||||
const currentTheme = useMemo(() => {
|
||||
if (log.themeId) {
|
||||
return TERMINAL_THEMES.find(t => t.id === log.themeId)
|
||||
|| customThemes.find(t => t.id === log.themeId)
|
||||
if (previewTheme) {
|
||||
return previewTheme;
|
||||
}
|
||||
if (explicitThemeId) {
|
||||
return TERMINAL_THEMES.find(t => t.id === explicitThemeId)
|
||||
|| customThemes.find(t => t.id === explicitThemeId)
|
||||
|| defaultTerminalTheme;
|
||||
}
|
||||
return defaultTerminalTheme;
|
||||
}, [log.themeId, defaultTerminalTheme, customThemes]);
|
||||
}, [customThemes, defaultTerminalTheme, explicitThemeId, previewTheme]);
|
||||
|
||||
const currentFontSize = log.fontSize ?? defaultFontSize;
|
||||
|
||||
@@ -69,6 +85,12 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
onUpdateLog(log.id, { themeId });
|
||||
}, [log.id, onUpdateLog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!themeModalOpen) {
|
||||
setPreviewTheme(null);
|
||||
}
|
||||
}, [themeModalOpen]);
|
||||
|
||||
// Handle font size change
|
||||
const handleFontSizeChange = useCallback((fontSize: number) => {
|
||||
onUpdateLog(log.id, { fontSize });
|
||||
@@ -295,10 +317,13 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
<ThemeCustomizeModal
|
||||
open={themeModalOpen}
|
||||
onClose={() => setThemeModalOpen(false)}
|
||||
currentThemeId={currentTheme.id}
|
||||
currentThemeId={explicitThemeId}
|
||||
displayThemeId={currentTheme.id}
|
||||
currentFontSize={currentFontSize}
|
||||
onThemeChange={handleThemeChange}
|
||||
onThemeReset={() => onUpdateLog(log.id, { themeId: undefined })}
|
||||
onFontSizeChange={handleFontSizeChange}
|
||||
onPreviewThemeChange={setPreviewTheme}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,12 +14,14 @@ import React, { useCallback, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import {
|
||||
GroupConfig,
|
||||
Host,
|
||||
ManagedSource,
|
||||
PortForwardingRule,
|
||||
PortForwardingType,
|
||||
SSHKey,
|
||||
} from "../domain/models";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { cn } from "../lib/utils";
|
||||
import SelectHostPanel from "./SelectHostPanel";
|
||||
import {
|
||||
@@ -66,6 +68,7 @@ interface PortForwardingProps {
|
||||
identities?: import('../domain/models').Identity[];
|
||||
customGroups: string[];
|
||||
managedSources?: ManagedSource[];
|
||||
groupConfigs?: GroupConfig[];
|
||||
onNewHost?: () => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
onCreateGroup?: (groupPath: string) => void;
|
||||
@@ -77,6 +80,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
identities = [],
|
||||
customGroups: _customGroups,
|
||||
managedSources = [],
|
||||
groupConfigs = [],
|
||||
onNewHost: _onNewHost,
|
||||
onSaveHost,
|
||||
onCreateGroup: _onCreateGroup,
|
||||
@@ -113,8 +117,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
// Start a port forwarding tunnel
|
||||
const handleStartTunnel = useCallback(
|
||||
async (rule: PortForwardingRule) => {
|
||||
const _host = hosts.find((h) => h.id === rule.hostId);
|
||||
if (!_host) {
|
||||
const _rawHost = hosts.find((h) => h.id === rule.hostId);
|
||||
if (!_rawHost) {
|
||||
setRuleStatus(rule.id, "error", t("pf.error.hostNotFound"));
|
||||
toast.error(
|
||||
t("pf.error.hostNotFound"),
|
||||
@@ -123,6 +127,10 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const _host = _rawHost.group
|
||||
? applyGroupDefaults(_rawHost, resolveGroupDefaults(_rawHost.group, groupConfigs))
|
||||
: _rawHost;
|
||||
|
||||
setPendingOperations((prev) => new Set([...prev, rule.id]));
|
||||
let errorShown = false;
|
||||
|
||||
@@ -161,7 +169,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
});
|
||||
}
|
||||
},
|
||||
[hosts, identities, keys, setRuleStatus, startTunnel, t],
|
||||
[hosts, identities, keys, groupConfigs, setRuleStatus, startTunnel, t],
|
||||
);
|
||||
|
||||
// Stop a port forwarding tunnel
|
||||
|
||||
185
components/QuickAddSnippetDialog.tsx
Normal file
185
components/QuickAddSnippetDialog.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* QuickAddSnippetDialog — lightweight "new snippet" modal mounted at the
|
||||
* App root and triggered by the `netcatty:snippets:add` window event.
|
||||
*
|
||||
* Intentionally minimal: label + command + package only. Advanced fields
|
||||
* (target hosts, shortkey, tags) can be set later via the full Snippets
|
||||
* manager. This keeps the user in their terminal context instead of
|
||||
* navigating to the Vault view just to add a command.
|
||||
*/
|
||||
|
||||
import { Package } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import type { Snippet } from '../domain/models';
|
||||
import { Button } from './ui/button';
|
||||
import { Combobox } from './ui/combobox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Textarea } from './ui/textarea';
|
||||
|
||||
export interface QuickAddSnippetDialogProps {
|
||||
snippets: Snippet[];
|
||||
packages: string[];
|
||||
onCreateSnippet: (snippet: Snippet) => void;
|
||||
onCreatePackage?: (packagePath: string) => void;
|
||||
}
|
||||
|
||||
export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
snippets,
|
||||
packages,
|
||||
onCreateSnippet,
|
||||
onCreatePackage,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [label, setLabel] = useState('');
|
||||
const [command, setCommand] = useState('');
|
||||
const [packagePath, setPackagePath] = useState('');
|
||||
const labelInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Listen for the global "add snippet" request dispatched by the
|
||||
// terminal-side ScriptsSidePanel + button. We reset form state on
|
||||
// every open so stale input from a previous cancel does not leak.
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setLabel('');
|
||||
setCommand('');
|
||||
setPackagePath('');
|
||||
setOpen(true);
|
||||
};
|
||||
window.addEventListener('netcatty:snippets:add', handler);
|
||||
return () => window.removeEventListener('netcatty:snippets:add', handler);
|
||||
}, []);
|
||||
|
||||
// Auto-focus the label input once the dialog renders, so the user can
|
||||
// start typing immediately after clicking the + button.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const id = window.setTimeout(() => labelInputRef.current?.focus(), 50);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [open]);
|
||||
|
||||
// Derive combobox options from the union of existing packages (from
|
||||
// props) and any package path referenced by an existing snippet, so
|
||||
// the user can reuse anything they see in the main snippets view.
|
||||
const packageOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const p of packages) {
|
||||
if (p) set.add(p);
|
||||
}
|
||||
for (const s of snippets) {
|
||||
if (s.package) set.add(s.package);
|
||||
}
|
||||
return Array.from(set).sort().map((value) => ({ value, label: value }));
|
||||
}, [packages, snippets]);
|
||||
|
||||
const canSave = label.trim().length > 0 && command.trim().length > 0;
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!canSave) return;
|
||||
const trimmedPackage = packagePath.trim();
|
||||
// If the user typed a brand new package name, surface it to the parent
|
||||
// so it can be added to the user's package list alongside the snippet.
|
||||
if (trimmedPackage && !packages.includes(trimmedPackage)) {
|
||||
onCreatePackage?.(trimmedPackage);
|
||||
}
|
||||
onCreateSnippet({
|
||||
id: crypto.randomUUID(),
|
||||
label: label.trim(),
|
||||
command, // preserve whitespace in multi-line commands
|
||||
tags: [],
|
||||
package: trimmedPackage || '',
|
||||
targets: [],
|
||||
});
|
||||
setOpen(false);
|
||||
}, [canSave, packagePath, packages, onCreatePackage, onCreateSnippet, label, command]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
// Cmd/Ctrl+Enter from anywhere in the dialog saves the snippet.
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && canSave) {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
},
|
||||
[canSave, handleSave],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-md" onKeyDown={handleKeyDown}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('snippets.panel.newTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('snippets.empty.desc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="quick-add-snippet-label" className="text-xs">
|
||||
{t('snippets.field.description')}
|
||||
</Label>
|
||||
<Input
|
||||
id="quick-add-snippet-label"
|
||||
ref={labelInputRef}
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t('snippets.field.descriptionPlaceholder')}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="quick-add-snippet-command" className="text-xs">
|
||||
{t('snippets.field.scriptRequired')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="quick-add-snippet-command"
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
placeholder="echo hello"
|
||||
className="min-h-[120px] font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs flex items-center gap-1.5">
|
||||
<Package size={12} /> {t('snippets.field.package')}
|
||||
</Label>
|
||||
<Combobox
|
||||
value={packagePath}
|
||||
onValueChange={setPackagePath}
|
||||
options={packageOptions}
|
||||
placeholder={t('snippets.field.packagePlaceholder')}
|
||||
allowCreate
|
||||
onCreateNew={setPackagePath}
|
||||
createText={t('snippets.field.createPackage')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!canSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickAddSnippetDialog;
|
||||
@@ -10,9 +10,10 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Host, TerminalSession, Workspace } from "../types";
|
||||
import { KeyBinding } from "../domain/models";
|
||||
import { useDiscoveredShells, getShellIconPath, isMonochromeShellIcon } from "../lib/useDiscoveredShells";
|
||||
|
||||
type QuickSwitcherItem = {
|
||||
type: "host" | "tab" | "workspace" | "action";
|
||||
type: "host" | "tab" | "workspace" | "action" | "shell";
|
||||
id: string;
|
||||
data?: Host | TerminalSession | Workspace;
|
||||
};
|
||||
@@ -66,7 +67,7 @@ interface QuickSwitcherProps {
|
||||
onSelect: (host: Host) => void;
|
||||
onSelectTab: (tabId: string) => void;
|
||||
onClose: () => void;
|
||||
onCreateLocalTerminal?: () => void;
|
||||
onCreateLocalTerminal?: (shell?: { command: string; args?: string[]; name?: string; icon?: string }) => void;
|
||||
// onCreateWorkspace removed - feature not currently used
|
||||
keyBindings?: KeyBinding[];
|
||||
}
|
||||
@@ -85,6 +86,18 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
keyBindings,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
|
||||
const filteredShells = useMemo(() => {
|
||||
const list = !query.trim()
|
||||
? discoveredShells
|
||||
: discoveredShells.filter(
|
||||
(s) => s.name.toLowerCase().includes(query.toLowerCase()) || s.id.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
// Default shell first
|
||||
return [...list].sort((a, b) => (a.isDefault === b.isDefault ? 0 : a.isDefault ? -1 : 1));
|
||||
}, [discoveredShells, query]);
|
||||
|
||||
// Get hotkey display strings
|
||||
const getHotkeyLabel = useCallback((actionId: string) => {
|
||||
const binding = keyBindings?.find(k => k.id === actionId);
|
||||
@@ -155,13 +168,23 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
workspaces.forEach((w) =>
|
||||
items.push({ type: "workspace", id: w.id, data: w }),
|
||||
);
|
||||
// Quick connect actions
|
||||
items.push({ type: "action", id: "local-terminal" });
|
||||
// Local shells (or fallback action if discovery not ready)
|
||||
if (filteredShells.length > 0) {
|
||||
filteredShells.forEach((shell) =>
|
||||
items.push({ type: "shell", id: shell.id }),
|
||||
);
|
||||
} else {
|
||||
items.push({ type: "action", id: "local-terminal" });
|
||||
}
|
||||
} else {
|
||||
// Recent connections only
|
||||
results.forEach((host) =>
|
||||
items.push({ type: "host", id: host.id, data: host }),
|
||||
);
|
||||
// Also include matching shells in search results
|
||||
filteredShells.forEach((shell) =>
|
||||
items.push({ type: "shell", id: shell.id }),
|
||||
);
|
||||
}
|
||||
|
||||
// Build index map for O(1) lookup
|
||||
@@ -171,7 +194,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
});
|
||||
|
||||
return { flatItems: items, itemIndexMap: indexMap };
|
||||
}, [showCategorized, results, orphanSessions, workspaces]);
|
||||
}, [showCategorized, results, orphanSessions, workspaces, filteredShells]);
|
||||
|
||||
// O(1) index lookup
|
||||
const getItemIndex = useCallback((type: string, id: string) => {
|
||||
@@ -210,6 +233,14 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
onClose();
|
||||
}
|
||||
break;
|
||||
case "shell": {
|
||||
const shell = discoveredShells.find(s => s.id === item.id);
|
||||
if (shell && onCreateLocalTerminal) {
|
||||
onCreateLocalTerminal({ command: shell.command, args: shell.args, name: shell.name, icon: shell.icon });
|
||||
onClose();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -369,21 +400,60 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Quick connect section */}
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Quick connect
|
||||
</span>
|
||||
{/* Local Shells section */}
|
||||
{/* Local Shells or fallback Local Terminal */}
|
||||
{filteredShells.length > 0 ? (
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("qs.localShells")}
|
||||
</span>
|
||||
</div>
|
||||
{filteredShells.map((shell) => {
|
||||
const idx = getItemIndex("shell", shell.id);
|
||||
const isSelected = idx === selectedIndex;
|
||||
return (
|
||||
<div
|
||||
key={shell.id}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${
|
||||
isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (onCreateLocalTerminal) {
|
||||
onCreateLocalTerminal({ command: shell.command, args: shell.args, name: shell.name, icon: shell.icon });
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<img
|
||||
src={getShellIconPath(shell.icon)}
|
||||
alt={shell.name}
|
||||
className={`h-6 w-6 shrink-0${isMonochromeShellIcon(shell.icon) ? " dark:invert" : ""}`}
|
||||
/>
|
||||
<span className="text-sm font-medium">{shell.name}</span>
|
||||
{shell.isDefault && (
|
||||
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{t("qs.default")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Local Terminal */}
|
||||
{onCreateLocalTerminal && (
|
||||
) : onCreateLocalTerminal && (
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("qs.localShells")}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${getItemIndex("action", "local-terminal") === selectedIndex
|
||||
? "bg-primary/15"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${
|
||||
getItemIndex("action", "local-terminal") === selectedIndex
|
||||
? "bg-primary/15"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onCreateLocalTerminal();
|
||||
onClose();
|
||||
@@ -397,10 +467,8 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
</div>
|
||||
<span className="text-sm font-medium">{t("qs.localTerminal")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Serial removed (not supported) */}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Clicking a snippet executes it in the focused terminal session.
|
||||
*/
|
||||
|
||||
import { ChevronRight, Package, Search, Zap } from 'lucide-react';
|
||||
import { ChevronRight, Package, Plus, Search, Zap } from 'lucide-react';
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { cn } from '../lib/utils';
|
||||
@@ -119,15 +119,25 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
onSnippetClick(command, noAutoRun);
|
||||
}, [onSnippetClick]);
|
||||
|
||||
const handleAddSnippet = useCallback(() => {
|
||||
// Let the App shell listen and navigate to the Snippets section with
|
||||
// the "add" panel pre-opened, so the user does not have to leave the
|
||||
// terminal to jump back and click "New Snippet".
|
||||
window.dispatchEvent(new CustomEvent('netcatty:snippets:add'));
|
||||
}, []);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const hasAnyContent = snippets.length > 0 || packages.length > 0;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background overflow-hidden">
|
||||
{/* Search */}
|
||||
<div className="shrink-0 px-2 py-1.5 border-b border-border/50">
|
||||
<div className="relative">
|
||||
<div
|
||||
className="h-full flex flex-col bg-background overflow-hidden"
|
||||
data-section="snippets-panel"
|
||||
>
|
||||
{/* Search + Add */}
|
||||
<div className="shrink-0 px-2 py-1.5 border-b border-border/50 flex items-center gap-1.5">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
@@ -136,6 +146,15 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
className="h-7 pl-7 text-xs bg-muted/30 border-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddSnippet}
|
||||
title={t('snippets.action.newSnippet')}
|
||||
aria-label={t('snippets.action.newSnippet')}
|
||||
className="shrink-0 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
ChevronRight,
|
||||
LayoutGrid,
|
||||
Plus,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -14,11 +12,18 @@ import { Host, SSHKey } from "../types";
|
||||
import { ManagedSource } from "../domain/models";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import HostDetailsPanel from "./HostDetailsPanel";
|
||||
import { AsidePanel, type AsidePanelLayout } from "./ui/aside-panel";
|
||||
import { Button } from "./ui/button";
|
||||
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[];
|
||||
@@ -38,6 +43,7 @@ interface SelectHostPanelProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
className?: string;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
@@ -57,6 +63,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
className,
|
||||
layout = "overlay",
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const panelTitle = title ?? t("selectHost.title");
|
||||
@@ -198,35 +205,21 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
}, [currentPath]);
|
||||
|
||||
return (
|
||||
<div
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={onBack}
|
||||
title={panelTitle}
|
||||
subtitle={subtitle}
|
||||
showBackButton={true}
|
||||
onBack={onBack}
|
||||
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",
|
||||
layout === "overlay" && "z-40",
|
||||
showNewHostPanel && "overflow-visible",
|
||||
className,
|
||||
)}
|
||||
layout={layout}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-border/60 flex items-center justify-between gap-3 shrink-0">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-1 hover:bg-muted rounded-md transition-colors cursor-pointer shrink-0"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold">{panelTitle}</h3>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-muted-foreground">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-1.5 hover:bg-muted rounded-md transition-colors cursor-pointer shrink-0"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="px-4 py-3 flex items-center gap-2 border-b border-border/60 shrink-0">
|
||||
@@ -270,8 +263,8 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-4">
|
||||
<ScrollArea className="flex-1 min-w-0">
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Breadcrumbs */}
|
||||
{currentPath && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
@@ -301,20 +294,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 +320,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 +340,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>
|
||||
);
|
||||
@@ -374,7 +384,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-3 border-t border-border/60">
|
||||
<div className="px-4 py-3 border-t border-border/60 shrink-0">
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={selectedHostIds.length === 0}
|
||||
@@ -412,7 +422,8 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
onCreateGroup={onCreateGroup}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AsidePanel>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
AsidePanelFooter,
|
||||
type AsidePanelLayout,
|
||||
} from './ui/aside-panel';
|
||||
|
||||
interface SerialPort {
|
||||
@@ -35,6 +36,7 @@ interface SerialHostDetailsPanelProps {
|
||||
groups?: string[];
|
||||
onSave: (host: Host) => void;
|
||||
onCancel: () => void;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
const BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600];
|
||||
@@ -49,6 +51,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
|
||||
groups = [],
|
||||
onSave,
|
||||
onCancel,
|
||||
layout = 'overlay',
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const terminalBackend = useTerminalBackend();
|
||||
@@ -164,6 +167,8 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
|
||||
title={t('serial.edit.title')}
|
||||
subtitle={initialData.label}
|
||||
className="z-40"
|
||||
layout={layout}
|
||||
dataSection="serial-host-details-panel"
|
||||
>
|
||||
<AsidePanelContent>
|
||||
{/* Label */}
|
||||
|
||||
@@ -96,6 +96,18 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
||||
};
|
||||
}, [getApplicationInfo]);
|
||||
|
||||
const handleOpenExternal = async (url: string) => {
|
||||
try {
|
||||
await openExternal(url);
|
||||
} catch (err) {
|
||||
console.warn("[SettingsApplicationTab] openExternal failed:", err);
|
||||
toast.error(
|
||||
t("settings.application.openExternal.failedBody"),
|
||||
t("settings.application.openExternal.failedTitle"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckForUpdates = async () => {
|
||||
// In demo mode, allow checking even for dev builds
|
||||
if (!isUpdateDemoMode && (!appInfo.version || appInfo.version === '0.0.0')) {
|
||||
@@ -198,25 +210,25 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
||||
icon={<Bug size={18} />}
|
||||
title={t("settings.application.reportProblem")}
|
||||
subtitle={t("settings.application.reportProblem.subtitle")}
|
||||
onClick={() => void openExternal(issueUrl)}
|
||||
onClick={() => void handleOpenExternal(issueUrl)}
|
||||
/>
|
||||
<ActionRow
|
||||
icon={<MessageCircle size={18} />}
|
||||
title={t("settings.application.community")}
|
||||
subtitle={t("settings.application.community.subtitle")}
|
||||
onClick={() => void openExternal(discussionsUrl)}
|
||||
onClick={() => void handleOpenExternal(discussionsUrl)}
|
||||
/>
|
||||
<ActionRow
|
||||
icon={<Github size={18} />}
|
||||
title="GitHub"
|
||||
subtitle={t("settings.application.github.subtitle")}
|
||||
onClick={() => void openExternal(REPO_URL)}
|
||||
onClick={() => void handleOpenExternal(REPO_URL)}
|
||||
/>
|
||||
<ActionRow
|
||||
icon={<Newspaper size={18} />}
|
||||
title={t("settings.application.whatsNew")}
|
||||
subtitle={t("settings.application.whatsNew.subtitle")}
|
||||
onClick={() => void openExternal(releasesUrl)}
|
||||
onClick={() => void handleOpenExternal(releasesUrl)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,6 +56,8 @@ const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ s
|
||||
<SettingsTerminalTab
|
||||
terminalThemeId={settings.terminalThemeId}
|
||||
setTerminalThemeId={settings.setTerminalThemeId}
|
||||
followAppTerminalTheme={settings.followAppTerminalTheme}
|
||||
setFollowAppTerminalTheme={settings.setFollowAppTerminalTheme}
|
||||
terminalFontFamilyId={settings.terminalFontFamilyId}
|
||||
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
|
||||
terminalFontSize={settings.terminalFontSize}
|
||||
@@ -86,6 +88,8 @@ const SettingsAITabContainer: React.FC = () => {
|
||||
setActiveModelId={aiState.setActiveModelId}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
toolIntegrationMode={aiState.toolIntegrationMode}
|
||||
setToolIntegrationMode={aiState.setToolIntegrationMode}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
@@ -113,6 +117,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
knownHosts,
|
||||
groupConfigs,
|
||||
importDataFromString,
|
||||
clearVaultData,
|
||||
} = useVaultState();
|
||||
@@ -132,8 +137,8 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
|
||||
);
|
||||
|
||||
const vault = useMemo(
|
||||
() => ({ hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts }),
|
||||
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts],
|
||||
() => ({ hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts, groupConfigs }),
|
||||
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -154,10 +159,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
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();
|
||||
@@ -285,8 +286,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setUiLanguage={settings.setUiLanguage}
|
||||
customCSS={settings.customCSS}
|
||||
setCustomCSS={settings.setCustomCSS}
|
||||
isImmersive={isImmersive}
|
||||
onToggleImmersive={toggleImmersive}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ 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 {
|
||||
@@ -130,12 +131,14 @@ 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,
|
||||
});
|
||||
|
||||
@@ -149,10 +152,19 @@ 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.
|
||||
@@ -161,19 +173,30 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
setHasPaneFocus(false);
|
||||
syncFocusedSelection(null);
|
||||
}
|
||||
}, [isVisible]);
|
||||
}, [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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -181,7 +204,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
};
|
||||
}, [isVisible]);
|
||||
}, [isVisible, syncFocusedSelection]);
|
||||
|
||||
const {
|
||||
leftCallbacks,
|
||||
@@ -599,10 +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}
|
||||
/>
|
||||
@@ -646,6 +671,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
handleSaveTextFile={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
showFileOpenerDialog={showFileOpenerDialog}
|
||||
setShowFileOpenerDialog={setShowFileOpenerDialog}
|
||||
fileOpenerTarget={fileOpenerTarget}
|
||||
@@ -675,6 +702,8 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
|
||||
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 &&
|
||||
prev.onGetTerminalCwd === next.onGetTerminalCwd &&
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useRenderTracker } from "../lib/useRenderTracker";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
|
||||
import { Host, Identity, SSHKey } from "../types";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
import { toast } from "./ui/toast";
|
||||
|
||||
@@ -40,6 +41,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,6 +52,7 @@ interface SftpViewProps {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
groupConfigs?: import('../domain/models').GroupConfig[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
sftpDefaultViewMode: "list" | "tree";
|
||||
sftpDoubleClickBehavior: "open" | "transfer";
|
||||
@@ -65,6 +69,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
groupConfigs = [],
|
||||
updateHosts,
|
||||
sftpDefaultViewMode,
|
||||
sftpDoubleClickBehavior,
|
||||
@@ -79,6 +84,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
const { t } = useI18n();
|
||||
const isActive = useIsSftpActive();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const dialogActionScopeIdRef = useRef("sftp-main-view");
|
||||
|
||||
useInstantThemeSwitch(rootRef);
|
||||
|
||||
@@ -101,7 +107,17 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
defaultShowHiddenFiles: sftpShowHiddenFiles,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
|
||||
// Pre-resolve group defaults so SFTP connections inherit group config
|
||||
const effectiveHosts = useMemo(() =>
|
||||
hosts.map(h => {
|
||||
if (!h.group) return h;
|
||||
const defaults = resolveGroupDefaults(h.group, groupConfigs);
|
||||
return applyGroupDefaults(h, defaults);
|
||||
}),
|
||||
[hosts, groupConfigs],
|
||||
);
|
||||
|
||||
const sftp = useSftpState(effectiveHosts, keys, identities, sftpOptions);
|
||||
|
||||
// Get backend helpers for file downloads and local filesystem writes.
|
||||
const {
|
||||
@@ -132,6 +148,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
sftpRef,
|
||||
dialogActionScopeId: dialogActionScopeIdRef.current,
|
||||
isActive,
|
||||
});
|
||||
|
||||
@@ -139,8 +156,18 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
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) => {
|
||||
@@ -255,6 +282,26 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
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}
|
||||
@@ -295,9 +342,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
<SftpTabBar
|
||||
tabs={leftTabsInfo}
|
||||
side="left"
|
||||
onSelectTab={handleSelectTabLeft}
|
||||
onSelectTab={handleSelectTabLeftWithFocus}
|
||||
onCloseTab={handleCloseTabLeft}
|
||||
onAddTab={handleAddTabLeft}
|
||||
onAddTab={handleAddTabLeftWithFocus}
|
||||
onReorderTabs={handleReorderTabsLeft}
|
||||
onMoveTabToOtherSide={handleMoveTabFromRightToLeft}
|
||||
/>
|
||||
@@ -313,6 +360,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
<SftpPaneView
|
||||
side="left"
|
||||
pane={pane}
|
||||
dialogActionScopeId={dialogActionScopeIdRef.current}
|
||||
isPaneFocused={focusedSide === "left"}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
showHeader
|
||||
@@ -354,9 +402,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
<SftpTabBar
|
||||
tabs={rightTabsInfo}
|
||||
side="right"
|
||||
onSelectTab={handleSelectTabRight}
|
||||
onSelectTab={handleSelectTabRightWithFocus}
|
||||
onCloseTab={handleCloseTabRight}
|
||||
onAddTab={handleAddTabRight}
|
||||
onAddTab={handleAddTabRightWithFocus}
|
||||
onReorderTabs={handleReorderTabsRight}
|
||||
onMoveTabToOtherSide={handleMoveTabFromLeftToRight}
|
||||
/>
|
||||
@@ -372,6 +420,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
<SftpPaneView
|
||||
side="right"
|
||||
pane={pane}
|
||||
dialogActionScopeId={dialogActionScopeIdRef.current}
|
||||
isPaneFocused={focusedSide === "right"}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
showHeader
|
||||
@@ -418,6 +467,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
handleSaveTextFile={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
showFileOpenerDialog={showFileOpenerDialog}
|
||||
setShowFileOpenerDialog={setShowFileOpenerDialog}
|
||||
fileOpenerTarget={fileOpenerTarget}
|
||||
@@ -435,6 +486,7 @@ const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
|
||||
prev.hosts === next.hosts &&
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.groupConfigs === next.groupConfigs &&
|
||||
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
|
||||
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
|
||||
prev.sftpAutoSync === next.sftpAutoSync &&
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Host, ShellHistoryEntry, Snippet, SSHKey } from '../types';
|
||||
import { HotkeyScheme, KeyBinding, keyEventToString, ManagedSource, matchesKeyBinding, parseKeyCombo } from '../domain/models';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import SelectHostPanel from './SelectHostPanel';
|
||||
import { AsidePanel, AsidePanelContent } from './ui/aside-panel';
|
||||
import { AsidePanel, AsidePanelContent, AsidePanelFooter } from './ui/aside-panel';
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
import { Combobox, ComboboxOption } from './ui/combobox';
|
||||
@@ -18,6 +18,7 @@ import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { SortDropdown, SortMode } from './ui/sort-dropdown';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
|
||||
interface SnippetsManagerProps {
|
||||
snippets: Snippet[];
|
||||
@@ -720,6 +721,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={onCreateGroup}
|
||||
title={t('snippets.targets.add')}
|
||||
layout="inline"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -730,6 +732,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
open={true}
|
||||
onClose={handleClosePanel}
|
||||
title={editingSnippet.id ? t('snippets.panel.editTitle') : t('snippets.panel.newTitle')}
|
||||
layout="inline"
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -883,7 +886,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
</AsidePanelContent>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-3 border-t border-border/60 shrink-0">
|
||||
<AsidePanelFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleSubmit}
|
||||
@@ -891,7 +894,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
>
|
||||
{editingSnippet.targets?.length ? t('action.run') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</AsidePanelFooter>
|
||||
</AsidePanel>
|
||||
);
|
||||
}
|
||||
@@ -905,6 +908,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
subtitle={t('snippets.history.subtitle', { count: shellHistory.length })}
|
||||
showBackButton={true}
|
||||
onBack={handleClosePanel}
|
||||
layout="inline"
|
||||
>
|
||||
{/* History List */}
|
||||
<div
|
||||
@@ -951,8 +955,9 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex gap-3 relative">
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="h-full min-h-0 flex relative">
|
||||
<div className="flex-1 flex flex-col min-h-0 min-w-0 overflow-hidden">
|
||||
<header className="border-b border-border/50 bg-secondary/80 backdrop-blur">
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-2">
|
||||
{/* Search box */}
|
||||
@@ -1059,7 +1064,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer",
|
||||
"group cursor-pointer overflow-hidden",
|
||||
viewMode === 'grid'
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
|
||||
@@ -1079,11 +1084,11 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
}}
|
||||
onClick={() => setSelectedPackage(pkg.path)}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div className="flex items-center gap-3 h-full min-w-0">
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center flex-shrink-0">
|
||||
<Package size={18} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="w-0 flex-1">
|
||||
<div className="text-sm font-semibold truncate">{pkg.name}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{t('snippets.package.count', { count: pkg.count })}</div>
|
||||
</div>
|
||||
@@ -1114,7 +1119,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer",
|
||||
"group cursor-pointer overflow-hidden",
|
||||
viewMode === 'grid'
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
|
||||
@@ -1126,15 +1131,22 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
}}
|
||||
onClick={() => handleEdit(snippet)}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div className="flex items-center gap-3 h-full min-w-0">
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center flex-shrink-0">
|
||||
<FileCode size={18} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="w-0 flex-1">
|
||||
<div className="text-sm font-semibold truncate">{snippet.label}</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono leading-4 truncate">
|
||||
{snippet.command.replace(/\s+/g, ' ') || t('snippets.commandFallback')}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-[11px] text-muted-foreground font-mono leading-4 truncate">
|
||||
{snippet.command.replace(/\s+/g, ' ') || t('snippets.commandFallback')}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-sm break-all font-mono text-xs">
|
||||
{snippet.command}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{snippet.shortkey && (
|
||||
<div className="shrink-0 px-2 py-1 text-[10px] font-mono rounded border border-border bg-muted/50 text-muted-foreground">
|
||||
@@ -1254,6 +1266,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
{/* Right Panel */}
|
||||
{renderRightPanel()}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import {
|
||||
resolveHostTerminalThemeId,
|
||||
} from "../domain/terminalAppearance";
|
||||
import { classifyDistroId } from "../domain/host";
|
||||
import { resolveHostAuth } from "../domain/sshAuth";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
|
||||
@@ -44,8 +45,10 @@ import { TerminalToolbar } from "./terminal/TerminalToolbar";
|
||||
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
|
||||
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
|
||||
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
|
||||
import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
|
||||
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
||||
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
|
||||
import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
|
||||
import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextActions";
|
||||
@@ -120,6 +123,7 @@ interface TerminalProps {
|
||||
fontFamilyId: string;
|
||||
fontSize: number;
|
||||
terminalTheme: TerminalTheme;
|
||||
followAppTerminalTheme?: boolean;
|
||||
terminalSettings?: TerminalSettings;
|
||||
sessionId: string;
|
||||
startupCommand?: string;
|
||||
@@ -194,6 +198,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
fontFamilyId,
|
||||
fontSize,
|
||||
terminalTheme,
|
||||
followAppTerminalTheme = false,
|
||||
terminalSettings,
|
||||
sessionId,
|
||||
startupCommand,
|
||||
@@ -254,6 +259,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isVisibleRef.current = isVisible;
|
||||
const pendingOutputScrollRef = useRef(false);
|
||||
const lastFittedSizeRef = useRef<{ width: number; height: number } | null>(null);
|
||||
const fontWeightFixupDoneRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (xtermRuntimeRef.current) {
|
||||
@@ -327,6 +333,23 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const statusRef = useRef<TerminalSession["status"]>(status);
|
||||
statusRef.current = status;
|
||||
|
||||
// Work around xterm.js WebGL renderer bug: glyphs rendered via the constructor
|
||||
// look different from dynamically-set ones. After text appears on screen (status
|
||||
// becomes "connected"), do a fontWeight round-trip to normalize the rendering.
|
||||
useEffect(() => {
|
||||
if (status !== 'connected' || fontWeightFixupDoneRef.current || !termRef.current) return;
|
||||
fontWeightFixupDoneRef.current = true;
|
||||
const timer = setTimeout(() => {
|
||||
if (!termRef.current) return;
|
||||
// Re-read the current weight at fire time to avoid stale closures
|
||||
const w = termRef.current.options.fontWeight;
|
||||
if (w === 'normal' || w === 400) return;
|
||||
termRef.current.options.fontWeight = 'normal';
|
||||
termRef.current.options.fontWeight = w;
|
||||
}, 200);
|
||||
return () => clearTimeout(timer);
|
||||
}, [status]);
|
||||
|
||||
const [chainProgress, setChainProgress] = useState<{
|
||||
currentHop: number;
|
||||
totalHops: number;
|
||||
@@ -490,16 +513,58 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const isLocalConnection = host.protocol === "local";
|
||||
const isSerialConnection = host.protocol === "serial";
|
||||
|
||||
// Server stats (CPU, Memory, Disk) — only for Linux/macOS
|
||||
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, and never
|
||||
// for hosts classified as network devices (either via explicit
|
||||
// deviceType='network' or via SSH banner detection that populated
|
||||
// host.distro with a network-vendor ID). See #674: polling the stats
|
||||
// command on Cisco / Huawei / Juniper etc. generates one AAA session
|
||||
// log entry per poll because each exec channel is counted as a new
|
||||
// session on those devices.
|
||||
//
|
||||
// IMPORTANT: this gating must NOT go through getEffectiveHostDistro()
|
||||
// because that honors the manual distro override (`distroMode: 'manual'`
|
||||
// + `manualDistro`) which is purely a cosmetic icon choice. A user who
|
||||
// pinned an "ubuntu" icon on what is actually a Cisco host would
|
||||
// otherwise silently re-enable the polling loop and re-introduce the
|
||||
// AAA log flood this patch is meant to eliminate. The display icon can
|
||||
// still be overridden (see DistroAvatar) — gating uses the raw detected
|
||||
// `host.distro` and the explicit `host.deviceType` only.
|
||||
const detectedDeviceClass = classifyDistroId(host.distro);
|
||||
const isNetworkDevice =
|
||||
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
|
||||
const isSupportedOs =
|
||||
!isNetworkDevice &&
|
||||
(host.os === 'linux' || host.os === 'macos' || detectedDeviceClass === 'linux-like');
|
||||
const { stats: serverStats } = useServerStats({
|
||||
sessionId,
|
||||
enabled: terminalSettings?.showServerStats ?? true,
|
||||
refreshInterval: terminalSettings?.serverStatsRefreshInterval ?? 5,
|
||||
isSupportedOs: host.os === 'linux' || host.os === 'macos',
|
||||
isSupportedOs,
|
||||
isConnected: status === 'connected',
|
||||
isVisible,
|
||||
});
|
||||
|
||||
const zmodem = useZmodemTransfer(sessionId);
|
||||
|
||||
const zmodemToastedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (zmodem.active) {
|
||||
zmodemToastedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (zmodemToastedRef.current) return;
|
||||
if (zmodem.error) {
|
||||
zmodemToastedRef.current = true;
|
||||
toast.error(zmodem.error, 'ZMODEM');
|
||||
} else if (zmodem.filename) {
|
||||
zmodemToastedRef.current = true;
|
||||
toast.success(
|
||||
`${zmodem.transferType === 'upload' ? 'Uploaded' : 'Downloaded'}: ${zmodem.filename}`,
|
||||
'ZMODEM',
|
||||
);
|
||||
}
|
||||
}, [zmodem.active, zmodem.error, zmodem.filename, zmodem.transferType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) {
|
||||
lastToastedErrorRef.current = null;
|
||||
@@ -553,10 +618,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
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 hasFontWeightOverride = host.fontWeightOverride === true || (host.fontWeightOverride === undefined && host.fontWeight != null);
|
||||
const effectiveFontSize = useMemo(
|
||||
() => (hasFontSizeOverride && host.fontSize != null ? host.fontSize : fontSize),
|
||||
[fontSize, hasFontSizeOverride, host.fontSize],
|
||||
);
|
||||
const effectiveFontWeight = useMemo(
|
||||
() => (hasFontWeightOverride && host.fontWeight != null ? host.fontWeight : (terminalSettings?.fontWeight ?? 400)),
|
||||
[terminalSettings?.fontWeight, hasFontWeightOverride, host.fontWeight],
|
||||
);
|
||||
const resolvedFontFamily = useMemo(() => {
|
||||
const hostFontId = hasFontFamilyOverride && host.fontFamily
|
||||
? host.fontFamily
|
||||
@@ -566,6 +636,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, [availableFonts, fontFamilyId, hasFontFamilyOverride, host.fontFamily]);
|
||||
|
||||
const effectiveTheme = useMemo(() => {
|
||||
// When "Follow Application Theme" is on and there's no active
|
||||
// preview, skip per-host overrides — all terminals should use the
|
||||
// UI-matched theme passed via terminalTheme prop.
|
||||
if (followAppTerminalTheme && !themePreviewId) return terminalTheme;
|
||||
const themeId = themePreviewId ?? resolveHostTerminalThemeId(
|
||||
{ theme: host.theme, themeOverride: host.themeOverride } as Pick<Host, 'theme' | 'themeOverride'>,
|
||||
terminalTheme.id,
|
||||
@@ -576,7 +650,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (hostTheme) return hostTheme;
|
||||
}
|
||||
return terminalTheme;
|
||||
}, [customThemes, host.theme, host.themeOverride, terminalTheme, themePreviewId]);
|
||||
}, [customThemes, followAppTerminalTheme, host.theme, host.themeOverride, terminalTheme, themePreviewId]);
|
||||
|
||||
const resolvedChainHosts =
|
||||
chainHosts;
|
||||
@@ -900,6 +974,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current.options.theme = {
|
||||
...effectiveTheme.colors,
|
||||
selectionBackground: effectiveTheme.colors.selection,
|
||||
scrollbarSliderBackground: effectiveTheme.colors.foreground + '33',
|
||||
scrollbarSliderHoverBackground: effectiveTheme.colors.foreground + '66',
|
||||
scrollbarSliderActiveBackground: effectiveTheme.colors.foreground + '80',
|
||||
};
|
||||
}
|
||||
}, [effectiveTheme]);
|
||||
@@ -913,7 +990,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current.options.cursorStyle = terminalSettings.cursorShape;
|
||||
termRef.current.options.cursorBlink = terminalSettings.cursorBlink;
|
||||
termRef.current.options.scrollback = terminalSettings.scrollback;
|
||||
termRef.current.options.fontWeight = terminalSettings.fontWeight as
|
||||
termRef.current.options.fontWeight = effectiveFontWeight as
|
||||
| 100
|
||||
| 200
|
||||
| 300
|
||||
@@ -928,10 +1005,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (typeof document === "undefined" || !document.fonts?.check) {
|
||||
return terminalSettings.fontWeightBold;
|
||||
}
|
||||
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
|
||||
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
|
||||
return document.fonts.check(weightSpec)
|
||||
? terminalSettings.fontWeightBold
|
||||
: terminalSettings.fontWeight;
|
||||
: effectiveFontWeight;
|
||||
})();
|
||||
|
||||
termRef.current.options.fontWeightBold = resolvedFontWeightBold as
|
||||
@@ -966,7 +1043,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
lastFittedSizeRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [effectiveFontSize, resolvedFontFamily, terminalSettings]);
|
||||
}, [effectiveFontSize, effectiveFontWeight, resolvedFontFamily, terminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
@@ -1015,10 +1092,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (terminalSettings && termRef.current) {
|
||||
const fontFamily = termRef.current.options?.fontFamily || "";
|
||||
if (typeof document !== "undefined" && document.fonts?.check) {
|
||||
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
|
||||
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
|
||||
const resolvedBold = document.fonts.check(weightSpec)
|
||||
? terminalSettings.fontWeightBold
|
||||
: terminalSettings.fontWeight;
|
||||
: effectiveFontWeight;
|
||||
termRef.current.options.fontWeightBold = resolvedBold as
|
||||
| 100
|
||||
| 200
|
||||
@@ -1049,7 +1126,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [effectiveFontSize, resizeSession, terminalSettings]);
|
||||
}, [effectiveFontSize, effectiveFontWeight, resizeSession, terminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !containerRef.current || !fitAddonRef.current) return;
|
||||
@@ -1086,12 +1163,38 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !fitAddonRef.current) return;
|
||||
const timer = setTimeout(() => {
|
||||
// Fit twice: once after initial layout (100ms) and again after layout settles
|
||||
// (350ms) to handle race conditions during split operations where the container
|
||||
// dimensions may not be final on the first pass.
|
||||
const timer1 = setTimeout(() => {
|
||||
safeFit({ requireVisible: true });
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
const timer2 = setTimeout(() => {
|
||||
safeFit({ force: true, requireVisible: true });
|
||||
}, 350);
|
||||
return () => { clearTimeout(timer1); clearTimeout(timer2); };
|
||||
}, [inWorkspace, isVisible]);
|
||||
|
||||
// When search bar opens/closes, re-fit terminal and maintain scroll position
|
||||
useEffect(() => {
|
||||
const term = termRef.current;
|
||||
if (!term || !fitAddonRef.current) return;
|
||||
const buffer = term.buffer.active;
|
||||
const wasAtBottom = buffer.viewportY >= buffer.baseY;
|
||||
const prevViewportY = buffer.viewportY;
|
||||
const timer = setTimeout(() => {
|
||||
safeFit({ force: true, requireVisible: true });
|
||||
requestAnimationFrame(() => {
|
||||
if (wasAtBottom) {
|
||||
term.scrollToBottom();
|
||||
} else {
|
||||
term.scrollToLine(prevViewportY);
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isSearchOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldAutoFocus = isVisible && termRef.current && (!inWorkspace || isFocusMode);
|
||||
if (shouldAutoFocus) {
|
||||
@@ -1355,6 +1458,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const handleRetry = () => {
|
||||
if (!termRef.current) return;
|
||||
cleanupSession();
|
||||
// Reset terminal state: disable mouse tracking modes and clear screen so
|
||||
// stale SGR mouse sequences don't leak into the new session as text input.
|
||||
termRef.current.write('\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l');
|
||||
termRef.current.reset();
|
||||
auth.resetForRetry();
|
||||
terminalDataCapturedRef.current = false;
|
||||
hasRunStartupCommandRef.current = false;
|
||||
@@ -1549,7 +1656,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
)}
|
||||
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0 border-b-[0.5px]"
|
||||
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0"
|
||||
style={{
|
||||
backgroundColor: 'var(--terminal-ui-bg)',
|
||||
color: 'var(--terminal-ui-fg)',
|
||||
@@ -1937,7 +2044,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-x-0 bottom-0"
|
||||
className="xterm-container absolute inset-x-0 bottom-0"
|
||||
style={{
|
||||
top: isSearchOpen ? "64px" : "30px",
|
||||
paddingLeft: 6,
|
||||
@@ -1963,6 +2070,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
containerRef={containerRef}
|
||||
onRequestReposition={autocomplete.repositionPopup}
|
||||
searchBarOffset={isSearchOpen ? 64 : 30}
|
||||
onDismiss={autocompleteClosePopup}
|
||||
/>,
|
||||
document.body,
|
||||
)
|
||||
@@ -2047,6 +2155,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ZMODEM transfer progress indicator */}
|
||||
{zmodem.active && (
|
||||
<div className="absolute bottom-4 right-4 z-[25] pointer-events-auto">
|
||||
<ZmodemProgressIndicator
|
||||
transferType={zmodem.transferType}
|
||||
filename={zmodem.filename}
|
||||
transferred={zmodem.transferred}
|
||||
total={zmodem.total}
|
||||
fileIndex={zmodem.fileIndex}
|
||||
fileCount={zmodem.fileCount}
|
||||
finalizing={zmodem.finalizing}
|
||||
onCancel={zmodem.cancel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compose Bar (solo sessions only; workspace uses TerminalLayer's global bar) */}
|
||||
|
||||
@@ -14,12 +14,15 @@ import { KeyBinding, TerminalSettings } from '../domain/models';
|
||||
import {
|
||||
clearHostFontFamilyOverride,
|
||||
clearHostFontSizeOverride,
|
||||
clearHostFontWeightOverride,
|
||||
clearHostThemeOverride,
|
||||
hasHostFontFamilyOverride,
|
||||
hasHostFontSizeOverride,
|
||||
hasHostFontWeightOverride,
|
||||
hasHostThemeOverride,
|
||||
resolveHostTerminalFontFamilyId,
|
||||
resolveHostTerminalFontSize,
|
||||
resolveHostTerminalFontWeight,
|
||||
resolveHostTerminalThemeId,
|
||||
} from '../domain/terminalAppearance';
|
||||
import { cn, normalizeLineEndings } from '../lib/utils';
|
||||
@@ -29,7 +32,8 @@ import { useStoredNumber } from '../application/state/useStoredNumber';
|
||||
import { STORAGE_KEY_SIDE_PANEL_WIDTH } from '../infrastructure/config/storageKeys';
|
||||
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
|
||||
import type { DropEntry } from '../lib/sftpFileUtils';
|
||||
import { Host, Identity, KnownHost, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
|
||||
import { GroupConfig, Host, Identity, KnownHost, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from '../domain/groupConfig';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import Terminal from './Terminal';
|
||||
import { SftpSidePanel } from './SftpSidePanel';
|
||||
@@ -309,6 +313,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
activeProviderId={aiState.activeProviderId}
|
||||
activeModelId={aiState.activeModelId}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
toolIntegrationMode={aiState.toolIntegrationMode}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
agentModelMap={aiState.agentModelMap}
|
||||
@@ -338,6 +343,7 @@ AIChatPanelsHost.displayName = 'AIChatPanelsHost';
|
||||
|
||||
interface TerminalLayerProps {
|
||||
hosts: Host[];
|
||||
groupConfigs: GroupConfig[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
snippets: Snippet[];
|
||||
@@ -347,6 +353,7 @@ interface TerminalLayerProps {
|
||||
knownHosts?: KnownHost[];
|
||||
draggingSessionId: string | null;
|
||||
terminalTheme: TerminalTheme;
|
||||
followAppTerminalTheme?: boolean;
|
||||
terminalSettings?: TerminalSettings;
|
||||
terminalFontFamilyId: string;
|
||||
fontSize?: number;
|
||||
@@ -356,6 +363,7 @@ interface TerminalLayerProps {
|
||||
onUpdateTerminalThemeId?: (themeId: string) => void;
|
||||
onUpdateTerminalFontFamilyId?: (fontFamilyId: string) => void;
|
||||
onUpdateTerminalFontSize?: (fontSize: number) => void;
|
||||
onUpdateTerminalFontWeight?: (fontWeight: number) => void;
|
||||
onCloseSession: (sessionId: string, e?: React.MouseEvent) => void;
|
||||
onUpdateSessionStatus: (sessionId: string, status: TerminalSession['status']) => void;
|
||||
onUpdateHostDistro: (hostId: string, distro: string) => void;
|
||||
@@ -391,6 +399,7 @@ interface TerminalLayerProps {
|
||||
|
||||
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
hosts,
|
||||
groupConfigs,
|
||||
keys,
|
||||
identities,
|
||||
snippets,
|
||||
@@ -400,6 +409,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
knownHosts = [],
|
||||
draggingSessionId,
|
||||
terminalTheme,
|
||||
followAppTerminalTheme = false,
|
||||
terminalSettings,
|
||||
terminalFontFamilyId,
|
||||
fontSize = 14,
|
||||
@@ -409,6 +419,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onUpdateTerminalThemeId,
|
||||
onUpdateTerminalFontFamilyId,
|
||||
onUpdateTerminalFontSize,
|
||||
onUpdateTerminalFontWeight,
|
||||
onCloseSession,
|
||||
onUpdateSessionStatus,
|
||||
onUpdateHostDistro,
|
||||
@@ -770,8 +781,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const sessionHostsMap = useMemo(() => {
|
||||
const map = new Map<string, Host>();
|
||||
for (const session of sessions) {
|
||||
const existingHost = hostMap.get(session.hostId);
|
||||
if (existingHost) {
|
||||
const rawHost = hostMap.get(session.hostId);
|
||||
if (rawHost) {
|
||||
// Apply group config defaults so Terminal sees the merged host
|
||||
const groupDefaults = rawHost.group
|
||||
? resolveGroupDefaults(rawHost.group, groupConfigs)
|
||||
: {};
|
||||
const existingHost = applyGroupDefaults(rawHost, groupDefaults);
|
||||
|
||||
const protocol = session.protocol ?? existingHost.protocol;
|
||||
const port = session.port ?? existingHost.port;
|
||||
const moshEnabled = session.moshEnabled ?? existingHost.moshEnabled;
|
||||
@@ -804,11 +821,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
protocol: session.protocol ?? 'local' as const,
|
||||
moshEnabled: session.moshEnabled,
|
||||
charset: session.charset,
|
||||
localShell: session.localShell,
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
});
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [sessions, hostMap]);
|
||||
}, [sessions, hostMap, groupConfigs]);
|
||||
const sessionChainHostsMap = useMemo(() => {
|
||||
const map = new Map<string, Host[]>();
|
||||
for (const session of sessions) {
|
||||
@@ -817,12 +838,19 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
map.set(
|
||||
session.id,
|
||||
host.hostChain.hostIds
|
||||
.map((hostId) => hostMap.get(hostId))
|
||||
.map((hostId) => {
|
||||
const rawChainHost = hostMap.get(hostId);
|
||||
if (!rawChainHost) return undefined;
|
||||
const chainGroupDefaults = rawChainHost.group
|
||||
? resolveGroupDefaults(rawChainHost.group, groupConfigs)
|
||||
: {};
|
||||
return applyGroupDefaults(rawChainHost, chainGroupDefaults);
|
||||
})
|
||||
.filter((value): value is Host => Boolean(value)),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}, [sessions, sessionHostsMap, hostMap]);
|
||||
}, [sessions, sessionHostsMap, hostMap, groupConfigs]);
|
||||
|
||||
const validTerminalTabIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
@@ -1354,6 +1382,17 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const isFocusedHostLocal = useMemo(() => {
|
||||
return focusedHost?.protocol === 'local' || !!focusedHost?.id?.startsWith('local-');
|
||||
}, [focusedHost]);
|
||||
// Hosts not in the persisted hostMap (e.g. quick-connect) are ephemeral —
|
||||
// sidebar appearance changes should update global settings, not per-host overrides.
|
||||
const isFocusedHostEphemeral = useMemo(() => {
|
||||
if (isFocusedHostLocal) return true;
|
||||
if (!focusedHost) return true;
|
||||
return !hostMap.has(focusedHost.id);
|
||||
}, [focusedHost, isFocusedHostLocal, hostMap]);
|
||||
const rawFocusedHost = useMemo(() => {
|
||||
if (!focusedHost) return null;
|
||||
return hostMap.get(focusedHost.id) ?? null;
|
||||
}, [focusedHost, hostMap]);
|
||||
const previewTargetSessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id ?? null;
|
||||
const activeThemePreviewId = themePreview.targetSessionId === previewTargetSessionId
|
||||
? themePreview.themeId
|
||||
@@ -1366,9 +1405,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const focusedThemeOverridden = hasHostThemeOverride(focusedHost);
|
||||
const focusedFontFamilyOverridden = hasHostFontFamilyOverride(focusedHost);
|
||||
const focusedFontSizeOverridden = hasHostFontSizeOverride(focusedHost);
|
||||
const focusedFontWeight = resolveHostTerminalFontWeight(focusedHost, terminalSettings?.fontWeight ?? 400);
|
||||
const focusedFontWeightOverridden = hasHostFontWeightOverride(focusedHost);
|
||||
const visibleFocusedThemeId = followAppTerminalTheme ? terminalTheme.id : focusedThemeId;
|
||||
const previewedOrVisibleThemeId = activeThemePreviewId ?? visibleFocusedThemeId;
|
||||
const activeTopTabsThemeId = activeSidePanelTab === 'theme' && previewTargetSessionId
|
||||
? (activeThemePreviewId ?? focusedThemeId)
|
||||
: null;
|
||||
? previewedOrVisibleThemeId
|
||||
: (isVisible ? visibleFocusedThemeId : null);
|
||||
const appliedPreviewSessionRef = useRef<string | null>(null);
|
||||
const customThemes = useCustomThemes();
|
||||
const applyTerminalPreviewVars = useCallback((sessionId: string | null, themeId: string | null) => {
|
||||
@@ -1460,9 +1503,27 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}, [activeTopTabsThemeId, applyTopTabsPreviewVars]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!followAppTerminalTheme) return;
|
||||
if (themeCommitTimerRef.current) {
|
||||
clearTimeout(themeCommitTimerRef.current);
|
||||
themeCommitTimerRef.current = null;
|
||||
}
|
||||
const appliedSessionId = appliedPreviewSessionRef.current;
|
||||
if (appliedSessionId) {
|
||||
clearTerminalPreviewVars(appliedSessionId);
|
||||
appliedPreviewSessionRef.current = null;
|
||||
}
|
||||
clearTopTabsPreviewVars();
|
||||
if (themePreview.targetSessionId || themePreview.themeId) {
|
||||
setThemePreview({ targetSessionId: null, themeId: null });
|
||||
}
|
||||
}, [followAppTerminalTheme, themePreview.targetSessionId, themePreview.themeId]);
|
||||
|
||||
useEffect(() => {
|
||||
const panelOpen = activeSidePanelTab === 'theme' && !!previewTargetSessionId;
|
||||
const shouldKeepPreview =
|
||||
activeSidePanelTab === 'theme' &&
|
||||
!!previewTargetSessionId &&
|
||||
panelOpen &&
|
||||
themePreview.targetSessionId === previewTargetSessionId &&
|
||||
!!themePreview.targetSessionId &&
|
||||
!!themePreview.themeId;
|
||||
|
||||
@@ -1473,8 +1534,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
clearTerminalPreviewVars(appliedSessionId);
|
||||
appliedPreviewSessionRef.current = null;
|
||||
}
|
||||
clearTopTabsPreviewVars();
|
||||
|
||||
if (themePreview.targetSessionId || themePreview.themeId) {
|
||||
setThemePreview({ targetSessionId: null, themeId: null });
|
||||
}
|
||||
@@ -1484,14 +1543,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
if (
|
||||
themePreview.targetSessionId === previewTargetSessionId &&
|
||||
themePreview.themeId &&
|
||||
themePreview.themeId === focusedThemeId
|
||||
themePreview.themeId === visibleFocusedThemeId
|
||||
) {
|
||||
setThemePreview({ targetSessionId: null, themeId: null });
|
||||
}
|
||||
}, [focusedThemeId, previewTargetSessionId, themePreview]);
|
||||
}, [previewTargetSessionId, themePreview, visibleFocusedThemeId]);
|
||||
|
||||
const handleThemeChangeForFocusedSession = useCallback((themeId: string) => {
|
||||
if (!focusedHost || themeId === focusedThemeId) return;
|
||||
if (!focusedHost || themeId === previewedOrVisibleThemeId) return;
|
||||
applyTerminalPreviewVars(previewTargetSessionId, themeId);
|
||||
applyTopTabsPreviewVars(themeId);
|
||||
setThemePreview({ targetSessionId: previewTargetSessionId, themeId });
|
||||
@@ -1500,14 +1559,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}
|
||||
themeCommitTimerRef.current = setTimeout(() => {
|
||||
startTransition(() => {
|
||||
if (isFocusedHostLocal) {
|
||||
if (isFocusedHostEphemeral) {
|
||||
onUpdateTerminalThemeId?.(themeId);
|
||||
return;
|
||||
}
|
||||
onUpdateHost({ ...focusedHost, theme: themeId, themeOverride: true });
|
||||
if (rawFocusedHost) {
|
||||
onUpdateHost({ ...rawFocusedHost, theme: themeId, themeOverride: true });
|
||||
}
|
||||
});
|
||||
}, 160);
|
||||
}, [applyTerminalPreviewVars, applyTopTabsPreviewVars, focusedHost, focusedThemeId, isFocusedHostLocal, onUpdateTerminalThemeId, onUpdateHost, previewTargetSessionId]);
|
||||
}, [applyTerminalPreviewVars, applyTopTabsPreviewVars, focusedHost, isFocusedHostEphemeral, onUpdateTerminalThemeId, onUpdateHost, previewTargetSessionId, previewedOrVisibleThemeId, rawFocusedHost]);
|
||||
|
||||
const handleThemeResetForFocusedSession = useCallback(() => {
|
||||
if (themeCommitTimerRef.current) {
|
||||
@@ -1515,41 +1576,64 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}
|
||||
clearTerminalPreviewVars(previewTargetSessionId);
|
||||
setThemePreview({ targetSessionId: null, themeId: null });
|
||||
if (!focusedHost || isFocusedHostLocal) return;
|
||||
onUpdateHost(clearHostThemeOverride(focusedHost));
|
||||
}, [focusedHost, isFocusedHostLocal, onUpdateHost, previewTargetSessionId]);
|
||||
if (!focusedHost || isFocusedHostEphemeral || !rawFocusedHost) return;
|
||||
onUpdateHost(clearHostThemeOverride(rawFocusedHost));
|
||||
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost, previewTargetSessionId, rawFocusedHost]);
|
||||
|
||||
const handleFontFamilyChangeForFocusedSession = useCallback((fontFamilyId: string) => {
|
||||
if (!focusedHost || fontFamilyId === focusedFontFamilyId) return;
|
||||
startTransition(() => {
|
||||
if (isFocusedHostLocal) {
|
||||
if (isFocusedHostEphemeral) {
|
||||
onUpdateTerminalFontFamilyId?.(fontFamilyId);
|
||||
return;
|
||||
}
|
||||
onUpdateHost({ ...focusedHost, fontFamily: fontFamilyId, fontFamilyOverride: true });
|
||||
});
|
||||
}, [focusedHost, focusedFontFamilyId, isFocusedHostLocal, onUpdateTerminalFontFamilyId, onUpdateHost]);
|
||||
}, [focusedHost, focusedFontFamilyId, isFocusedHostEphemeral, onUpdateTerminalFontFamilyId, onUpdateHost]);
|
||||
|
||||
const handleFontFamilyResetForFocusedSession = useCallback(() => {
|
||||
if (!focusedHost || isFocusedHostLocal) return;
|
||||
if (!focusedHost || isFocusedHostEphemeral) return;
|
||||
onUpdateHost(clearHostFontFamilyOverride(focusedHost));
|
||||
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
|
||||
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost]);
|
||||
|
||||
const handleFontSizeChangeForFocusedSession = useCallback((newFontSize: number) => {
|
||||
if (!focusedHost || newFontSize === focusedFontSize) return;
|
||||
startTransition(() => {
|
||||
if (isFocusedHostLocal) {
|
||||
if (isFocusedHostEphemeral) {
|
||||
onUpdateTerminalFontSize?.(newFontSize);
|
||||
return;
|
||||
}
|
||||
onUpdateHost({ ...focusedHost, fontSize: newFontSize, fontSizeOverride: true });
|
||||
});
|
||||
}, [focusedHost, focusedFontSize, isFocusedHostLocal, onUpdateTerminalFontSize, onUpdateHost]);
|
||||
}, [focusedHost, focusedFontSize, isFocusedHostEphemeral, onUpdateTerminalFontSize, onUpdateHost]);
|
||||
|
||||
const handleFontSizeResetForFocusedSession = useCallback(() => {
|
||||
if (!focusedHost || isFocusedHostLocal) return;
|
||||
if (!focusedHost || isFocusedHostEphemeral) return;
|
||||
onUpdateHost(clearHostFontSizeOverride(focusedHost));
|
||||
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
|
||||
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost]);
|
||||
|
||||
const handleFontWeightChangeForFocusedSession = useCallback((newFontWeight: number) => {
|
||||
if (!focusedHost || newFontWeight === focusedFontWeight) return;
|
||||
startTransition(() => {
|
||||
if (isFocusedHostEphemeral) {
|
||||
onUpdateTerminalFontWeight?.(newFontWeight);
|
||||
return;
|
||||
}
|
||||
// Prefer raw (un-merged) host to avoid flattening group defaults
|
||||
const rawHost = hostMap.get(focusedHost.id);
|
||||
if (rawHost) {
|
||||
onUpdateHost({ ...rawHost, fontWeight: newFontWeight, fontWeightOverride: true });
|
||||
}
|
||||
});
|
||||
}, [focusedHost, focusedFontWeight, isFocusedHostEphemeral, onUpdateTerminalFontWeight, onUpdateHost, hostMap]);
|
||||
|
||||
const handleFontWeightResetForFocusedSession = useCallback(() => {
|
||||
if (!focusedHost || isFocusedHostEphemeral) return;
|
||||
const rawHost = hostMap.get(focusedHost.id);
|
||||
if (rawHost) {
|
||||
onUpdateHost(clearHostFontWeightOverride(rawHost));
|
||||
}
|
||||
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost, hostMap]);
|
||||
|
||||
// Keep MCP/ACP approval IPC listener alive for the entire terminal lifecycle.
|
||||
// Must live here (TerminalLayer), not inside the AI panel subtree, so closing
|
||||
@@ -1644,11 +1728,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}, []);
|
||||
|
||||
const resolvedPreviewTheme = useMemo(() => {
|
||||
const themeId = activeThemePreviewId ?? focusedThemeId;
|
||||
const themeId = previewedOrVisibleThemeId;
|
||||
return TERMINAL_THEMES.find((theme) => theme.id === themeId)
|
||||
|| customThemes.find((theme) => theme.id === themeId)
|
||||
|| terminalTheme;
|
||||
}, [activeThemePreviewId, customThemes, focusedThemeId, terminalTheme]);
|
||||
}, [customThemes, previewedOrVisibleThemeId, terminalTheme]);
|
||||
const sessionLogConfig = useMemo(
|
||||
() =>
|
||||
sessionLogsEnabled && sessionLogsDir
|
||||
@@ -1764,7 +1848,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
if (!activeWorkspace || !isFocusMode) return null;
|
||||
|
||||
return (
|
||||
<div className="w-56 flex-shrink-0 bg-secondary/50 border-r border-border/50 flex flex-col">
|
||||
<div
|
||||
className="w-56 flex-shrink-0 bg-secondary/50 border-r border-border/50 flex flex-col"
|
||||
data-section="terminal-workspace-sidebar"
|
||||
>
|
||||
{/* Header with view toggle */}
|
||||
<div className="h-10 flex items-center justify-between px-3 border-b border-border/50">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
@@ -1835,6 +1922,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
<div
|
||||
ref={workspaceOuterRef}
|
||||
className="absolute inset-0 bg-background flex flex-col"
|
||||
data-section="terminal-workspace"
|
||||
style={{
|
||||
visibility: isTerminalLayerVisible ? 'visible' : 'hidden',
|
||||
pointerEvents: isTerminalLayerVisible ? 'auto' : 'none',
|
||||
@@ -2016,20 +2104,25 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
{activeSidePanelTab === 'theme' && (
|
||||
<div className="absolute inset-0 z-10">
|
||||
<ThemeSidePanel
|
||||
currentThemeId={activeThemePreviewId ?? focusedThemeId}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
currentThemeId={previewedOrVisibleThemeId}
|
||||
globalThemeId={terminalTheme.id}
|
||||
currentFontFamilyId={focusedFontFamilyId}
|
||||
globalFontFamilyId={terminalFontFamilyId}
|
||||
currentFontSize={focusedFontSize}
|
||||
currentFontWeight={focusedFontWeight}
|
||||
canResetTheme={focusedThemeOverridden}
|
||||
canResetFontFamily={focusedFontFamilyOverridden}
|
||||
canResetFontSize={focusedFontSizeOverridden}
|
||||
canResetFontWeight={focusedFontWeightOverridden}
|
||||
onThemeChange={handleThemeChangeForFocusedSession}
|
||||
onThemeReset={handleThemeResetForFocusedSession}
|
||||
onFontFamilyChange={handleFontFamilyChangeForFocusedSession}
|
||||
onFontFamilyReset={handleFontFamilyResetForFocusedSession}
|
||||
onFontSizeChange={handleFontSizeChangeForFocusedSession}
|
||||
onFontSizeReset={handleFontSizeResetForFocusedSession}
|
||||
onFontWeightChange={handleFontWeightChangeForFocusedSession}
|
||||
onFontWeightReset={handleFontWeightResetForFocusedSession}
|
||||
previewColors={resolvedPreviewTheme.colors}
|
||||
/>
|
||||
</div>
|
||||
@@ -2171,6 +2264,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
fontFamilyId={terminalFontFamilyId}
|
||||
fontSize={fontSize}
|
||||
terminalTheme={terminalTheme}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
terminalSettings={terminalSettings}
|
||||
sessionId={session.id}
|
||||
startupCommand={session.startupCommand}
|
||||
|
||||
@@ -20,6 +20,7 @@ loader.config({ paths: { vs: monacoBasePath } });
|
||||
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useClipboardBackend } from '../application/state/useClipboardBackend';
|
||||
import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../domain/models';
|
||||
import { getLanguageId, getLanguageName, getSupportedLanguages } from '../lib/sftpFileUtils';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
@@ -34,6 +35,8 @@ interface TextEditorModalProps {
|
||||
onSave: (content: string) => Promise<void>;
|
||||
editorWordWrap: boolean;
|
||||
onToggleWordWrap: () => void;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
}
|
||||
|
||||
// Map our language IDs to Monaco language IDs
|
||||
@@ -122,12 +125,38 @@ const hslToHex = (hslString: string): string => {
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
};
|
||||
|
||||
// Get background color from CSS variable
|
||||
const getBackgroundColor = (): string => {
|
||||
const bgValue = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--background')
|
||||
// Read a CSS custom-property and convert from HSL to hex
|
||||
const getCssColor = (varName: string, fallback: string): string => {
|
||||
const value = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(varName)
|
||||
.trim();
|
||||
return bgValue ? hslToHex(bgValue) : '#1e1e1e';
|
||||
return value ? hslToHex(value) : fallback;
|
||||
};
|
||||
|
||||
interface EditorColors {
|
||||
bg: string;
|
||||
fg: string;
|
||||
primary: string;
|
||||
card: string;
|
||||
mutedFg: string;
|
||||
border: string;
|
||||
}
|
||||
|
||||
/** Read all UI CSS variables that matter for the Monaco theme. */
|
||||
const getEditorColors = (isDark: boolean): EditorColors => ({
|
||||
bg: getCssColor('--background', isDark ? '#1e1e1e' : '#ffffff'),
|
||||
fg: getCssColor('--foreground', isDark ? '#d4d4d4' : '#1e1e1e'),
|
||||
primary: getCssColor('--primary', isDark ? '#569cd6' : '#0078d4'),
|
||||
card: getCssColor('--card', isDark ? '#252526' : '#f3f3f3'),
|
||||
mutedFg: getCssColor('--muted-foreground', isDark ? '#858585' : '#858585'),
|
||||
border: getCssColor('--border', isDark ? '#3c3c3c' : '#d4d4d4'),
|
||||
});
|
||||
|
||||
/** Build a fingerprint string so we can detect immersive-mode color changes cheaply. */
|
||||
const getThemeSignal = (): string => {
|
||||
const root = document.documentElement;
|
||||
return root.dataset.immersiveTheme
|
||||
?? getComputedStyle(root).getPropertyValue('--background').trim();
|
||||
};
|
||||
|
||||
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
@@ -138,6 +167,8 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
onSave,
|
||||
editorWordWrap,
|
||||
onToggleWordWrap,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { readClipboardText: readClipboardTextFromBridge } = useClipboardBackend();
|
||||
@@ -158,49 +189,64 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
document.documentElement.classList.contains('dark')
|
||||
);
|
||||
|
||||
// Track background color for custom theme
|
||||
const [bgColor, setBgColor] = useState(() => getBackgroundColor());
|
||||
// Track a signal that changes whenever immersive-mode or base theme colors change
|
||||
const [themeSignal, setThemeSignal] = useState(() => getThemeSignal());
|
||||
|
||||
// Custom theme name
|
||||
const customThemeName = isDarkTheme ? 'netcatty-dark' : 'netcatty-light';
|
||||
|
||||
// Define and update custom Monaco themes based on UI background color
|
||||
// Define and update custom Monaco themes — syncs with immersive-mode / base UI colors
|
||||
useEffect(() => {
|
||||
if (!monaco) return;
|
||||
|
||||
// Define dark theme with custom background
|
||||
const colors = getEditorColors(isDarkTheme);
|
||||
|
||||
const themeColors: Record<string, string> = {
|
||||
'editor.background': colors.bg,
|
||||
'editor.foreground': colors.fg,
|
||||
'editorCursor.foreground': colors.primary,
|
||||
'editor.selectionBackground': colors.primary + '40',
|
||||
'editor.inactiveSelectionBackground': colors.primary + '25',
|
||||
'editorLineNumber.foreground': colors.mutedFg,
|
||||
'editorLineNumber.activeForeground': colors.fg,
|
||||
'editor.lineHighlightBackground': colors.fg + '08',
|
||||
'editorWidget.background': colors.card,
|
||||
'editorWidget.foreground': colors.fg,
|
||||
'editorWidget.border': colors.border,
|
||||
'input.background': colors.card,
|
||||
'input.foreground': colors.fg,
|
||||
'input.border': colors.border,
|
||||
};
|
||||
|
||||
monaco.editor.defineTheme('netcatty-dark', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editor.background': bgColor,
|
||||
},
|
||||
colors: themeColors,
|
||||
});
|
||||
|
||||
// Define light theme with custom background
|
||||
monaco.editor.defineTheme('netcatty-light', {
|
||||
base: 'vs',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editor.background': bgColor,
|
||||
},
|
||||
colors: themeColors,
|
||||
});
|
||||
|
||||
// Apply the current theme
|
||||
monaco.editor.setTheme(customThemeName);
|
||||
}, [monaco, isDarkTheme, bgColor, customThemeName]);
|
||||
}, [monaco, isDarkTheme, themeSignal, customThemeName]);
|
||||
|
||||
// Listen for theme changes via MutationObserver on <html> class and style
|
||||
// Listen for theme changes via MutationObserver on <html> class, style, and immersive data attr
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const updateTheme = () => {
|
||||
setIsDarkTheme(root.classList.contains('dark'));
|
||||
setBgColor(getBackgroundColor());
|
||||
setThemeSignal(getThemeSignal());
|
||||
};
|
||||
const observer = new MutationObserver(updateTheme);
|
||||
observer.observe(root, { attributes: true, attributeFilter: ['class', 'style'] });
|
||||
observer.observe(root, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style', 'data-immersive-theme'],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
@@ -216,6 +262,11 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
setHasChanges(content !== initialContent);
|
||||
}, [content, initialContent]);
|
||||
|
||||
const closeTabBinding = useMemo(
|
||||
() => keyBindings.find((binding) => binding.action === 'closeTab'),
|
||||
[keyBindings],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
@@ -347,8 +398,33 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
}
|
||||
void handlePasteRef.current();
|
||||
});
|
||||
|
||||
editor.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
editorRef.current?.focus();
|
||||
});
|
||||
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [open]);
|
||||
|
||||
const handleDialogKeyDownCapture = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (hotkeyScheme === 'disabled' || !closeTabBinding) return;
|
||||
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
const keyStr = isMac ? closeTabBinding.mac : closeTabBinding.pc;
|
||||
if (!matchesKeyBinding(e.nativeEvent, keyStr, isMac)) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopPropagation();
|
||||
handleClose();
|
||||
}, [closeTabBinding, handleClose, hotkeyScheme]);
|
||||
|
||||
// Trigger search dialog
|
||||
const handleSearch = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
@@ -370,7 +446,12 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent className="max-w-5xl h-[85vh] flex flex-col p-0 gap-0" hideCloseButton>
|
||||
<DialogContent
|
||||
className="max-w-5xl h-[85vh] flex flex-col p-0 gap-0"
|
||||
hideCloseButton
|
||||
data-hotkey-close-tab="true"
|
||||
onKeyDownCapture={handleDialogKeyDownCapture}
|
||||
>
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
|
||||
161
components/ThemeList.tsx
Normal file
161
components/ThemeList.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Shared theme list component used by both ThemeSelectPanel and ThemeSelectModal
|
||||
*/
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { TERMINAL_THEMES, USER_VISIBLE_TERMINAL_THEMES, isUiMatchTerminalThemeId } from '../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../application/state/customThemeStore';
|
||||
import { cn } from '../lib/utils';
|
||||
import { TerminalTheme } from '../types';
|
||||
|
||||
// Memoized theme item component
|
||||
export const ThemeItem = memo(({
|
||||
theme,
|
||||
isSelected,
|
||||
onSelect
|
||||
}: {
|
||||
theme: TerminalTheme;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) => (
|
||||
<button
|
||||
onClick={() => onSelect(theme.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-all',
|
||||
isSelected
|
||||
? 'bg-primary/10'
|
||||
: 'hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
{/* Color swatch preview */}
|
||||
<div
|
||||
className="w-12 h-8 rounded-[4px] flex-shrink-0 flex flex-col justify-center items-start pl-1.5 gap-0.5 border border-border/50"
|
||||
style={{ backgroundColor: theme.colors.background }}
|
||||
>
|
||||
<div className="h-1 w-4 rounded-full" style={{ backgroundColor: theme.colors.green }} />
|
||||
<div className="h-1 w-6 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
|
||||
<div className="h-1 w-3 rounded-full" style={{ backgroundColor: theme.colors.yellow }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn('text-sm font-medium truncate', isSelected ? 'text-primary' : 'text-foreground')}>
|
||||
{theme.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground capitalize">{theme.type}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check size={16} className="text-primary flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
));
|
||||
ThemeItem.displayName = 'ThemeItem';
|
||||
|
||||
interface ThemeListProps {
|
||||
selectedThemeId: string;
|
||||
onSelect: (themeId: string) => void;
|
||||
}
|
||||
|
||||
export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const customThemes = useCustomThemes();
|
||||
const deletedSelectedTheme = useMemo(
|
||||
() => (selectedThemeId
|
||||
&& !isUiMatchTerminalThemeId(selectedThemeId)
|
||||
&& !TERMINAL_THEMES.some((theme) => theme.id === selectedThemeId)
|
||||
&& !customThemes.some((theme) => theme.id === selectedThemeId)
|
||||
? selectedThemeId
|
||||
: null),
|
||||
[customThemes, selectedThemeId],
|
||||
);
|
||||
const hiddenSelectedTheme = useMemo(
|
||||
() => (isUiMatchTerminalThemeId(selectedThemeId)
|
||||
? TERMINAL_THEMES.find(theme => theme.id === selectedThemeId) || null
|
||||
: null),
|
||||
[selectedThemeId],
|
||||
);
|
||||
|
||||
const { darkThemes, lightThemes } = useMemo(() => {
|
||||
const dark = USER_VISIBLE_TERMINAL_THEMES.filter(t => t.type === 'dark');
|
||||
const light = USER_VISIBLE_TERMINAL_THEMES.filter(t => t.type === 'light');
|
||||
return { darkThemes: dark, lightThemes: light };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{hiddenSelectedTheme && (
|
||||
<div className="mb-4 rounded-lg border border-border/60 bg-muted/30 px-3 py-2.5">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1 font-semibold">
|
||||
{t('terminal.hiddenTheme.title')}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">{hiddenSelectedTheme.name}</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-1">
|
||||
{t('terminal.hiddenTheme.desc')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{deletedSelectedTheme && (
|
||||
<div className="mb-4 rounded-lg border border-border/60 bg-muted/30 px-3 py-2.5">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1 font-semibold">
|
||||
Missing Theme
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">{deletedSelectedTheme}</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-1">
|
||||
This custom theme is no longer available. Pick another theme to replace it.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Dark Themes Section */}
|
||||
<div className="mb-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
|
||||
{t('settings.terminal.themeModal.darkThemes')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{darkThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Light Themes Section */}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
|
||||
{t('settings.terminal.themeModal.lightThemes')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{lightThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Themes Section */}
|
||||
{customThemes.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
|
||||
{t('terminal.customTheme.section')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{customThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,11 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../application/state/customThemeStore';
|
||||
import { cn } from '../lib/utils';
|
||||
import { TerminalTheme } from '../types';
|
||||
import React from 'react';
|
||||
import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
type AsidePanelLayout,
|
||||
} from './ui/aside-panel';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { ThemeList } from './ThemeList';
|
||||
|
||||
interface ThemeSelectPanelProps {
|
||||
open: boolean;
|
||||
@@ -16,42 +14,9 @@ interface ThemeSelectPanelProps {
|
||||
onClose: () => void;
|
||||
onBack?: () => void;
|
||||
showBackButton?: boolean;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
// Mini terminal preview component
|
||||
const TerminalPreview: React.FC<{ theme: TerminalTheme; isSelected: boolean }> = ({
|
||||
theme,
|
||||
isSelected
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-16 h-10 rounded-md overflow-hidden border-2 flex-shrink-0",
|
||||
isSelected ? "border-primary" : "border-transparent"
|
||||
)}
|
||||
style={{ backgroundColor: theme.colors.background }}
|
||||
>
|
||||
<div className="p-1 text-[4px] font-mono leading-tight" style={{ color: theme.colors.foreground }}>
|
||||
<div>
|
||||
<span style={{ color: theme.colors.green }}>$</span>{' '}
|
||||
<span style={{ color: theme.colors.cyan }}>ls</span>
|
||||
</div>
|
||||
<div className="flex gap-0.5 flex-wrap">
|
||||
<span style={{ color: theme.colors.blue }}>dir/</span>
|
||||
<span style={{ color: theme.colors.green }}>file</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: theme.colors.green }}>$</span>{' '}
|
||||
<span
|
||||
className="inline-block w-1 h-1.5"
|
||||
style={{ backgroundColor: theme.colors.cursor }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
|
||||
open,
|
||||
selectedThemeId,
|
||||
@@ -59,52 +24,8 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
|
||||
onClose,
|
||||
onBack,
|
||||
showBackButton = true,
|
||||
layout = 'overlay',
|
||||
}) => {
|
||||
// Reserved for future hover preview feature
|
||||
const [_hoveredThemeId, setHoveredThemeId] = useState<string | null>(null);
|
||||
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
// All themes combined
|
||||
const allThemes = useMemo(() => {
|
||||
return [...TERMINAL_THEMES, ...customThemes];
|
||||
}, [customThemes]);
|
||||
|
||||
const renderThemeItem = (theme: TerminalTheme) => {
|
||||
const isSelected = theme.id === selectedThemeId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={theme.id}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors text-left",
|
||||
isSelected
|
||||
? "bg-primary/10"
|
||||
: "hover:bg-secondary/50"
|
||||
)}
|
||||
onClick={() => onSelect(theme.id)}
|
||||
onMouseEnter={() => setHoveredThemeId(theme.id)}
|
||||
onMouseLeave={() => setHoveredThemeId(null)}
|
||||
>
|
||||
<TerminalPreview theme={theme} isSelected={isSelected} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn(
|
||||
"text-sm font-medium truncate",
|
||||
isSelected && "text-primary"
|
||||
)}>
|
||||
{theme.name}
|
||||
</div>
|
||||
{theme.id === 'netcatty-dark' && (
|
||||
<div className="text-xs text-muted-foreground">Default</div>
|
||||
)}
|
||||
{theme.id === 'netcatty-light' && (
|
||||
<div className="text-xs text-muted-foreground">Light mode</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AsidePanel
|
||||
open={open}
|
||||
@@ -112,12 +33,15 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
|
||||
title="Select Color Theme"
|
||||
showBackButton={showBackButton}
|
||||
onBack={onBack}
|
||||
layout={layout}
|
||||
>
|
||||
<AsidePanelContent className="p-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="py-2">
|
||||
{/* All themes in a single list */}
|
||||
{allThemes.map(renderThemeItem)}
|
||||
<ThemeList
|
||||
selectedThemeId={selectedThemeId || ''}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</AsidePanelContent>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getEffectiveHostDistro } from '../domain/host';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Host, TerminalSession, Workspace } from '../types';
|
||||
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
|
||||
import { getShellIconPath, isMonochromeShellIcon } from '../lib/useDiscoveredShells';
|
||||
import { Button } from './ui/button';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { SyncStatusButton } from './SyncStatusButton';
|
||||
@@ -20,6 +21,7 @@ const dragRegionNoSelect = { WebkitAppRegion: 'drag', userSelect: 'none' } as Re
|
||||
|
||||
interface TopTabsProps {
|
||||
theme: 'dark' | 'light';
|
||||
followAppTerminalTheme?: boolean;
|
||||
hosts: Host[];
|
||||
sessions: TerminalSession[];
|
||||
orphanSessions: TerminalSession[];
|
||||
@@ -54,7 +56,7 @@ const localOsId = (() => {
|
||||
})();
|
||||
|
||||
// Lightweight OS/distro icon for session tabs — matches DistroAvatar "sm" style
|
||||
const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; protocol?: string }> = memo(({ host, isActive, protocol }) => {
|
||||
const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; protocol?: string; shellIcon?: string }> = memo(({ host, isActive, protocol, shellIcon }) => {
|
||||
const boxBase = "shrink-0 h-4 w-4 rounded flex items-center justify-center";
|
||||
const iconSize = "h-2.5 w-2.5";
|
||||
const fallbackStyle = { color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' };
|
||||
@@ -68,8 +70,19 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
|
||||
);
|
||||
}
|
||||
|
||||
// Local protocol → OS-specific icon (protocol may be undefined for local sessions)
|
||||
// Local protocol → shell-specific icon if available, else OS-specific icon
|
||||
if (protocol === 'local' || host?.protocol === 'local' || (!protocol && !host)) {
|
||||
// Use shell icon from discovery when available
|
||||
const iconId = shellIcon || host?.localShellIcon;
|
||||
if (iconId) {
|
||||
return (
|
||||
<img
|
||||
src={getShellIconPath(iconId)}
|
||||
alt={iconId}
|
||||
className={cn("shrink-0 h-4 w-4 object-contain", isMonochromeShellIcon(iconId) && "dark:invert")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const logo = DISTRO_LOGOS[localOsId];
|
||||
const bg = DISTRO_COLORS[localOsId] || DISTRO_COLORS.default;
|
||||
if (logo) {
|
||||
@@ -215,6 +228,7 @@ WindowControls.displayName = 'WindowControls';
|
||||
|
||||
const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
theme,
|
||||
followAppTerminalTheme = false,
|
||||
hosts,
|
||||
sessions,
|
||||
orphanSessions,
|
||||
@@ -522,7 +536,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{activeTabId === session.id && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
|
||||
/>
|
||||
)}
|
||||
{/* Drop indicator line - before */}
|
||||
@@ -540,7 +554,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
/>
|
||||
)}
|
||||
<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} />
|
||||
<SessionTabIcon host={hostMap.get(session.hostId)} isActive={activeTabId === session.id} protocol={session.protocol} shellIcon={session.localShellIcon} />
|
||||
<span className="truncate">{session.hostLabel}</span>
|
||||
<div className="flex-shrink-0">{sessionStatusDot(session.status, hasActivity)}</div>
|
||||
</div>
|
||||
@@ -621,7 +635,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{isActive && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
|
||||
/>
|
||||
)}
|
||||
{/* Drop indicator line - before */}
|
||||
@@ -753,6 +767,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
return (
|
||||
<div
|
||||
data-top-tabs-root
|
||||
data-section="top-tabs"
|
||||
className="relative w-full bg-secondary app-drag"
|
||||
style={{
|
||||
...dragRegionNoSelect,
|
||||
@@ -923,7 +938,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
className="h-6 w-6 app-no-drag"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={onToggleTheme}
|
||||
disabled={isImmersiveActive}
|
||||
disabled={isImmersiveActive && !followAppTerminalTheme}
|
||||
title="Toggle theme"
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||
@@ -952,6 +967,8 @@ const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
|
||||
prev.isMacClient === next.isMacClient &&
|
||||
prev.onOpenSettings === next.onOpenSettings &&
|
||||
prev.onSyncNow === next.onSyncNow &&
|
||||
prev.onToggleTheme === next.onToggleTheme &&
|
||||
prev.followAppTerminalTheme === next.followAppTerminalTheme &&
|
||||
prev.isImmersiveActive === next.isImmersiveActive
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import { I18nProvider } from "../application/i18n/I18nProvider";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
|
||||
import { useActiveTabId } from "../application/state/activeTabStore";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { X, Maximize2, ChevronRight, ChevronDown, Power } from "lucide-react";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
|
||||
@@ -116,7 +117,7 @@ const TrayPanelContent: React.FC = () => {
|
||||
onTrayPanelMenuData,
|
||||
} = useTrayPanelBackend();
|
||||
|
||||
const { hosts, keys, identities } = useVaultState();
|
||||
const { hosts, keys, identities, groupConfigs } = useVaultState();
|
||||
useSessionState();
|
||||
const { rules: portForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
|
||||
const activeTabId = useActiveTabId();
|
||||
@@ -326,14 +327,17 @@ const TrayPanelContent: React.FC = () => {
|
||||
disabled={isConnecting}
|
||||
title={label}
|
||||
onClick={() => {
|
||||
const host = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
|
||||
if (!host) {
|
||||
const rawHost = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
|
||||
if (!rawHost) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
if (isActive) {
|
||||
void stopTunnel(rule.id);
|
||||
} else {
|
||||
const host = rawHost.group
|
||||
? applyGroupDefaults(rawHost, resolveGroupDefaults(rawHost.group, groupConfigs))
|
||||
: rawHost;
|
||||
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
@@ -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';
|
||||
}
|
||||
@@ -160,7 +169,8 @@ export const AgentIconBadge: React.FC<{
|
||||
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'
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -238,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
|
||||
@@ -249,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)}
|
||||
@@ -290,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))
|
||||
|
||||
@@ -14,6 +14,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { streamText, stepCountIs, type ModelMessage } from 'ai';
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
AISession,
|
||||
ChatMessage,
|
||||
ChatMessageAttachment,
|
||||
@@ -112,6 +113,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;
|
||||
}
|
||||
@@ -130,6 +138,10 @@ export interface TerminalSessionInfo {
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
export interface DefaultTargetSessionHint extends TerminalSessionInfo {
|
||||
source: 'scope-target' | 'only-connected-in-scope';
|
||||
}
|
||||
|
||||
/** Typed accessor for the netcatty bridge on the window object. */
|
||||
export function getNetcattyBridge(): PanelBridge | undefined {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -235,8 +247,10 @@ export interface SendToExternalContext {
|
||||
updateExternalSessionId?: (sessionId: string, externalSessionId: string | undefined) => void;
|
||||
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
defaultTargetSession?: DefaultTargetSessionHint;
|
||||
providers: ProviderConfig[];
|
||||
selectedAgentModel?: string;
|
||||
toolIntegrationMode: AIToolIntegrationMode;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -628,6 +642,8 @@ export function useAIChatStreaming({
|
||||
context.existingSessionId,
|
||||
context.historyMessages,
|
||||
attachedImages.length > 0 ? attachedImages : undefined,
|
||||
context.toolIntegrationMode,
|
||||
context.defaultTargetSession,
|
||||
);
|
||||
} else {
|
||||
// Fallback: spawn as raw process
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
* Host Chain Sub-Panel
|
||||
* Panel for configuring SSH jump host chain
|
||||
*/
|
||||
import { ArrowDown,Plus,X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { ArrowDown,Plus,Search,X } from 'lucide-react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Host } from '../../types';
|
||||
import { DistroAvatar } from '../DistroAvatar';
|
||||
import { AsidePanel } from '../ui/aside-panel';
|
||||
import { AsidePanel, type AsidePanelLayout } from '../ui/aside-panel';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card } from '../ui/card';
|
||||
import { Input } from '../ui/input';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
|
||||
export interface ChainPanelProps {
|
||||
@@ -23,6 +24,7 @@ export interface ChainPanelProps {
|
||||
onClearChain: () => void;
|
||||
onBack: () => void;
|
||||
onCancel: () => void;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
export const ChainPanel: React.FC<ChainPanelProps> = ({
|
||||
@@ -36,8 +38,17 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
|
||||
onClearChain,
|
||||
onBack,
|
||||
onCancel,
|
||||
layout = 'overlay',
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const filteredHosts = useMemo(() => {
|
||||
if (!searchQuery.trim()) return availableHostsForChain;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return availableHostsForChain.filter(
|
||||
(host) => host.label.toLowerCase().includes(q) || host.hostname.toLowerCase().includes(q)
|
||||
);
|
||||
}, [availableHostsForChain, searchQuery]);
|
||||
return (
|
||||
<AsidePanel
|
||||
open={true}
|
||||
@@ -45,6 +56,7 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
|
||||
title={t('hostDetails.chain.title')}
|
||||
showBackButton={true}
|
||||
onBack={onBack}
|
||||
layout={layout}
|
||||
actions={
|
||||
<Button size="sm" onClick={onBack}>
|
||||
{t('common.save')}
|
||||
@@ -52,16 +64,7 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
|
||||
}
|
||||
>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-4">
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('hostDetails.chain.desc', { host: formLabel || formHostname })}
|
||||
</p>
|
||||
<Button className="w-full h-10" onClick={() => { }}>
|
||||
<Plus size={14} className="mr-2" /> {t('hostDetails.chain.addHost')}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<div className="p-4 space-y-4 w-0 min-w-full">
|
||||
{/* Chain visualization */}
|
||||
<div className="space-y-2">
|
||||
{chainedHosts.map((host, index) => (
|
||||
@@ -73,7 +76,7 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
|
||||
)}
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg border border-border/60 bg-card">
|
||||
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-8 w-8" />
|
||||
<span className="text-sm font-medium flex-1">{host.label || host.hostname}</span>
|
||||
<span className="text-sm font-medium flex-1 min-w-0 truncate">{host.label || host.hostname}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -110,11 +113,20 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
|
||||
{availableHostsForChain.length > 0 && (
|
||||
<Card className="p-3 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold text-muted-foreground mb-2">{t('hostDetails.chain.availableHosts')}</p>
|
||||
<div className="relative mb-2">
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('common.searchPlaceholder')}
|
||||
className="h-8 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{availableHostsForChain.map((host) => (
|
||||
{filteredHosts.map((host) => (
|
||||
<button
|
||||
key={host.id}
|
||||
className="w-full flex items-center gap-2 p-2 rounded-md hover:bg-secondary transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 p-2 rounded-md hover:bg-secondary transition-colors text-left overflow-hidden"
|
||||
onClick={() => onAddHost(host.id)}
|
||||
>
|
||||
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-8 w-8" />
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { FolderPlus,HelpCircle,Plus } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { AsidePanel,AsidePanelContent } from '../ui/aside-panel';
|
||||
import { AsidePanel,AsidePanelContent,type AsidePanelLayout } from '../ui/aside-panel';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card } from '../ui/card';
|
||||
import { Input } from '../ui/input';
|
||||
@@ -42,6 +42,7 @@ export interface CreateGroupPanelProps {
|
||||
onSave: () => void;
|
||||
onBack: () => void;
|
||||
onCancel: () => void;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
export const CreateGroupPanel: React.FC<CreateGroupPanelProps> = ({
|
||||
@@ -53,6 +54,7 @@ export const CreateGroupPanel: React.FC<CreateGroupPanelProps> = ({
|
||||
onSave,
|
||||
onBack,
|
||||
onCancel,
|
||||
layout = 'overlay',
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
@@ -62,6 +64,7 @@ export const CreateGroupPanel: React.FC<CreateGroupPanelProps> = ({
|
||||
title={t('hostDetails.group.title')}
|
||||
showBackButton={true}
|
||||
onBack={onBack}
|
||||
layout={layout}
|
||||
actions={
|
||||
<Button size="sm" onClick={onSave} disabled={!newGroupName.trim()}>
|
||||
{t('common.save')}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Plus,X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { EnvVar } from '../../types';
|
||||
import { AsidePanel,AsidePanelContent } from '../ui/aside-panel';
|
||||
import { AsidePanel,AsidePanelContent,type AsidePanelLayout } from '../ui/aside-panel';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card } from '../ui/card';
|
||||
import { Input } from '../ui/input';
|
||||
@@ -25,6 +25,7 @@ export interface EnvVarsPanelProps {
|
||||
onSave: () => void;
|
||||
onBack: () => void;
|
||||
onCancel: () => void;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
export const EnvVarsPanel: React.FC<EnvVarsPanelProps> = ({
|
||||
@@ -41,6 +42,7 @@ export const EnvVarsPanel: React.FC<EnvVarsPanelProps> = ({
|
||||
onSave,
|
||||
onBack,
|
||||
onCancel,
|
||||
layout = 'overlay',
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
@@ -50,6 +52,7 @@ export const EnvVarsPanel: React.FC<EnvVarsPanelProps> = ({
|
||||
title={t('hostDetails.envVars.title')}
|
||||
showBackButton={true}
|
||||
onBack={onBack}
|
||||
layout={layout}
|
||||
actions={
|
||||
<Button size="sm" onClick={onSave}>
|
||||
{t('common.save')}
|
||||
|
||||
@@ -7,7 +7,7 @@ import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ProxyConfig } from '../../types';
|
||||
import { AsidePanel,AsidePanelContent } from '../ui/aside-panel';
|
||||
import { AsidePanel,AsidePanelContent,type AsidePanelLayout } from '../ui/aside-panel';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card } from '../ui/card';
|
||||
@@ -19,6 +19,7 @@ export interface ProxyPanelProps {
|
||||
onClearProxy: () => void;
|
||||
onBack: () => void;
|
||||
onCancel: () => void;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
export const ProxyPanel: React.FC<ProxyPanelProps> = ({
|
||||
@@ -27,6 +28,7 @@ export const ProxyPanel: React.FC<ProxyPanelProps> = ({
|
||||
onClearProxy,
|
||||
onBack,
|
||||
onCancel,
|
||||
layout = 'overlay',
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
@@ -36,6 +38,7 @@ export const ProxyPanel: React.FC<ProxyPanelProps> = ({
|
||||
title={t('hostDetails.proxyPanel.title')}
|
||||
showBackButton={true}
|
||||
onBack={onBack}
|
||||
layout={layout}
|
||||
actions={
|
||||
<Button size="sm" onClick={onBack} disabled={!proxyConfig?.host}>
|
||||
{t('common.save')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,55 +3,12 @@
|
||||
* A modal dialog for selecting terminal themes in settings
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useMemo } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Check, Palette, X } from 'lucide-react';
|
||||
import { Palette, X } from 'lucide-react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../../application/state/customThemeStore';
|
||||
import { Button } from '../ui/button';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
// Memoized theme item component to prevent unnecessary re-renders
|
||||
const ThemeItem = memo(({
|
||||
theme,
|
||||
isSelected,
|
||||
onSelect
|
||||
}: {
|
||||
theme: TerminalThemeConfig;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) => (
|
||||
<button
|
||||
onClick={() => onSelect(theme.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all',
|
||||
isSelected
|
||||
? 'bg-primary/15 ring-1 ring-primary'
|
||||
: 'hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
{/* Color swatch preview */}
|
||||
<div
|
||||
className="w-12 h-8 rounded-md flex-shrink-0 flex flex-col justify-center items-start pl-1.5 gap-0.5 border border-border/50"
|
||||
style={{ backgroundColor: theme.colors.background }}
|
||||
>
|
||||
<div className="h-1 w-4 rounded-full" style={{ backgroundColor: theme.colors.green }} />
|
||||
<div className="h-1 w-6 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
|
||||
<div className="h-1 w-3 rounded-full" style={{ backgroundColor: theme.colors.yellow }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn('text-sm font-medium truncate', isSelected ? 'text-primary' : 'text-foreground')}>
|
||||
{theme.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground capitalize">{theme.type}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check size={16} className="text-primary flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
));
|
||||
ThemeItem.displayName = 'ThemeItem';
|
||||
import { ThemeList } from '../ThemeList';
|
||||
|
||||
interface ThemeSelectModalProps {
|
||||
open: boolean;
|
||||
@@ -68,15 +25,6 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
// Group themes by type
|
||||
const { darkThemes, lightThemes } = useMemo(() => {
|
||||
const dark = TERMINAL_THEMES.filter(t => t.type === 'dark');
|
||||
const light = TERMINAL_THEMES.filter(t => t.type === 'light');
|
||||
return { darkThemes: dark, lightThemes: light };
|
||||
}, []);
|
||||
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
// Handle theme selection - select and close
|
||||
const handleThemeSelect = useCallback((themeId: string) => {
|
||||
onSelect(themeId);
|
||||
@@ -134,58 +82,10 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
|
||||
|
||||
{/* Theme List */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-4">
|
||||
{/* Dark Themes Section */}
|
||||
<div className="mb-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
|
||||
{t('settings.terminal.themeModal.darkThemes')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{darkThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Light Themes Section */}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
|
||||
{t('settings.terminal.themeModal.lightThemes')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{lightThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Themes Section */}
|
||||
{customThemes.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
|
||||
{t('terminal.customTheme.section')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{customThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ThemeList
|
||||
selectedThemeId={selectedThemeId}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -8,16 +8,21 @@
|
||||
* - 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,
|
||||
AIToolIntegrationMode,
|
||||
ExternalAgentConfig,
|
||||
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 +43,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";
|
||||
|
||||
@@ -56,6 +62,8 @@ interface SettingsAITabProps {
|
||||
setActiveModelId: (id: string) => void;
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
setGlobalPermissionMode: (mode: AIPermissionMode) => void;
|
||||
toolIntegrationMode: AIToolIntegrationMode;
|
||||
setToolIntegrationMode: (mode: AIToolIntegrationMode) => void;
|
||||
externalAgents: ExternalAgentConfig[];
|
||||
setExternalAgents: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
|
||||
defaultAgentId: string;
|
||||
@@ -70,6 +78,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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -85,6 +141,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setActiveModelId,
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
toolIntegrationMode,
|
||||
setToolIntegrationMode,
|
||||
externalAgents,
|
||||
setExternalAgents,
|
||||
defaultAgentId,
|
||||
@@ -113,58 +171,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 +218,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 +517,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
|
||||
<CodexConnectionCard
|
||||
pathInfo={codexPathInfo}
|
||||
isResolvingPath={isDiscovering || isResolvingCodex}
|
||||
isResolvingPath={isResolvingCodex}
|
||||
customPath={codexCustomPath}
|
||||
onCustomPathChange={setCodexCustomPath}
|
||||
onRecheckPath={() => void handleCheckCustomPath("codex")}
|
||||
@@ -483,13 +543,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,13 +583,37 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
value={defaultAgentId}
|
||||
options={agentOptions}
|
||||
onChange={setDefaultAgentId}
|
||||
className="w-48"
|
||||
className="w-64"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t('ai.toolAccess.title')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4">
|
||||
<SettingRow
|
||||
label={t('ai.toolAccess.mode')}
|
||||
description={t('ai.toolAccess.description')}
|
||||
>
|
||||
<Select
|
||||
value={toolIntegrationMode}
|
||||
options={[
|
||||
{ value: 'mcp', label: t('ai.toolAccess.mode.mcp') },
|
||||
{ value: 'skills', label: t('ai.toolAccess.mode.skills') },
|
||||
]}
|
||||
onChange={(value) => setToolIntegrationMode(value as AIToolIntegrationMode)}
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* -- Web Search Section -- */}
|
||||
<WebSearchSettings
|
||||
webSearchConfig={webSearchConfig}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { SUPPORTED_UI_LOCALES } from "../../../infrastructure/config/i18n";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { SectionHeader, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui";
|
||||
import { FontSelect } from "../FontSelect";
|
||||
import { STORAGE_KEY_SHOW_RECENT_HOSTS } from "../../../infrastructure/config/storageKeys";
|
||||
import { useStoredBoolean } from "../../../application/state/useStoredBoolean";
|
||||
|
||||
export default function SettingsAppearanceTab(props: {
|
||||
theme: "dark" | "light" | "system";
|
||||
@@ -25,8 +27,6 @@ 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();
|
||||
@@ -47,10 +47,13 @@ export default function SettingsAppearanceTab(props: {
|
||||
setUiLanguage,
|
||||
customCSS,
|
||||
setCustomCSS,
|
||||
isImmersive,
|
||||
onToggleImmersive,
|
||||
} = props;
|
||||
|
||||
const [showRecentHosts, setShowRecentHosts] = useStoredBoolean(
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
true,
|
||||
);
|
||||
|
||||
const getHslStyle = useCallback((hsl: string) => ({ backgroundColor: `hsl(${hsl})` }), []);
|
||||
|
||||
const hexToHsl = useCallback((hex: string) => {
|
||||
@@ -258,16 +261,13 @@ export default function SettingsAppearanceTab(props: {
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.appearance.immersiveMode")} />
|
||||
<SectionHeader title={t("settings.vault.title")} />
|
||||
<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")}
|
||||
label={t('settings.vault.showRecentHosts')}
|
||||
description={t('settings.vault.showRecentHostsDesc')}
|
||||
>
|
||||
<Toggle
|
||||
checked={!!isImmersive}
|
||||
onChange={() => onToggleImmersive?.()}
|
||||
/>
|
||||
<Toggle checked={showRecentHosts} onChange={setShowRecentHosts} />
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,14 +7,16 @@ import type {
|
||||
TerminalEmulationType,
|
||||
TerminalSettings,
|
||||
} from "../../../domain/models";
|
||||
import { DEFAULT_KEYWORD_HIGHLIGHT_RULES } from "../../../domain/models";
|
||||
import { DEFAULT_KEYWORD_HIGHLIGHT_RULES, type KeywordHighlightRule } from "../../../domain/models";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { MAX_FONT_SIZE, MIN_FONT_SIZE, type TerminalFont } from "../../../infrastructure/config/fonts";
|
||||
import { TERMINAL_THEMES } from "../../../infrastructure/config/terminalThemes";
|
||||
import { customThemeStore, useCustomThemes } from "../../../application/state/customThemeStore";
|
||||
import { parseItermcolors } from "../../../infrastructure/parsers/itermcolorsParser";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { useDiscoveredShells } from "../../../lib/useDiscoveredShells";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../../ui/dialog";
|
||||
import { Input } from "../../ui/input";
|
||||
import { Label } from "../../ui/label";
|
||||
import { SectionHeader, Select, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
|
||||
@@ -23,6 +25,193 @@ import { TerminalFontSelect } from "../TerminalFontSelect";
|
||||
import { CustomThemeModal } from "../../terminal/CustomThemeModal";
|
||||
import type { TerminalTheme } from "../../../domain/models";
|
||||
|
||||
// Keyword highlight rules editor for global settings
|
||||
const DEFAULT_NEW_RULE_COLOR = '#F87171';
|
||||
|
||||
const AddCustomRuleDialog: React.FC<{
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editRule?: KeywordHighlightRule | null;
|
||||
onAdd: (rule: KeywordHighlightRule) => void;
|
||||
}> = ({ open, onOpenChange, editRule, onAdd }) => {
|
||||
const { t } = useI18n();
|
||||
const [label, setLabel] = useState('');
|
||||
const [pattern, setPattern] = useState('');
|
||||
const [color, setColor] = useState(DEFAULT_NEW_RULE_COLOR);
|
||||
const [patternError, setPatternError] = useState<string | null>(null);
|
||||
|
||||
const reset = () => { setLabel(''); setPattern(''); setColor(DEFAULT_NEW_RULE_COLOR); setPatternError(null); };
|
||||
|
||||
// Populate form when editing
|
||||
useEffect(() => {
|
||||
if (open && editRule) {
|
||||
setLabel(editRule.label);
|
||||
setPattern(editRule.patterns[0] || '');
|
||||
setColor(editRule.color);
|
||||
setPatternError(null);
|
||||
} else if (!open) {
|
||||
reset();
|
||||
}
|
||||
}, [open, editRule]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!label.trim() || !pattern.trim()) return;
|
||||
try { new RegExp(pattern, 'gi'); } catch {
|
||||
setPatternError(t('settings.terminal.keywordHighlight.invalidPattern'));
|
||||
return;
|
||||
}
|
||||
// When editing, replace only the first pattern and keep any additional ones
|
||||
const patterns = editRule
|
||||
? [pattern, ...editRule.patterns.slice(1)]
|
||||
: [pattern];
|
||||
onAdd({ id: editRule?.id ?? crypto.randomUUID(), label: label.trim(), patterns, color, enabled: editRule?.enabled ?? true });
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) reset(); onOpenChange(v); }}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editRule ? t('settings.terminal.keywordHighlight.editCustom') : t('settings.terminal.keywordHighlight.addCustom')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t('settings.terminal.keywordHighlight.labelField')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('settings.terminal.keywordHighlight.labelPlaceholder')}
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<label className="relative flex-shrink-0">
|
||||
<input type="color" value={color} onChange={(e) => setColor(e.target.value)} className="sr-only" />
|
||||
<span className="block w-9 h-9 rounded-md cursor-pointer border border-border/50 hover:border-border" style={{ backgroundColor: color }} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t('settings.terminal.keywordHighlight.patternField')}</Label>
|
||||
<Input
|
||||
placeholder={t('settings.terminal.keywordHighlight.patternPlaceholder')}
|
||||
value={pattern}
|
||||
onChange={(e) => { setPattern(e.target.value); if (patternError) setPatternError(null); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }}
|
||||
className={cn("font-mono", patternError && "border-destructive")}
|
||||
/>
|
||||
{patternError && <div className="text-xs text-destructive">{patternError}</div>}
|
||||
</div>
|
||||
{label.trim() && pattern.trim() && !patternError && (
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-muted/50">
|
||||
<span className="text-xs text-muted-foreground">{t('settings.terminal.keywordHighlight.preview')}:</span>
|
||||
<span className="text-sm font-medium" style={{ color }}>{label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { reset(); onOpenChange(false); }}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSubmit} disabled={!label.trim() || !pattern.trim()}>{editRule ? t('common.save') : t('common.add')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const KeywordHighlightRulesEditor: React.FC<{
|
||||
rules: KeywordHighlightRule[];
|
||||
onChange: (rules: KeywordHighlightRule[]) => void;
|
||||
}> = ({ rules, onChange }) => {
|
||||
const { t } = useI18n();
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<KeywordHighlightRule | null>(null);
|
||||
|
||||
const isBuiltIn = (id: string) => DEFAULT_KEYWORD_HIGHLIGHT_RULES.some((r) => r.id === id);
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
{rules.map((rule) => {
|
||||
const custom = !isBuiltIn(rule.id);
|
||||
return (
|
||||
<div key={rule.id} className="flex items-center gap-2 group">
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1.5">
|
||||
<span className={cn("text-sm truncate", !rule.enabled && "text-muted-foreground line-through")} style={rule.enabled ? { color: rule.color } : undefined}>
|
||||
{rule.label}
|
||||
</span>
|
||||
{custom && (
|
||||
<>
|
||||
<Pencil
|
||||
size={10}
|
||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer"
|
||||
onClick={() => { setEditingRule(rule); setAddDialogOpen(true); }}
|
||||
/>
|
||||
<Trash2
|
||||
size={10}
|
||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer"
|
||||
onClick={() => onChange(rules.filter((r) => r.id !== rule.id))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<label className="relative flex-shrink-0">
|
||||
<input
|
||||
type="color"
|
||||
value={rule.color}
|
||||
onChange={(e) => onChange(rules.map((r) => r.id === rule.id ? { ...r, color: e.target.value } : r))}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span
|
||||
className="block w-8 h-5 rounded cursor-pointer border border-border/50 hover:border-border transition-colors"
|
||||
style={{ backgroundColor: rule.color }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex pt-2 mt-2 border-t border-border/50">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setAddDialogOpen(true)}
|
||||
>
|
||||
<Plus size={14} className="mr-1.5" />
|
||||
{t('settings.terminal.keywordHighlight.addCustom')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
onChange(rules.map((rule) => {
|
||||
const def = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((r) => r.id === rule.id);
|
||||
return def ? { ...rule, color: def.color } : rule;
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={14} className="mr-1.5" />
|
||||
{t("settings.terminal.keywordHighlight.resetColors")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AddCustomRuleDialog
|
||||
open={addDialogOpen}
|
||||
onOpenChange={(v) => { setAddDialogOpen(v); if (!v) setEditingRule(null); }}
|
||||
editRule={editingRule}
|
||||
onAdd={(rule) => {
|
||||
if (editingRule) {
|
||||
onChange(rules.map((r) => r.id === editingRule.id ? rule : r));
|
||||
} else {
|
||||
onChange([...rules, rule]);
|
||||
}
|
||||
setEditingRule(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Theme preview button component
|
||||
const ThemePreviewButton: React.FC<{
|
||||
theme: (typeof TERMINAL_THEMES)[0];
|
||||
@@ -74,6 +263,8 @@ const ThemePreviewButton: React.FC<{
|
||||
export default function SettingsTerminalTab(props: {
|
||||
terminalThemeId: string;
|
||||
setTerminalThemeId: (id: string) => void;
|
||||
followAppTerminalTheme: boolean;
|
||||
setFollowAppTerminalTheme: (value: boolean) => void;
|
||||
terminalFontFamilyId: string;
|
||||
setTerminalFontFamilyId: (id: string) => void;
|
||||
terminalFontSize: number;
|
||||
@@ -90,6 +281,8 @@ export default function SettingsTerminalTab(props: {
|
||||
const {
|
||||
terminalThemeId,
|
||||
setTerminalThemeId,
|
||||
followAppTerminalTheme,
|
||||
setFollowAppTerminalTheme,
|
||||
terminalFontFamilyId,
|
||||
setTerminalFontFamilyId,
|
||||
terminalFontSize,
|
||||
@@ -106,6 +299,20 @@ export default function SettingsTerminalTab(props: {
|
||||
const [defaultShell, setDefaultShell] = useState<string>("");
|
||||
const [shellValidation, setShellValidation] = useState<{ valid: boolean; message?: string } | null>(null);
|
||||
const [dirValidation, setDirValidation] = useState<{ valid: boolean; message?: string } | null>(null);
|
||||
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
const [showCustomShellInput, setShowCustomShellInput] = useState(() => {
|
||||
if (!terminalSettings.localShell) return false;
|
||||
return !discoveredShells.some(s => s.id === terminalSettings.localShell);
|
||||
});
|
||||
const [customShellModalOpen, setCustomShellModalOpen] = useState(false);
|
||||
const [customShellDraft, setCustomShellDraft] = useState("");
|
||||
|
||||
// Update showCustomShellInput once discovered shells load
|
||||
useEffect(() => {
|
||||
if (!terminalSettings.localShell) return;
|
||||
setShowCustomShellInput(!discoveredShells.some(s => s.id === terminalSettings.localShell));
|
||||
}, [discoveredShells, terminalSettings.localShell]);
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false);
|
||||
|
||||
// Subscribe to custom theme changes so editing in-place triggers re-render
|
||||
@@ -210,7 +417,7 @@ export default function SettingsTerminalTab(props: {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Validate shell path when it changes
|
||||
// Validate shell path when it changes (only for custom paths, not discovered shell ids)
|
||||
useEffect(() => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
const shellPath = terminalSettings.localShell;
|
||||
@@ -220,6 +427,12 @@ export default function SettingsTerminalTab(props: {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip validation for discovered shell ids — only validate custom paths
|
||||
if (discoveredShells.some(s => s.id === shellPath)) {
|
||||
setShellValidation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bridge?.validatePath) {
|
||||
setShellValidation(null);
|
||||
return;
|
||||
@@ -240,7 +453,7 @@ export default function SettingsTerminalTab(props: {
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [terminalSettings.localShell, t]);
|
||||
}, [terminalSettings.localShell, discoveredShells, t]);
|
||||
|
||||
// Validate directory path when it changes
|
||||
useEffect(() => {
|
||||
@@ -282,11 +495,24 @@ export default function SettingsTerminalTab(props: {
|
||||
return (
|
||||
<SettingsTabContent value="terminal">
|
||||
<SectionHeader title={t("settings.terminal.section.theme")} />
|
||||
<ThemePreviewButton
|
||||
theme={currentTheme}
|
||||
onClick={() => setThemeModalOpen(true)}
|
||||
buttonLabel={t("settings.terminal.theme.selectButton")}
|
||||
/>
|
||||
<div className="rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
label={t("settings.terminal.theme.followApp")}
|
||||
description={t("settings.terminal.theme.followApp.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={followAppTerminalTheme}
|
||||
onChange={setFollowAppTerminalTheme}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
{!followAppTerminalTheme && (
|
||||
<ThemePreviewButton
|
||||
theme={currentTheme}
|
||||
onClick={() => setThemeModalOpen(true)}
|
||||
buttonLabel={t("settings.terminal.theme.selectButton")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ThemeSelectModal
|
||||
open={themeModalOpen}
|
||||
@@ -694,47 +920,10 @@ export default function SettingsTerminalTab(props: {
|
||||
/>
|
||||
</div>
|
||||
{terminalSettings.keywordHighlightEnabled && (
|
||||
<div className="space-y-2.5">
|
||||
{terminalSettings.keywordHighlightRules.map((rule) => (
|
||||
<div key={rule.id} className="flex items-center justify-between">
|
||||
<span className="text-sm" style={{ color: rule.color }}>
|
||||
{rule.label}
|
||||
</span>
|
||||
<label className="relative">
|
||||
<input
|
||||
type="color"
|
||||
value={rule.color}
|
||||
onChange={(e) => {
|
||||
const newRules = terminalSettings.keywordHighlightRules.map((r) =>
|
||||
r.id === rule.id ? { ...r, color: e.target.value } : r,
|
||||
);
|
||||
updateTerminalSetting("keywordHighlightRules", newRules);
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span
|
||||
className="block w-10 h-6 rounded-md cursor-pointer border border-border/50 hover:border-border transition-colors"
|
||||
style={{ backgroundColor: rule.color }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full mt-3 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
const resetRules = terminalSettings.keywordHighlightRules.map((rule) => {
|
||||
const defaultRule = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((r) => r.id === rule.id);
|
||||
return defaultRule ? { ...rule, color: defaultRule.color } : rule;
|
||||
});
|
||||
updateTerminalSetting("keywordHighlightRules", resetRules);
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={14} className="mr-2" />
|
||||
{t("settings.terminal.keywordHighlight.resetColors")}
|
||||
</Button>
|
||||
</div>
|
||||
<KeywordHighlightRulesEditor
|
||||
rules={terminalSettings.keywordHighlightRules}
|
||||
onChange={(rules) => updateTerminalSetting("keywordHighlightRules", rules)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -745,24 +934,43 @@ export default function SettingsTerminalTab(props: {
|
||||
description={t("settings.terminal.localShell.shell.desc")}
|
||||
>
|
||||
<div className="flex flex-col gap-1 items-end">
|
||||
<Input
|
||||
value={terminalSettings.localShell}
|
||||
placeholder={t("settings.terminal.localShell.shell.placeholder")}
|
||||
onChange={(e) => updateTerminalSetting("localShell", e.target.value)}
|
||||
className={cn(
|
||||
"w-48",
|
||||
shellValidation && !shellValidation.valid && "border-destructive focus-visible:ring-destructive"
|
||||
)}
|
||||
/>
|
||||
{defaultShell && !terminalSettings.localShell && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("settings.terminal.localShell.shell.detected")}: {defaultShell}
|
||||
<select
|
||||
className="h-9 w-48 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={
|
||||
showCustomShellInput
|
||||
? "__custom__"
|
||||
: terminalSettings.localShell || ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "__custom__") {
|
||||
setCustomShellDraft(terminalSettings.localShell || "");
|
||||
setCustomShellModalOpen(true);
|
||||
} else {
|
||||
setShowCustomShellInput(false);
|
||||
updateTerminalSetting("localShell", value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">
|
||||
{t("settings.terminal.localShell.shell.default")}
|
||||
{defaultShell ? ` (${defaultShell.split(/[/\\]/).pop()})` : ""}
|
||||
</option>
|
||||
{discoveredShells.map((shell) => (
|
||||
<option key={shell.id} value={shell.id}>
|
||||
{shell.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="__custom__">{t("settings.terminal.localShell.shell.custom")}</option>
|
||||
</select>
|
||||
{showCustomShellInput && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-48">
|
||||
{terminalSettings.localShell}
|
||||
</span>
|
||||
)}
|
||||
{shellValidation && !shellValidation.valid && shellValidation.message && (
|
||||
<span className="text-xs text-destructive flex items-center gap-1">
|
||||
<AlertCircle size={12} />
|
||||
{shellValidation.message}
|
||||
{!showCustomShellInput && defaultShell && !terminalSettings.localShell && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("settings.terminal.localShell.shell.detected")}: {defaultShell}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -862,9 +1070,9 @@ export default function SettingsTerminalTab(props: {
|
||||
options={[
|
||||
{ value: "auto", label: t("settings.terminal.rendering.auto") },
|
||||
{ value: "webgl", label: "WebGL" },
|
||||
{ value: "canvas", label: "Canvas" },
|
||||
{ value: "dom", label: "DOM" },
|
||||
]}
|
||||
onChange={(v) => updateTerminalSetting("rendererType", v as "auto" | "webgl" | "canvas")}
|
||||
onChange={(v) => updateTerminalSetting("rendererType", v as "auto" | "webgl" | "dom")}
|
||||
className="w-32"
|
||||
/>
|
||||
</SettingRow>
|
||||
@@ -919,6 +1127,73 @@ export default function SettingsTerminalTab(props: {
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
{/* Custom Shell Modal */}
|
||||
<Dialog open={customShellModalOpen} onOpenChange={setCustomShellModalOpen}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("settings.terminal.localShell.shell.custom")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t("settings.terminal.localShell.shell.customPath")}</label>
|
||||
<Input
|
||||
value={customShellDraft}
|
||||
placeholder={t("settings.terminal.localShell.shell.placeholder")}
|
||||
onChange={(e) => setCustomShellDraft(e.target.value)}
|
||||
className="w-full"
|
||||
autoFocus
|
||||
/>
|
||||
{shellValidation && !shellValidation.valid && shellValidation.message && (
|
||||
<span className="text-xs text-destructive flex items-center gap-1">
|
||||
<AlertCircle size={12} />
|
||||
{shellValidation.message}
|
||||
</span>
|
||||
)}
|
||||
{shellValidation?.valid && (
|
||||
<span className="text-xs text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
|
||||
✓ {t("settings.terminal.localShell.shell.pathValid")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">{t("settings.terminal.localShell.shell.commonPaths")}</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{["/bin/bash", "/bin/zsh", "/usr/bin/fish", "/bin/sh", "powershell.exe", "pwsh.exe", "cmd.exe"].map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => setCustomShellDraft(p)}
|
||||
className="text-xs px-2 py-1 rounded-md border border-border bg-muted/50 hover:bg-muted transition-colors"
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomShellModalOpen(false)}
|
||||
className="px-3 py-1.5 text-sm rounded-md border border-border hover:bg-muted transition-colors"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
updateTerminalSetting("localShell", customShellDraft);
|
||||
setShowCustomShellInput(true);
|
||||
setCustomShellModalOpen(false);
|
||||
}}
|
||||
disabled={!customShellDraft.trim()}
|
||||
className="px-3 py-1.5 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -82,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"],
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -108,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",
|
||||
@@ -124,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",
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface SftpTransferSource {
|
||||
export interface SftpPaneCallbacks {
|
||||
onConnect: (host: Host | "local") => void;
|
||||
onDisconnect: () => void;
|
||||
onPrepareSelection: () => void;
|
||||
onNavigateTo: (path: string) => void;
|
||||
onNavigateUp: () => void;
|
||||
onRefresh: () => void;
|
||||
|
||||
@@ -76,8 +76,10 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
onClick={handleSelect}
|
||||
onDoubleClick={handleOpen}
|
||||
className={cn(
|
||||
"px-4 py-2 items-center cursor-pointer text-sm hover:bg-accent/50",
|
||||
isSelectionVisible && "bg-accent text-accent-foreground",
|
||||
"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: buildSftpColumnTemplate(columnWidths) }}
|
||||
@@ -130,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;
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import type { Host, SftpFileEntry } from "../../types";
|
||||
import type { FileOpenerType, SystemAppInfo } from "../../lib/sftpFileUtils";
|
||||
import type { useSftpState } from "../../application/state/useSftpState";
|
||||
import type { HotkeyScheme, KeyBinding } from "../../domain/models";
|
||||
import FileOpenerDialog from "../FileOpenerDialog";
|
||||
import TextEditorModal from "../TextEditorModal";
|
||||
import { SftpConflictDialog, SftpHostPicker, SftpPermissionsDialog } from "./index";
|
||||
@@ -35,6 +36,8 @@ interface SftpOverlaysProps {
|
||||
handleSaveTextFile: (content: string) => Promise<void>;
|
||||
editorWordWrap: boolean;
|
||||
setEditorWordWrap: (enabled: boolean) => void;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
showFileOpenerDialog: boolean;
|
||||
setShowFileOpenerDialog: (open: boolean) => void;
|
||||
fileOpenerTarget: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
|
||||
@@ -69,6 +72,8 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
handleSaveTextFile,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
@@ -139,6 +144,8 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
onSave={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
/>
|
||||
|
||||
{/* File Opener Dialog */}
|
||||
|
||||
@@ -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> = ({
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
@@ -44,7 +44,7 @@ import { getParentPath, joinPath } from '../../application/state/sftp/utils';
|
||||
import { buildSftpColumnTemplate, filterHiddenFiles, formatBytes, formatDate, getFileIcon, isNavigableDirectory, sortSftpEntries, type ColumnWidths, type SortField, type SortOrder } from './utils';
|
||||
import type { SftpTransferSource } from './SftpContext';
|
||||
import { sftpTreeSelectionStore, useSftpTreeSelectionState } from './hooks/useSftpTreeSelectionStore';
|
||||
import { sftpTreeEnterStore } from './hooks/useSftpKeyboardShortcuts';
|
||||
import { sftpKeyboardSelectionStore, sftpTreeEnterStore } from './hooks/useSftpKeyboardShortcuts';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { isKnownBinaryFile } from '../../lib/sftpFileUtils';
|
||||
|
||||
@@ -55,6 +55,7 @@ type NodeDescriptor =
|
||||
interface SftpPaneTreeViewProps {
|
||||
pane: SftpPane;
|
||||
side: 'left' | 'right';
|
||||
onPrepareSelection: () => void;
|
||||
onLoadChildren: (path: string) => Promise<SftpFileEntry[]>;
|
||||
onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise<void>;
|
||||
onNavigateUp: () => void;
|
||||
@@ -126,8 +127,10 @@ const TreeNode = React.memo<TreeNodeProps>(({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid items-center gap-x-1 px-2 cursor-pointer select-none hover:bg-accent/50 text-sm',
|
||||
isSelected && 'bg-accent text-accent-foreground',
|
||||
'grid items-center gap-x-1 px-2 cursor-pointer select-none text-sm',
|
||||
isSelected
|
||||
? 'bg-accent text-accent-foreground hover:bg-accent'
|
||||
: 'hover:bg-accent/50',
|
||||
isDragOver && 'ring-2 ring-primary/50 ring-inset bg-primary/10',
|
||||
)}
|
||||
style={{ gridTemplateColumns: columnTemplate, height: TREE_ROW_HEIGHT }}
|
||||
@@ -257,6 +260,7 @@ interface ContextTarget {
|
||||
export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
pane,
|
||||
side,
|
||||
onPrepareSelection,
|
||||
onLoadChildren,
|
||||
onMoveEntriesToPath,
|
||||
onNavigateUp,
|
||||
@@ -368,12 +372,21 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
const [rootEntries, setRootEntries] = useState<SftpFileEntry[]>(pane.files ?? []);
|
||||
const [resolvedRootPath, setResolvedRootPath] = useState(pane.connection?.currentPath ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPaths.size === 0) {
|
||||
lastClickedPathRef.current = null;
|
||||
sftpKeyboardSelectionStore.clear(pane.id);
|
||||
}
|
||||
}, [pane.id, selectedPaths.size]);
|
||||
|
||||
const onOpenEntryRef = useRef(onOpenEntry);
|
||||
onOpenEntryRef.current = onOpenEntry;
|
||||
const onNavigateUpRef = useRef(onNavigateUp);
|
||||
onNavigateUpRef.current = onNavigateUp;
|
||||
const onNavigateToRef = useRef(onNavigateTo);
|
||||
onNavigateToRef.current = onNavigateTo;
|
||||
const onPrepareSelectionRef = useRef(onPrepareSelection);
|
||||
onPrepareSelectionRef.current = onPrepareSelection;
|
||||
const onMoveEntriesToPathRef = useRef(onMoveEntriesToPath);
|
||||
onMoveEntriesToPathRef.current = onMoveEntriesToPath;
|
||||
const onDragStartRef = useRef(onDragStart);
|
||||
@@ -508,6 +521,7 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
invalidateTreeCache();
|
||||
dispatchTreePaths({ type: 'RESET' });
|
||||
sftpTreeSelectionStore.clearSelection(pane.id);
|
||||
sftpKeyboardSelectionStore.clear(pane.id);
|
||||
lastClickedPathRef.current = null;
|
||||
}
|
||||
}, [pane.connection?.currentPath, pane.connection?.id, pane.id, invalidateTreeCache]);
|
||||
@@ -556,11 +570,11 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
focusTreeContainer();
|
||||
|
||||
const state = treeSelectionStateRef.current;
|
||||
const currentIdx = state.visibleIndexByPath.get(entryPath) ?? -1;
|
||||
const nextSelection: string[] = (() => {
|
||||
if (e.shiftKey && lastClickedPathRef.current) {
|
||||
const items = state.visibleItems;
|
||||
const lastIdx = state.visibleIndexByPath.get(lastClickedPathRef.current) ?? -1;
|
||||
const currentIdx = state.visibleIndexByPath.get(entryPath) ?? -1;
|
||||
if (lastIdx !== -1 && currentIdx !== -1) {
|
||||
const parentPath = getParentPath(entryPath);
|
||||
const start = Math.min(lastIdx, currentIdx);
|
||||
@@ -582,7 +596,16 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
return [entryPath];
|
||||
})();
|
||||
|
||||
onPrepareSelectionRef.current();
|
||||
sftpTreeSelectionStore.setSelection(pane.id, nextSelection);
|
||||
if (currentIdx !== -1) {
|
||||
if (e.shiftKey && lastClickedPathRef.current) {
|
||||
const anchorIdx = state.visibleIndexByPath.get(lastClickedPathRef.current) ?? currentIdx;
|
||||
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, currentIdx);
|
||||
} else {
|
||||
sftpKeyboardSelectionStore.set(pane.id, currentIdx, currentIdx);
|
||||
}
|
||||
}
|
||||
|
||||
lastClickedPathRef.current = entryPath;
|
||||
}, [focusTreeContainer, pane.id]);
|
||||
@@ -610,23 +633,33 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
|
||||
const delta = e.key === 'ArrowDown' ? 1 : -1;
|
||||
const currentSelected = [...selectedPathsRef.current];
|
||||
let currentIdx = -1;
|
||||
if (currentSelected.length === 1) {
|
||||
currentIdx = state.visibleIndexByPath.get(currentSelected[0]) ?? -1;
|
||||
let { anchor: anchorIdx, focus: focusIdx } = sftpKeyboardSelectionStore.get(pane.id);
|
||||
if (currentSelected.length === 0) {
|
||||
anchorIdx = e.shiftKey ? 0 : -1;
|
||||
focusIdx = -1;
|
||||
} else {
|
||||
const focusPath = items[focusIdx]?.path;
|
||||
if (!focusPath || !state.selectedPaths.has(focusPath)) {
|
||||
focusIdx = state.visibleIndexByPath.get(currentSelected[currentSelected.length - 1]) ?? 0;
|
||||
anchorIdx = focusIdx;
|
||||
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, focusIdx);
|
||||
}
|
||||
}
|
||||
|
||||
let nextIdx = currentIdx + delta;
|
||||
let nextIdx = focusIdx + delta;
|
||||
if (nextIdx < 0) nextIdx = 0;
|
||||
if (nextIdx >= items.length) nextIdx = items.length - 1;
|
||||
|
||||
onPrepareSelectionRef.current();
|
||||
if (e.shiftKey && currentSelected.length > 0) {
|
||||
const anchorIdx = currentIdx >= 0 ? currentIdx : 0;
|
||||
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);
|
||||
}
|
||||
|
||||
lastClickedPathRef.current = items[nextIdx].path;
|
||||
|
||||
@@ -67,25 +67,31 @@ 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();
|
||||
@@ -354,7 +360,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
],
|
||||
);
|
||||
|
||||
useSftpDialogActionHandler(side, dialogActionHandlers);
|
||||
useSftpDialogActionHandler(side, dialogActionScopeId, dialogActionHandlers, isActive);
|
||||
|
||||
const handleSortWithTransition = (field: typeof sortField) => {
|
||||
startTransition(() => handleSort(field));
|
||||
@@ -495,6 +501,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
<SftpPaneTreeView
|
||||
pane={pane}
|
||||
side={side}
|
||||
onPrepareSelection={callbacks.onPrepareSelection}
|
||||
onLoadChildren={callbacks.onListDirectory}
|
||||
onMoveEntriesToPath={handleMoveEntriesToPath}
|
||||
onNavigateUp={callbacks.onNavigateUp}
|
||||
@@ -573,6 +580,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
|
||||
<SftpPaneDialogs
|
||||
t={t}
|
||||
hostLabel={pane.connection?.hostLabel}
|
||||
currentPath={pane.connection?.currentPath}
|
||||
showNewFolderDialog={showNewFolderDialog}
|
||||
setShowNewFolderDialog={setShowNewFolderDialog}
|
||||
newFolderName={newFolderName}
|
||||
@@ -621,6 +630,7 @@ 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;
|
||||
|
||||
@@ -214,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) => {
|
||||
@@ -302,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}
|
||||
@@ -379,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} />
|
||||
@@ -418,4 +434,3 @@ const sftpTabBarAreEqual = (
|
||||
|
||||
export const SftpTabBar = memo(SftpTabBarInner, sftpTabBarAreEqual);
|
||||
SftpTabBar.displayName = "SftpTabBar";
|
||||
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ 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";
|
||||
@@ -72,13 +73,15 @@ export const sftpTreeEnterStore = {
|
||||
// indices per pane so Shift+Arrow extends correctly.
|
||||
const _kbSelectionState = new Map<string, { anchor: number; focus: number }>();
|
||||
|
||||
function getKbSelection(paneId: string) {
|
||||
return _kbSelectionState.get(paneId) ?? { anchor: 0, focus: 0 };
|
||||
}
|
||||
|
||||
function setKbSelection(paneId: string, anchor: number, focus: number) {
|
||||
_kbSelectionState.set(paneId, { anchor, focus });
|
||||
}
|
||||
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> = {
|
||||
@@ -90,6 +93,7 @@ interface UseSftpKeyboardShortcutsParams {
|
||||
keyBindings: KeyBinding[];
|
||||
hotkeyScheme: "disabled" | "mac" | "pc";
|
||||
sftpRef: MutableRefObject<SftpStateApi>;
|
||||
dialogActionScopeId: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
@@ -115,6 +119,7 @@ export const useSftpKeyboardShortcuts = ({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
sftpRef,
|
||||
dialogActionScopeId,
|
||||
isActive,
|
||||
}: UseSftpKeyboardShortcutsParams) => {
|
||||
const handleKeyDown = useCallback(
|
||||
@@ -134,6 +139,12 @@ export const useSftpKeyboardShortcuts = ({
|
||||
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;
|
||||
}
|
||||
|
||||
// ── Arrow Up/Down: move selection ────────────────────────────────
|
||||
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
const sftp = sftpRef.current;
|
||||
@@ -155,29 +166,35 @@ export const useSftpKeyboardShortcuts = ({
|
||||
|
||||
// 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 } = getKbSelection(pane.id);
|
||||
let { anchor: anchorIdx, focus: focusIdx } = sftpKeyboardSelectionStore.get(pane.id);
|
||||
const currentSelected = Array.from(pane.selectedFiles) as string[];
|
||||
// If the tracked focus doesn't match the actual selection, re-sync
|
||||
if (currentSelected.length >= 1 && !currentSelected.includes(listItems[focusIdx])) {
|
||||
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;
|
||||
setKbSelection(pane.id, 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));
|
||||
setKbSelection(pane.id, anchorIdx, nextIdx);
|
||||
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, nextIdx);
|
||||
} else {
|
||||
sftp.rangeSelect(focusedSide, [listItems[nextIdx]]);
|
||||
setKbSelection(pane.id, nextIdx, nextIdx);
|
||||
sftpKeyboardSelectionStore.set(pane.id, nextIdx, nextIdx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -191,26 +208,35 @@ export const useSftpKeyboardShortcuts = ({
|
||||
const currentSelected = [...treeState.selectedPaths];
|
||||
|
||||
// Use tracked state, re-sync if needed
|
||||
let { anchor: anchorIdx, focus: focusIdx } = getKbSelection(pane.id);
|
||||
if (currentSelected.length >= 1 && items[focusIdx]?.path !== currentSelected[currentSelected.length - 1]) {
|
||||
focusIdx = treeState.visibleIndexByPath.get(currentSelected[currentSelected.length - 1]) ?? 0;
|
||||
anchorIdx = focusIdx;
|
||||
setKbSelection(pane.id, anchorIdx, focusIdx);
|
||||
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);
|
||||
setKbSelection(pane.id, anchorIdx, nextIdx);
|
||||
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, nextIdx);
|
||||
} else {
|
||||
sftpTreeSelectionStore.setSelection(pane.id, [items[nextIdx].path]);
|
||||
setKbSelection(pane.id, nextIdx, nextIdx);
|
||||
sftpKeyboardSelectionStore.set(pane.id, nextIdx, nextIdx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -350,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);
|
||||
|
||||
@@ -439,6 +467,7 @@ export const useSftpKeyboardShortcuts = ({
|
||||
|
||||
case "sftpSelectAll": {
|
||||
if (treeSelectionState.visibleItems.length > 0) {
|
||||
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
|
||||
sftpTreeSelectionStore.selectAllVisible(pane.id);
|
||||
break;
|
||||
}
|
||||
@@ -458,33 +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", [treeActionSelection[0].path]);
|
||||
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", treeActionSelection.map((entry) => entry.path));
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -496,7 +530,7 @@ export const useSftpKeyboardShortcuts = ({
|
||||
|
||||
case "sftpNewFolder": {
|
||||
// Create new folder
|
||||
sftpDialogActionStore.trigger("newFolder");
|
||||
sftpDialogActionStore.trigger("newFolder", dialogActionScopeId);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -559,7 +593,7 @@ export const useSftpKeyboardShortcuts = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[hotkeyScheme, isActive, keyBindings, sftpRef]
|
||||
[dialogActionScopeId, hotkeyScheme, isActive, keyBindings, sftpRef]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import type { SftpPaneCallbacks, SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
|
||||
import { isNavigableDirectory } from "../index";
|
||||
@@ -67,6 +67,12 @@ export const useSftpPaneDragAndSelect = ({
|
||||
const onUploadRef = useRef(onUploadExternalFiles);
|
||||
onUploadRef.current = onUploadExternalFiles;
|
||||
|
||||
useEffect(() => {
|
||||
if (pane.selectedFiles.size === 0) {
|
||||
lastSelectedIndexRef.current = null;
|
||||
}
|
||||
}, [pane.selectedFiles.size]);
|
||||
|
||||
const getSamePaneDragPaths = useCallback((): string[] | null => {
|
||||
const dragged = draggedFilesRef.current;
|
||||
if (!dragged || dragged.length === 0) return null;
|
||||
|
||||
@@ -99,6 +99,17 @@ export const sftpTreeSelectionStore = {
|
||||
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,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import type { SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
|
||||
import { keepOnlyActivePaneSelections } from "./selectionScope";
|
||||
|
||||
interface UseSftpViewPaneActionsParams {
|
||||
sftpRef: MutableRefObject<SftpStateApi>;
|
||||
@@ -14,6 +15,8 @@ interface UseSftpViewPaneActionsResult {
|
||||
onConnectRight: (host: Parameters<SftpStateApi["connect"]>[1]) => void;
|
||||
onDisconnectLeft: () => void;
|
||||
onDisconnectRight: () => void;
|
||||
onPrepareSelectionLeft: () => void;
|
||||
onPrepareSelectionRight: () => void;
|
||||
onNavigateToLeft: (path: string) => void;
|
||||
onNavigateToRight: (path: string) => void;
|
||||
onNavigateUpLeft: () => void;
|
||||
@@ -126,6 +129,12 @@ export const useSftpViewPaneActions = ({
|
||||
);
|
||||
const onDisconnectLeft = useCallback(() => sftpRef.current.disconnect("left"), [sftpRef]);
|
||||
const onDisconnectRight = useCallback(() => sftpRef.current.disconnect("right"), [sftpRef]);
|
||||
const onPrepareSelectionLeft = useCallback(() => {
|
||||
keepOnlyActivePaneSelections(sftpRef.current, "left");
|
||||
}, [sftpRef]);
|
||||
const onPrepareSelectionRight = useCallback(() => {
|
||||
keepOnlyActivePaneSelections(sftpRef.current, "right");
|
||||
}, [sftpRef]);
|
||||
const onNavigateToLeft = useCallback(
|
||||
(path: string) => sftpRef.current.navigateTo("left", path),
|
||||
[sftpRef],
|
||||
@@ -151,20 +160,32 @@ export const useSftpViewPaneActions = ({
|
||||
[sftpRef],
|
||||
);
|
||||
const onToggleSelectionLeft = useCallback(
|
||||
(name: string, multi: boolean) => sftpRef.current.toggleSelection("left", name, multi),
|
||||
[sftpRef],
|
||||
(name: string, multi: boolean) => {
|
||||
onPrepareSelectionLeft();
|
||||
sftpRef.current.toggleSelection("left", name, multi);
|
||||
},
|
||||
[onPrepareSelectionLeft, sftpRef],
|
||||
);
|
||||
const onToggleSelectionRight = useCallback(
|
||||
(name: string, multi: boolean) => sftpRef.current.toggleSelection("right", name, multi),
|
||||
[sftpRef],
|
||||
(name: string, multi: boolean) => {
|
||||
onPrepareSelectionRight();
|
||||
sftpRef.current.toggleSelection("right", name, multi);
|
||||
},
|
||||
[onPrepareSelectionRight, sftpRef],
|
||||
);
|
||||
const onRangeSelectLeft = useCallback(
|
||||
(fileNames: string[]) => sftpRef.current.rangeSelect("left", fileNames),
|
||||
[sftpRef],
|
||||
(fileNames: string[]) => {
|
||||
onPrepareSelectionLeft();
|
||||
sftpRef.current.rangeSelect("left", fileNames);
|
||||
},
|
||||
[onPrepareSelectionLeft, sftpRef],
|
||||
);
|
||||
const onRangeSelectRight = useCallback(
|
||||
(fileNames: string[]) => sftpRef.current.rangeSelect("right", fileNames),
|
||||
[sftpRef],
|
||||
(fileNames: string[]) => {
|
||||
onPrepareSelectionRight();
|
||||
sftpRef.current.rangeSelect("right", fileNames);
|
||||
},
|
||||
[onPrepareSelectionRight, sftpRef],
|
||||
);
|
||||
const onClearSelectionLeft = useCallback(() => sftpRef.current.clearSelection("left"), [sftpRef]);
|
||||
const onClearSelectionRight = useCallback(() => sftpRef.current.clearSelection("right"), [sftpRef]);
|
||||
@@ -266,6 +287,8 @@ export const useSftpViewPaneActions = ({
|
||||
onConnectRight,
|
||||
onDisconnectLeft,
|
||||
onDisconnectRight,
|
||||
onPrepareSelectionLeft,
|
||||
onPrepareSelectionRight,
|
||||
onNavigateToLeft,
|
||||
onNavigateToRight,
|
||||
onNavigateUpLeft,
|
||||
|
||||
@@ -140,6 +140,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
() => ({
|
||||
onConnect: paneActions.onConnectLeft,
|
||||
onDisconnect: paneActions.onDisconnectLeft,
|
||||
onPrepareSelection: paneActions.onPrepareSelectionLeft,
|
||||
onNavigateTo: paneActions.onNavigateToLeft,
|
||||
onNavigateUp: paneActions.onNavigateUpLeft,
|
||||
onRefresh: paneActions.onRefreshLeft,
|
||||
@@ -176,6 +177,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
() => ({
|
||||
onConnect: paneActions.onConnectRight,
|
||||
onDisconnect: paneActions.onDisconnectRight,
|
||||
onPrepareSelection: paneActions.onPrepareSelectionRight,
|
||||
onNavigateTo: paneActions.onNavigateToRight,
|
||||
onNavigateUp: paneActions.onNavigateUpRight,
|
||||
onRefresh: paneActions.onRefreshRight,
|
||||
|
||||
@@ -21,8 +21,8 @@ interface UseSftpViewTabsResult {
|
||||
setShowHostPickerRight: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setHostSearchLeft: React.Dispatch<React.SetStateAction<string>>;
|
||||
setHostSearchRight: React.Dispatch<React.SetStateAction<string>>;
|
||||
handleAddTabLeft: () => void;
|
||||
handleAddTabRight: () => void;
|
||||
handleAddTabLeft: () => string;
|
||||
handleAddTabRight: () => string;
|
||||
handleCloseTabLeft: (tabId: string) => void;
|
||||
handleCloseTabRight: (tabId: string) => void;
|
||||
handleSelectTabLeft: (tabId: string) => void;
|
||||
@@ -42,13 +42,15 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
|
||||
const [hostSearchRight, setHostSearchRight] = useState("");
|
||||
|
||||
const handleAddTabLeft = useCallback(() => {
|
||||
sftpRef.current.addTab("left");
|
||||
const tabId = sftpRef.current.addTab("left");
|
||||
setShowHostPickerLeft(true);
|
||||
return tabId;
|
||||
}, [sftpRef]);
|
||||
|
||||
const handleAddTabRight = useCallback(() => {
|
||||
sftpRef.current.addTab("right");
|
||||
const tabId = sftpRef.current.addTab("right");
|
||||
setShowHostPickerRight(true);
|
||||
return tabId;
|
||||
}, [sftpRef]);
|
||||
|
||||
const handleCloseTabLeft = useCallback((tabId: string) => {
|
||||
|
||||
@@ -75,21 +75,26 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
const splitVShortcut = getShortcut('split-vertical');
|
||||
const clearShortcut = getShortcut('clear-buffer');
|
||||
|
||||
const showContextMenu = rightClickBehavior === 'context-menu' && !isAlternateScreen;
|
||||
|
||||
// Handle right-click: intercept for paste/select-word unless Shift is held
|
||||
// or rightClickBehavior is 'context-menu'. The ContextMenuTrigger stays always
|
||||
// enabled so Shift+Right-Click opens the menu on the first click.
|
||||
const handleRightClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// In alternate screen (tmux, vim, etc.), let the terminal application
|
||||
// handle right-click natively to avoid conflicting menus
|
||||
if (isAlternateScreen) return;
|
||||
|
||||
if (rightClickBehavior === 'paste') {
|
||||
if (isAlternateScreen) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Shift+Right-Click or context-menu mode: let Radix open the menu
|
||||
if (e.shiftKey || rightClickBehavior === 'context-menu') return;
|
||||
|
||||
// Paste / select-word: intercept and prevent the context menu
|
||||
e.preventDefault();
|
||||
if (rightClickBehavior === 'paste') {
|
||||
onPaste?.();
|
||||
} else if (rightClickBehavior === 'select-word') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelectWord?.();
|
||||
}
|
||||
},
|
||||
@@ -102,12 +107,11 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger
|
||||
asChild
|
||||
disabled={!showContextMenu}
|
||||
onContextMenu={!showContextMenu ? handleRightClick : undefined}
|
||||
onContextMenu={handleRightClick}
|
||||
>
|
||||
{children}
|
||||
</ContextMenuTrigger>
|
||||
{showContextMenu && (
|
||||
{!isAlternateScreen && (
|
||||
<ContextMenuContent className="w-56">
|
||||
<ContextMenuItem onClick={onCopy} disabled={!hasSelection}>
|
||||
<Copy size={14} className="mr-2" />
|
||||
|
||||
@@ -15,7 +15,7 @@ import { createPortal } from 'react-dom';
|
||||
import { Check, Download, Minus, Palette, Pencil, Plus, Sparkles, Type, X } from 'lucide-react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { useAvailableFonts } from '../../application/state/fontStore';
|
||||
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
|
||||
import { TERMINAL_THEMES, TerminalThemeConfig, USER_VISIBLE_TERMINAL_THEMES, isUiMatchTerminalThemeId } from '../../infrastructure/config/terminalThemes';
|
||||
import { DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE, TerminalFont } from '../../infrastructure/config/fonts';
|
||||
import { useCustomThemes, useCustomThemeActions } from '../../application/state/customThemeStore';
|
||||
import { parseItermcolors } from '../../infrastructure/parsers/itermcolorsParser';
|
||||
@@ -125,16 +125,21 @@ interface ThemeCustomizeModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
currentThemeId?: string;
|
||||
displayThemeId?: string;
|
||||
currentFontFamilyId?: string;
|
||||
currentFontSize?: number;
|
||||
/** Called immediately when user selects a theme (for real-time preview) */
|
||||
onThemeChange?: (themeId: string) => void;
|
||||
/** Called when the theme should return to inherited/default state */
|
||||
onThemeReset?: () => void;
|
||||
/** Called immediately when user selects a font (for real-time preview) */
|
||||
onFontFamilyChange?: (fontFamilyId: string) => void;
|
||||
/** Called immediately when user changes font size (for real-time preview) */
|
||||
onFontSizeChange?: (fontSize: number) => void;
|
||||
/** Called when user clicks Save to persist settings */
|
||||
onSave?: () => void;
|
||||
/** Optional live preview callback for consumers that render outside this modal */
|
||||
onPreviewThemeChange?: (theme: TerminalTheme | null) => void;
|
||||
}
|
||||
|
||||
// Memoized preview component to avoid re-rendering on every state change
|
||||
@@ -261,26 +266,39 @@ const TerminalPreview = memo(({
|
||||
));
|
||||
TerminalPreview.displayName = 'TerminalPreview';
|
||||
|
||||
const cloneTheme = (theme: TerminalTheme): TerminalTheme => ({
|
||||
...theme,
|
||||
colors: { ...theme.colors },
|
||||
isCustom: true,
|
||||
});
|
||||
|
||||
const serializeTheme = (theme: TerminalTheme): string => JSON.stringify(theme);
|
||||
|
||||
export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
currentThemeId = 'termius-dark',
|
||||
currentThemeId,
|
||||
displayThemeId,
|
||||
currentFontFamilyId = 'menlo',
|
||||
currentFontSize = DEFAULT_FONT_SIZE,
|
||||
onThemeChange,
|
||||
onThemeReset,
|
||||
onFontFamilyChange,
|
||||
onFontSizeChange,
|
||||
onSave,
|
||||
onPreviewThemeChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const availableFonts = useAvailableFonts();
|
||||
const customThemes = useCustomThemes();
|
||||
const { addTheme, updateTheme, deleteTheme } = useCustomThemeActions();
|
||||
|
||||
const resolvedThemeId = currentThemeId ?? displayThemeId ?? TERMINAL_THEMES[0].id;
|
||||
const [activeTab, setActiveTab] = useState<TabType>('theme');
|
||||
const [selectedTheme, setSelectedTheme] = useState(currentThemeId);
|
||||
const [selectedTheme, setSelectedTheme] = useState(resolvedThemeId);
|
||||
const [selectedFont, setSelectedFont] = useState(currentFontFamilyId);
|
||||
const [fontSize, setFontSize] = useState(currentFontSize);
|
||||
const [draftCustomThemes, setDraftCustomThemes] = useState<TerminalTheme[]>(() => customThemes.map(cloneTheme));
|
||||
|
||||
// Custom theme editor state
|
||||
const [editingTheme, setEditingTheme] = useState<TerminalTheme | null>(null);
|
||||
@@ -293,30 +311,37 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
|
||||
font: currentFontFamilyId,
|
||||
fontSize: currentFontSize,
|
||||
});
|
||||
const originalCustomThemesRef = useRef<TerminalTheme[]>([]);
|
||||
const wasOpenRef = useRef(false);
|
||||
|
||||
// Combine built-in + custom themes
|
||||
const allThemes = useMemo(
|
||||
() => [...TERMINAL_THEMES, ...customThemes],
|
||||
[customThemes]
|
||||
() => [...TERMINAL_THEMES, ...draftCustomThemes],
|
||||
[draftCustomThemes]
|
||||
);
|
||||
|
||||
// Sync state when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (open && !wasOpenRef.current) {
|
||||
// Store original values for potential cancel
|
||||
originalValuesRef.current = {
|
||||
theme: currentThemeId,
|
||||
font: currentFontFamilyId,
|
||||
fontSize: currentFontSize,
|
||||
};
|
||||
originalCustomThemesRef.current = customThemes.map((theme) => ({
|
||||
...cloneTheme(theme),
|
||||
}));
|
||||
// Initialize selected values
|
||||
setSelectedTheme(currentThemeId);
|
||||
setSelectedTheme(resolvedThemeId);
|
||||
setSelectedFont(currentFontFamilyId);
|
||||
setFontSize(currentFontSize);
|
||||
setDraftCustomThemes(customThemes.map(cloneTheme));
|
||||
setEditingTheme(null);
|
||||
setIsNewTheme(false);
|
||||
}
|
||||
}, [open, currentThemeId, currentFontFamilyId, currentFontSize]);
|
||||
wasOpenRef.current = open;
|
||||
}, [open, currentThemeId, resolvedThemeId, currentFontFamilyId, currentFontSize, customThemes]);
|
||||
|
||||
const currentFont = useMemo(
|
||||
(): TerminalFont => availableFonts.find(f => f.id === selectedFont) || availableFonts[0],
|
||||
@@ -326,6 +351,16 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
|
||||
() => editingTheme || allThemes.find(t => t.id === selectedTheme) || TERMINAL_THEMES[0],
|
||||
[selectedTheme, allThemes, editingTheme]
|
||||
);
|
||||
const hiddenSelectedTheme = useMemo(
|
||||
() => (isUiMatchTerminalThemeId(selectedTheme)
|
||||
? TERMINAL_THEMES.find((theme) => theme.id === selectedTheme) || null
|
||||
: null),
|
||||
[selectedTheme]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onPreviewThemeChange?.(open ? currentTheme : null);
|
||||
}, [currentTheme, onPreviewThemeChange, open]);
|
||||
|
||||
// Handle theme selection - apply immediately for real-time preview
|
||||
const handleThemeSelect = useCallback((themeId: string) => {
|
||||
@@ -379,7 +414,7 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
|
||||
const xml = reader.result as string;
|
||||
const parsed = parseItermcolors(xml, name);
|
||||
if (parsed) {
|
||||
addTheme(parsed);
|
||||
setDraftCustomThemes((prev) => [...prev, cloneTheme(parsed)]);
|
||||
setSelectedTheme(parsed.id);
|
||||
onThemeChange?.(parsed.id);
|
||||
setActiveTab('theme');
|
||||
@@ -394,16 +429,16 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
|
||||
reader.readAsText(file);
|
||||
// Reset file input so the same file can be re-imported
|
||||
e.target.value = '';
|
||||
}, [addTheme, onThemeChange, t]);
|
||||
}, [onThemeChange, t]);
|
||||
|
||||
const handleEditTheme = useCallback((themeId: string) => {
|
||||
const theme = customThemes.find(t => t.id === themeId);
|
||||
const theme = draftCustomThemes.find(t => t.id === themeId);
|
||||
if (theme) {
|
||||
setEditingTheme({ ...theme, colors: { ...theme.colors } });
|
||||
setIsNewTheme(false);
|
||||
setActiveTab('custom');
|
||||
}
|
||||
}, [customThemes]);
|
||||
}, [draftCustomThemes]);
|
||||
|
||||
|
||||
const handleEditorBack = useCallback(() => {
|
||||
@@ -412,40 +447,63 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
|
||||
}, []);
|
||||
|
||||
const handleEditorDelete = useCallback((themeId: string) => {
|
||||
deleteTheme(themeId);
|
||||
setDraftCustomThemes((prev) => prev.filter((theme) => theme.id !== themeId));
|
||||
if (selectedTheme === themeId) {
|
||||
setSelectedTheme(TERMINAL_THEMES[0].id);
|
||||
onThemeChange?.(TERMINAL_THEMES[0].id);
|
||||
const originalThemeId = originalValuesRef.current.theme;
|
||||
const fallbackThemeId = originalThemeId && originalThemeId !== themeId
|
||||
? originalThemeId
|
||||
: (displayThemeId && displayThemeId !== themeId ? displayThemeId : USER_VISIBLE_TERMINAL_THEMES[0].id);
|
||||
setSelectedTheme(fallbackThemeId);
|
||||
if (originalThemeId == null && displayThemeId && displayThemeId !== themeId) {
|
||||
onThemeReset?.();
|
||||
} else {
|
||||
onThemeChange?.(fallbackThemeId);
|
||||
}
|
||||
}
|
||||
setEditingTheme(null);
|
||||
setIsNewTheme(false);
|
||||
}, [deleteTheme, selectedTheme, onThemeChange]);
|
||||
}, [displayThemeId, onThemeChange, onThemeReset, selectedTheme]);
|
||||
|
||||
// Save: just close (changes are already applied)
|
||||
const handleSave = useCallback(() => {
|
||||
// If editing a custom theme, save it first
|
||||
if (editingTheme) {
|
||||
if (isNewTheme) {
|
||||
addTheme(editingTheme);
|
||||
setSelectedTheme(editingTheme.id);
|
||||
onThemeChange?.(editingTheme.id);
|
||||
} else {
|
||||
updateTheme(editingTheme.id, editingTheme);
|
||||
const originalThemes = originalCustomThemesRef.current;
|
||||
const originalMap = new Map(originalThemes.map((theme) => [theme.id, theme]));
|
||||
const draftMap = new Map(draftCustomThemes.map((theme) => [theme.id, theme]));
|
||||
|
||||
for (const [id, originalTheme] of originalMap) {
|
||||
if (!draftMap.has(id)) {
|
||||
deleteTheme(id);
|
||||
continue;
|
||||
}
|
||||
const nextTheme = draftMap.get(id)!;
|
||||
if (serializeTheme(originalTheme) !== serializeTheme(nextTheme)) {
|
||||
updateTheme(id, nextTheme);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, draftTheme] of draftMap) {
|
||||
if (!originalMap.has(id)) {
|
||||
addTheme(draftTheme);
|
||||
}
|
||||
}
|
||||
|
||||
onSave?.();
|
||||
onClose();
|
||||
}, [editingTheme, isNewTheme, addTheme, updateTheme, onSave, onClose, onThemeChange]);
|
||||
}, [addTheme, deleteTheme, draftCustomThemes, onClose, onSave, updateTheme]);
|
||||
|
||||
// Cancel: revert to original values
|
||||
const handleCancel = useCallback(() => {
|
||||
const original = originalValuesRef.current;
|
||||
// Revert all changes
|
||||
onThemeChange?.(original.theme);
|
||||
if (original.theme) {
|
||||
onThemeChange?.(original.theme);
|
||||
} else {
|
||||
onThemeReset?.();
|
||||
}
|
||||
onFontFamilyChange?.(original.font);
|
||||
onFontSizeChange?.(original.fontSize);
|
||||
onClose();
|
||||
}, [onThemeChange, onFontFamilyChange, onFontSizeChange, onClose]);
|
||||
}, [onThemeChange, onThemeReset, onFontFamilyChange, onFontSizeChange, onClose]);
|
||||
|
||||
// Handle ESC key - same as cancel, but skip when child editor is open
|
||||
useEffect(() => {
|
||||
@@ -465,7 +523,7 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
|
||||
if (!open) return null;
|
||||
|
||||
// Separate built-in and custom themes for display in the theme list
|
||||
const builtinThemes = TERMINAL_THEMES;
|
||||
const builtinThemes = USER_VISIBLE_TERMINAL_THEMES;
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
@@ -541,6 +599,17 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2">
|
||||
{activeTab === 'theme' && (
|
||||
<div className="space-y-1">
|
||||
{hiddenSelectedTheme && (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/30 px-3 py-2.5 mb-2">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1 font-semibold">
|
||||
{t('terminal.hiddenTheme.title')}
|
||||
</div>
|
||||
<div className="text-xs font-medium text-foreground">{hiddenSelectedTheme.name}</div>
|
||||
<div className="text-[10px] text-muted-foreground mt-1">
|
||||
{t('terminal.hiddenTheme.desc')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Built-in themes */}
|
||||
{builtinThemes.map(theme => (
|
||||
<ThemeItem
|
||||
@@ -551,12 +620,12 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
|
||||
/>
|
||||
))}
|
||||
{/* Custom themes section */}
|
||||
{customThemes.length > 0 && (
|
||||
{draftCustomThemes.length > 0 && (
|
||||
<>
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-3 mb-1.5 px-1 font-semibold">
|
||||
{t('terminal.customTheme.section')}
|
||||
</div>
|
||||
{customThemes.map(theme => (
|
||||
{draftCustomThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
@@ -617,12 +686,12 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
|
||||
/>
|
||||
|
||||
{/* Custom themes list */}
|
||||
{customThemes.length > 0 && (
|
||||
{draftCustomThemes.length > 0 && (
|
||||
<>
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-3 mb-1 px-1 font-semibold">
|
||||
{t('terminal.customTheme.yourThemes')}
|
||||
</div>
|
||||
{customThemes.map(theme => (
|
||||
{draftCustomThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
@@ -717,12 +786,16 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
|
||||
theme={editingTheme}
|
||||
isNew={isNewTheme}
|
||||
onSave={(theme) => {
|
||||
setDraftCustomThemes((prev) => {
|
||||
if (isNewTheme) {
|
||||
return [...prev, cloneTheme(theme)];
|
||||
}
|
||||
return prev.map((entry) => entry.id === theme.id ? cloneTheme(theme) : entry);
|
||||
});
|
||||
if (isNewTheme) {
|
||||
addTheme(theme);
|
||||
setSelectedTheme(theme.id);
|
||||
onThemeChange?.(theme.id);
|
||||
} else {
|
||||
updateTheme(theme.id, theme);
|
||||
if (selectedTheme === theme.id) {
|
||||
onThemeChange?.(theme.id);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
* Changes apply in real-time.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Check, Download, Minus, Palette, Pencil, Plus, Sparkles, Type } from 'lucide-react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { useAvailableFonts } from '../../application/state/fontStore';
|
||||
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
|
||||
import { TERMINAL_THEMES, TerminalThemeConfig, USER_VISIBLE_TERMINAL_THEMES, isUiMatchTerminalThemeId } from '../../infrastructure/config/terminalThemes';
|
||||
import { MIN_FONT_SIZE, MAX_FONT_SIZE, TerminalFont } from '../../infrastructure/config/fonts';
|
||||
import { useCustomThemes, useCustomThemeActions } from '../../application/state/customThemeStore';
|
||||
import { parseItermcolors } from '../../infrastructure/parsers/itermcolorsParser';
|
||||
@@ -126,20 +126,25 @@ const FontItem = memo(({
|
||||
FontItem.displayName = 'FontItem';
|
||||
|
||||
interface ThemeSidePanelProps {
|
||||
followAppTerminalTheme?: boolean;
|
||||
currentThemeId: string;
|
||||
globalThemeId: string;
|
||||
currentFontFamilyId: string;
|
||||
globalFontFamilyId: string;
|
||||
currentFontSize: number;
|
||||
currentFontWeight: number;
|
||||
canResetTheme?: boolean;
|
||||
canResetFontFamily?: boolean;
|
||||
canResetFontSize?: boolean;
|
||||
canResetFontWeight?: boolean;
|
||||
onThemeChange: (themeId: string) => void;
|
||||
onThemeReset?: () => void;
|
||||
onFontFamilyChange: (fontFamilyId: string) => void;
|
||||
onFontFamilyReset?: () => void;
|
||||
onFontSizeChange: (fontSize: number) => void;
|
||||
onFontSizeReset?: () => void;
|
||||
onFontWeightChange: (fontWeight: number) => void;
|
||||
onFontWeightReset?: () => void;
|
||||
isVisible?: boolean;
|
||||
previewColors?: {
|
||||
background: string;
|
||||
@@ -148,20 +153,25 @@ interface ThemeSidePanelProps {
|
||||
}
|
||||
|
||||
const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
followAppTerminalTheme = false,
|
||||
currentThemeId,
|
||||
globalThemeId,
|
||||
currentFontFamilyId,
|
||||
globalFontFamilyId,
|
||||
currentFontSize,
|
||||
currentFontWeight,
|
||||
canResetTheme = false,
|
||||
canResetFontFamily = false,
|
||||
canResetFontSize = false,
|
||||
canResetFontWeight = false,
|
||||
onThemeChange,
|
||||
onThemeReset,
|
||||
onFontFamilyChange,
|
||||
onFontFamilyReset,
|
||||
onFontSizeChange,
|
||||
onFontSizeReset,
|
||||
onFontWeightChange,
|
||||
onFontWeightReset,
|
||||
isVisible = true,
|
||||
previewColors,
|
||||
}) => {
|
||||
@@ -170,7 +180,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
const customThemes = useCustomThemes();
|
||||
const { addTheme, updateTheme, deleteTheme } = useCustomThemeActions();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>('theme');
|
||||
const [activeTab, setActiveTab] = useState<TabType>(followAppTerminalTheme ? 'font' : 'theme');
|
||||
const [editingTheme, setEditingTheme] = useState<TerminalTheme | null>(null);
|
||||
const [isNewTheme, setIsNewTheme] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -183,6 +193,12 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
() => allThemes.find((theme) => theme.id === globalThemeId) || TERMINAL_THEMES[0],
|
||||
[allThemes, globalThemeId],
|
||||
);
|
||||
const hiddenSelectedTheme = useMemo(
|
||||
() => (isUiMatchTerminalThemeId(currentThemeId)
|
||||
? TERMINAL_THEMES.find((theme) => theme.id === currentThemeId) || null
|
||||
: null),
|
||||
[currentThemeId],
|
||||
);
|
||||
const globalFont = useMemo(
|
||||
() => availableFonts.find((font) => font.id === globalFontFamilyId) || availableFonts[0],
|
||||
[availableFonts, globalFontFamilyId],
|
||||
@@ -256,9 +272,27 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
setIsNewTheme(false);
|
||||
}, [deleteTheme, currentThemeId, onThemeChange]);
|
||||
|
||||
const themeEditingLocked = followAppTerminalTheme;
|
||||
|
||||
useEffect(() => {
|
||||
if (themeEditingLocked && activeTab !== 'font') {
|
||||
setActiveTab('font');
|
||||
}
|
||||
}, [activeTab, themeEditingLocked]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!themeEditingLocked || !editingTheme) return;
|
||||
setEditingTheme(null);
|
||||
setIsNewTheme(false);
|
||||
}, [editingTheme, themeEditingLocked]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const builtinThemes = TERMINAL_THEMES;
|
||||
const builtinThemes = USER_VISIBLE_TERMINAL_THEMES;
|
||||
|
||||
const footerLabel = themeEditingLocked
|
||||
? `${availableFonts.find(f => f.id === currentFontFamilyId)?.name ?? currentFontFamilyId} • ${currentFontSize}px • ${currentFontWeight}`
|
||||
: `${allThemes.find(t => t.id === currentThemeId)?.name ?? currentThemeId} • ${availableFonts.find(f => f.id === currentFontFamilyId)?.name ?? currentFontFamilyId} • ${currentFontSize}px • ${currentFontWeight}`;
|
||||
const panelVars = {
|
||||
['--terminal-panel-bg' as never]: previewColors?.background ?? 'var(--background)',
|
||||
['--terminal-panel-fg' as never]: previewColors?.foreground ?? 'var(--foreground)',
|
||||
@@ -281,17 +315,19 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
>
|
||||
{/* Tab Bar */}
|
||||
<div className="flex p-1.5 gap-0.5 shrink-0 border-b" style={{ borderColor: 'var(--terminal-panel-border)' }}>
|
||||
<button
|
||||
onClick={() => { setActiveTab('theme'); setEditingTheme(null); }}
|
||||
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
|
||||
style={{
|
||||
backgroundColor: activeTab === 'theme' ? 'var(--terminal-panel-active)' : 'transparent',
|
||||
color: activeTab === 'theme' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
|
||||
}}
|
||||
>
|
||||
<Palette size={12} />
|
||||
{t('terminal.themeModal.tab.theme')}
|
||||
</button>
|
||||
{!themeEditingLocked && (
|
||||
<button
|
||||
onClick={() => { setActiveTab('theme'); setEditingTheme(null); }}
|
||||
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
|
||||
style={{
|
||||
backgroundColor: activeTab === 'theme' ? 'var(--terminal-panel-active)' : 'transparent',
|
||||
color: activeTab === 'theme' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
|
||||
}}
|
||||
>
|
||||
<Palette size={12} />
|
||||
{t('terminal.themeModal.tab.theme')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setActiveTab('font')}
|
||||
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
|
||||
@@ -303,24 +339,37 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
<Type size={12} />
|
||||
{t('terminal.themeModal.tab.font')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('custom')}
|
||||
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
|
||||
style={{
|
||||
backgroundColor: activeTab === 'custom' ? 'var(--terminal-panel-active)' : 'transparent',
|
||||
color: activeTab === 'custom' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
|
||||
}}
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
{t('terminal.themeModal.tab.custom')}
|
||||
</button>
|
||||
{!themeEditingLocked && (
|
||||
<button
|
||||
onClick={() => setActiveTab('custom')}
|
||||
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
|
||||
style={{
|
||||
backgroundColor: activeTab === 'custom' ? 'var(--terminal-panel-active)' : 'transparent',
|
||||
color: activeTab === 'custom' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
|
||||
}}
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
{t('terminal.themeModal.tab.custom')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* List Content */}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="py-1">
|
||||
{activeTab === 'theme' && (
|
||||
{!themeEditingLocked && activeTab === 'theme' && (
|
||||
<div>
|
||||
{hiddenSelectedTheme && (
|
||||
<div className="mx-2 mb-2 rounded-lg border px-3 py-2.5" style={{ borderColor: 'var(--terminal-panel-border)', backgroundColor: 'var(--terminal-panel-hover)' }}>
|
||||
<div className="text-[10px] uppercase tracking-wider mb-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
|
||||
{t('terminal.hiddenTheme.title')}
|
||||
</div>
|
||||
<div className="text-xs font-medium">{hiddenSelectedTheme.name}</div>
|
||||
<div className="text-[10px] mt-1" style={{ color: 'var(--terminal-panel-muted)' }}>
|
||||
{t('terminal.hiddenTheme.desc')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{builtinThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
@@ -383,7 +432,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'custom' && !editingTheme && (
|
||||
{!themeEditingLocked && activeTab === 'custom' && !editingTheme && (
|
||||
<div>
|
||||
<button
|
||||
onClick={handleNewTheme}
|
||||
@@ -497,10 +546,69 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{themeEditingLocked && canResetTheme && (
|
||||
<div className="p-2.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-[9px] uppercase tracking-wider font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
|
||||
{t('terminal.themeModal.globalTheme')}
|
||||
</div>
|
||||
<button
|
||||
onClick={onThemeReset}
|
||||
className="text-[10px] font-medium hover:opacity-80 transition-opacity"
|
||||
style={{ color: 'var(--terminal-panel-fg)' }}
|
||||
>
|
||||
{t('common.useGlobal')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Font Weight Control (only in font tab) */}
|
||||
{activeTab === 'font' && (
|
||||
<div className="p-2.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
|
||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||
<div className="text-[9px] uppercase tracking-wider font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
|
||||
{t('terminal.themeModal.fontWeight')}
|
||||
</div>
|
||||
{canResetFontWeight && (
|
||||
<button
|
||||
onClick={onFontWeightReset}
|
||||
className="text-[10px] font-medium hover:opacity-80 transition-opacity"
|
||||
style={{ color: 'var(--terminal-panel-fg)' }}
|
||||
>
|
||||
{t('common.useGlobal')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-lg p-1.5" style={{ backgroundColor: 'var(--terminal-panel-hover)' }}>
|
||||
<select
|
||||
value={currentFontWeight}
|
||||
onChange={(e) => onFontWeightChange(Number(e.target.value))}
|
||||
className="flex-1 h-7 rounded-md border text-xs px-2 cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: 'var(--terminal-panel-bg)',
|
||||
color: 'var(--terminal-panel-fg)',
|
||||
borderColor: 'var(--terminal-panel-border)',
|
||||
}}
|
||||
>
|
||||
<option value={100}>100 Thin</option>
|
||||
<option value={200}>200 ExtraLight</option>
|
||||
<option value={300}>300 Light</option>
|
||||
<option value={400}>400 Normal</option>
|
||||
<option value={500}>500 Medium</option>
|
||||
<option value={600}>600 SemiBold</option>
|
||||
<option value={700}>700 Bold</option>
|
||||
<option value={800}>800 ExtraBold</option>
|
||||
<option value={900}>900 Black</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current selection info */}
|
||||
<div className="px-2.5 py-1.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
|
||||
<div className="text-[9px] truncate" style={{ color: 'var(--terminal-panel-muted)' }}>
|
||||
{allThemes.find(t => t.id === currentThemeId)?.name ?? currentThemeId} • {availableFonts.find(f => f.id === currentFontFamilyId)?.name ?? currentFontFamilyId} • {currentFontSize}px
|
||||
{footerLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
79
components/terminal/ZmodemProgressIndicator.tsx
Normal file
79
components/terminal/ZmodemProgressIndicator.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { ArrowDownToLine, ArrowUpFromLine, X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface ZmodemProgressIndicatorProps {
|
||||
transferType: 'upload' | 'download' | null;
|
||||
filename: string | null;
|
||||
transferred: number;
|
||||
total: number;
|
||||
fileIndex: number;
|
||||
fileCount: number;
|
||||
finalizing: boolean;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes <= 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export const ZmodemProgressIndicator: React.FC<ZmodemProgressIndicatorProps> = ({
|
||||
transferType,
|
||||
filename,
|
||||
transferred,
|
||||
total,
|
||||
fileIndex,
|
||||
fileCount,
|
||||
finalizing,
|
||||
onCancel,
|
||||
}) => {
|
||||
const percent = total > 0 ? Math.min(100, Math.round((transferred / total) * 100)) : 0;
|
||||
const Icon = transferType === 'upload' ? ArrowUpFromLine : ArrowDownToLine;
|
||||
const label = finalizing ? 'Waiting for remote...' : transferType === 'upload' ? 'Uploading' : 'Downloading';
|
||||
const fileInfo = fileCount > 0 ? ` (${fileIndex + 1}/${fileCount})` : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-3 py-2 rounded-lg shadow-lg backdrop-blur-sm min-w-[240px] max-w-[360px]"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-bg, #000000) 90%, transparent)',
|
||||
border: '1px solid color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 15%, var(--terminal-ui-bg, #000000))',
|
||||
color: 'var(--terminal-ui-fg, #ffffff)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0 opacity-60" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="text-xs font-medium truncate">
|
||||
{filename || label}{fileInfo}
|
||||
</span>
|
||||
<span className="text-[10px] opacity-60 flex-shrink-0">{percent}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1 rounded-full overflow-hidden" style={{ backgroundColor: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 10%, transparent)' }}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-150"
|
||||
style={{
|
||||
width: `${percent}%`,
|
||||
backgroundColor: transferType === 'upload' ? '#3b82f6' : '#22c55e',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[10px] opacity-50 mt-0.5">
|
||||
{formatBytes(transferred)} / {formatBytes(total)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-shrink-0 p-1 rounded transition-colors hover:bg-white/10"
|
||||
title="Cancel transfer (Ctrl+C)"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 opacity-60" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -48,6 +48,8 @@ interface AutocompletePopupProps {
|
||||
onRequestReposition?: () => void;
|
||||
/** Offset from top of container to terminal content area (toolbar + search bar) */
|
||||
searchBarOffset?: number;
|
||||
/** Called when user clicks outside the popup to dismiss it */
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<SuggestionSource, { label: string; fullLabel: string; fallbackColor: string }> = {
|
||||
@@ -105,7 +107,9 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
containerRef,
|
||||
onRequestReposition,
|
||||
searchBarOffset: _searchBarOffset = 30,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const selectedRef = useRef<HTMLDivElement>(null);
|
||||
const [hoveredIndex, setHoveredIndex] = useState(-1);
|
||||
@@ -148,6 +152,18 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
};
|
||||
}, [containerRef, onRequestReposition, visible]);
|
||||
|
||||
// Dismiss popup when clicking outside
|
||||
useEffect(() => {
|
||||
if (!visible || !onDismiss) return;
|
||||
const handlePointerDown = (e: PointerEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||
onDismiss();
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
||||
}, [visible, onDismiss]);
|
||||
|
||||
if (!visible || suggestions.length === 0) return null;
|
||||
|
||||
const bg = themeColors?.background ?? "#1e1e2e";
|
||||
@@ -217,6 +233,7 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: `${clampedLeft}px`,
|
||||
|
||||
@@ -943,15 +943,15 @@ function resolveAutocompleteCwd(
|
||||
if (os === "windows") return fallbackCwd;
|
||||
|
||||
const normalizedWord = currentWord.trim().replace(/^['"]/, "");
|
||||
const isRelativePathWord = normalizedWord.length > 0 &&
|
||||
!normalizedWord.startsWith("/") &&
|
||||
!normalizedWord.startsWith("~/") &&
|
||||
!normalizedWord.startsWith("-");
|
||||
|
||||
if (!isRelativePathWord) {
|
||||
// Absolute or home-relative paths don't depend on cwd
|
||||
if (normalizedWord.startsWith("/") || normalizedWord.startsWith("~/")) {
|
||||
return fallbackCwd;
|
||||
}
|
||||
|
||||
// For empty word (e.g. "cd ") and relative paths, try prompt-based cwd
|
||||
// extraction which reflects the current visible prompt — more up-to-date
|
||||
// than fallbackCwd when OSC 7 is not supported.
|
||||
const promptCwd = extractPosixCwdFromPrompt(promptText);
|
||||
return chooseAutocompleteCwd(promptCwd, fallbackCwd);
|
||||
}
|
||||
@@ -963,15 +963,16 @@ function chooseAutocompleteCwd(
|
||||
if (!promptCwd) return fallbackCwd;
|
||||
if (!fallbackCwd) return promptCwd;
|
||||
|
||||
if (promptCwd.startsWith("/")) {
|
||||
// Prompt cwd is extracted from the currently visible prompt, so it tracks
|
||||
// directory changes even when OSC 7 is not supported. Prefer it over
|
||||
// fallbackCwd (which may be stale from initial connection) whenever it
|
||||
// looks like a usable path.
|
||||
if (promptCwd.startsWith("/") || promptCwd === "~" || promptCwd.startsWith("~/")) {
|
||||
return promptCwd;
|
||||
}
|
||||
|
||||
if (promptCwd === "~" || promptCwd.startsWith("~/")) {
|
||||
return fallbackCwd;
|
||||
}
|
||||
|
||||
return promptCwd;
|
||||
// Bare directory name (e.g. "xunlong") can't be used as a path — fallback
|
||||
return fallbackCwd;
|
||||
}
|
||||
|
||||
function extractPosixCwdFromPrompt(promptText: string): string | undefined {
|
||||
|
||||
85
components/terminal/clearTerminalViewport.ts
Normal file
85
components/terminal/clearTerminalViewport.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
|
||||
type CsiParam = number | number[];
|
||||
type InternalTerminal = XTerm & {
|
||||
_core?: {
|
||||
scroll?: (eraseAttr: unknown, isWrapped?: boolean) => void;
|
||||
_inputHandler?: {
|
||||
_eraseAttrData?: () => unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const getVisibleContentRowCount = (term: XTerm): number => {
|
||||
const buffer = term.buffer.active;
|
||||
if (buffer.type !== "normal") {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const baseY = buffer.baseY;
|
||||
for (let row = term.rows - 1; row >= 0; row--) {
|
||||
const line = buffer.getLine(baseY + row);
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
if (line.translateToString(true).length > 0) {
|
||||
return row + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const preserveTerminalViewportInScrollback = (term: XTerm): void => {
|
||||
const rowsToPreserve = getVisibleContentRowCount(term);
|
||||
if (rowsToPreserve <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const internal = term as InternalTerminal;
|
||||
const scroll = internal._core?.scroll;
|
||||
const eraseAttr = internal._core?._inputHandler?._eraseAttrData?.();
|
||||
|
||||
if (typeof scroll !== "function" || eraseAttr === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let row = 0; row < rowsToPreserve; row++) {
|
||||
scroll.call(internal._core, eraseAttr, false);
|
||||
}
|
||||
};
|
||||
|
||||
export const clearTerminalViewport = (term: XTerm): void => {
|
||||
const buffer = term.buffer.active;
|
||||
if (buffer.type !== "normal") return;
|
||||
|
||||
const cursorY = buffer.cursorY;
|
||||
const cursorX = buffer.cursorX;
|
||||
|
||||
if (cursorY === 0 && buffer.baseY === 0) return;
|
||||
|
||||
const internal = term as InternalTerminal;
|
||||
const scroll = internal._core?.scroll;
|
||||
const eraseAttr = internal._core?._inputHandler?._eraseAttrData?.();
|
||||
|
||||
if (typeof scroll !== "function" || eraseAttr === undefined) return;
|
||||
|
||||
// Push lines above cursor into scrollback so they are preserved.
|
||||
// After cursorY scrolls the prompt line shifts to active-screen row 0.
|
||||
for (let i = 0; i < cursorY; i++) {
|
||||
scroll.call(internal._core, eraseAttr, false);
|
||||
}
|
||||
|
||||
// Clear everything below the prompt and reposition the cursor on it.
|
||||
// CSI coordinates are 1-indexed.
|
||||
const col = cursorX + 1;
|
||||
term.write(`\x1b[2;1H\x1b[J\x1b[1;${col}H`, () => {
|
||||
term.scrollToBottom();
|
||||
});
|
||||
};
|
||||
|
||||
export const isEraseScrollbackSequence = (params: CsiParam[]): boolean =>
|
||||
params.length > 0 && params[0] === 3;
|
||||
|
||||
export const isEraseViewportSequence = (params: CsiParam[]): boolean =>
|
||||
params.length > 0 && params[0] === 2;
|
||||
@@ -89,11 +89,23 @@ export function useServerStats({
|
||||
const hasFetchedRef = useRef(false);
|
||||
const connectedAtRef = useRef(0);
|
||||
const fetchGenerationRef = useRef(0);
|
||||
// Auto-disable polling after a few consecutive failures. This covers
|
||||
// hosts the banner classifier could not identify (e.g. Juniper JUNOS,
|
||||
// Arista EOS, Cisco NX-OS — all of which advertise themselves as
|
||||
// OpenSSH but do not support the POSIX stats shell command). Without
|
||||
// this, the hook would keep retrying forever and generate an AAA
|
||||
// session log every refresh interval.
|
||||
const CONSECUTIVE_FAILURE_LIMIT = 3;
|
||||
const consecutiveFailuresRef = useRef(0);
|
||||
const givenUpRef = useRef(false);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
if (!enabled || !isSupportedOs || !isConnected || !isVisible || !sessionId) {
|
||||
return;
|
||||
}
|
||||
if (givenUpRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getServerStats) {
|
||||
@@ -104,6 +116,21 @@ export function useServerStats({
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const markFailure = (message: string) => {
|
||||
consecutiveFailuresRef.current += 1;
|
||||
setError(message);
|
||||
if (consecutiveFailuresRef.current >= CONSECUTIVE_FAILURE_LIMIT) {
|
||||
// Stop polling this session. The caller's useEffect sees the
|
||||
// givenUp flag via the next render cycle and we also clear the
|
||||
// interval locally so no further ticks fire.
|
||||
givenUpRef.current = true;
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await bridge.getServerStats(sessionId);
|
||||
|
||||
@@ -112,6 +139,7 @@ export function useServerStats({
|
||||
|
||||
if (result.success && result.stats) {
|
||||
hasFetchedRef.current = true;
|
||||
consecutiveFailuresRef.current = 0;
|
||||
setStats({
|
||||
cpu: result.stats.cpu,
|
||||
cpuCores: result.stats.cpuCores,
|
||||
@@ -134,11 +162,17 @@ export function useServerStats({
|
||||
lastUpdated: Date.now(),
|
||||
});
|
||||
} else if (result.error) {
|
||||
setError(result.error);
|
||||
markFailure(result.error);
|
||||
} else {
|
||||
// Response was not marked as success but has no error — treat as
|
||||
// a soft failure. This happens e.g. when the stats shell pipeline
|
||||
// returns a parse failure on a host that isn't a typical Linux
|
||||
// distro (JUNOS, NX-OS, EOS).
|
||||
markFailure('No stats returned');
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMountedRef.current && generation === fetchGenerationRef.current) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
markFailure(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current && generation === fetchGenerationRef.current) {
|
||||
@@ -147,6 +181,15 @@ export function useServerStats({
|
||||
}
|
||||
}, [sessionId, enabled, isSupportedOs, isConnected, isVisible]);
|
||||
|
||||
// When the session changes (e.g., same tab reconnects to a different host
|
||||
// while staying connected), reset the failure counter. Without this, a
|
||||
// JUNOS session that tripped the counter would permanently suppress
|
||||
// polling even after the tab reconnects to a Linux host.
|
||||
useEffect(() => {
|
||||
consecutiveFailuresRef.current = 0;
|
||||
givenUpRef.current = false;
|
||||
}, [sessionId]);
|
||||
|
||||
// Initial fetch and periodic refresh
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
@@ -158,9 +201,13 @@ export function useServerStats({
|
||||
}
|
||||
|
||||
if (!enabled || !isSupportedOs || !isConnected) {
|
||||
// Reset stats and fetch state when disabled or not connected
|
||||
// Reset stats and fetch state when disabled or not connected.
|
||||
// Also reset the give-up flag so that a reconnect (possibly to a
|
||||
// different host at the same sessionId slot) gets a fresh chance.
|
||||
hasFetchedRef.current = false;
|
||||
connectedAtRef.current = 0;
|
||||
consecutiveFailuresRef.current = 0;
|
||||
givenUpRef.current = false;
|
||||
|
||||
setStats({
|
||||
cpu: null,
|
||||
@@ -222,15 +269,23 @@ export function useServerStats({
|
||||
// (e.g., tab was hidden while connected and is now becoming visible).
|
||||
const connectionAge = Date.now() - connectedAtRef.current;
|
||||
const needsWarmup = !hasFetchedRef.current && connectionAge < 2000;
|
||||
const initialTimer = setTimeout(fetchStats, needsWarmup ? 2000 : 0);
|
||||
// If we already gave up on this session (exceeded the consecutive
|
||||
// failure limit), don't even schedule new timers on effect reruns
|
||||
// such as visibility/tab-focus/settings changes. The cleanup at
|
||||
// disconnect/sessionId change clears the flag for a fresh attempt.
|
||||
const initialTimer = givenUpRef.current
|
||||
? null
|
||||
: setTimeout(fetchStats, needsWarmup ? 2000 : 0);
|
||||
|
||||
// Set up periodic refresh
|
||||
const intervalMs = Math.max(5, refreshInterval) * 1000; // Minimum 5 seconds
|
||||
intervalRef.current = setInterval(fetchStats, intervalMs);
|
||||
if (!givenUpRef.current) {
|
||||
intervalRef.current = setInterval(fetchStats, intervalMs);
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
clearTimeout(initialTimer);
|
||||
if (initialTimer) clearTimeout(initialTimer);
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
|
||||
import { clearTerminalViewport } from "../clearTerminalViewport";
|
||||
|
||||
type TerminalBackendWriteApi = {
|
||||
writeToSession: (sessionId: string, data: string) => void;
|
||||
@@ -65,7 +66,7 @@ export const useTerminalContextActions = ({
|
||||
const onClear = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
term.clear();
|
||||
clearTerminalViewport(term);
|
||||
}, [termRef]);
|
||||
|
||||
const onSelectWord = useCallback(() => {
|
||||
|
||||
102
components/terminal/hooks/useZmodemTransfer.ts
Normal file
102
components/terminal/hooks/useZmodemTransfer.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export interface ZmodemTransferState {
|
||||
active: boolean;
|
||||
transferType: 'upload' | 'download' | null;
|
||||
filename: string | null;
|
||||
transferred: number;
|
||||
total: number;
|
||||
fileIndex: number;
|
||||
fileCount: number;
|
||||
finalizing: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: ZmodemTransferState = {
|
||||
active: false,
|
||||
transferType: null,
|
||||
filename: null,
|
||||
transferred: 0,
|
||||
total: 0,
|
||||
fileIndex: 0,
|
||||
fileCount: 0,
|
||||
finalizing: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export function useZmodemTransfer(sessionId: string | null) {
|
||||
const [state, setState] = useState<ZmodemTransferState>(initialState);
|
||||
const disposeRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const disposeExitRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onZmodemEvent) return;
|
||||
|
||||
disposeRef.current = bridge.onZmodemEvent(sessionId, (event) => {
|
||||
switch (event.type) {
|
||||
case 'detect':
|
||||
setState({
|
||||
active: true,
|
||||
transferType: event.transferType ?? null,
|
||||
filename: null,
|
||||
transferred: 0,
|
||||
total: 0,
|
||||
fileIndex: 0,
|
||||
fileCount: 0,
|
||||
error: null,
|
||||
});
|
||||
break;
|
||||
case 'progress':
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
active: true,
|
||||
transferType: event.transferType ?? prev.transferType,
|
||||
filename: event.filename ?? prev.filename,
|
||||
transferred: event.transferred ?? prev.transferred,
|
||||
total: event.total ?? prev.total,
|
||||
fileIndex: event.fileIndex ?? prev.fileIndex,
|
||||
fileCount: event.fileCount ?? prev.fileCount,
|
||||
finalizing: !!((event as Record<string, unknown>).finalizing),
|
||||
}));
|
||||
break;
|
||||
case 'complete':
|
||||
setState((prev) => ({ ...prev, active: false }));
|
||||
break;
|
||||
case 'error':
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
active: false,
|
||||
error: event.error ?? 'Unknown error',
|
||||
}));
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// If the session exits mid-transfer (disconnect, shell exit, etc.),
|
||||
// reset state so the progress indicator doesn't stay stuck.
|
||||
disposeExitRef.current = bridge.onSessionExit(sessionId, () => {
|
||||
setState(initialState);
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposeRef.current?.();
|
||||
disposeRef.current = null;
|
||||
disposeExitRef.current?.();
|
||||
disposeExitRef.current = null;
|
||||
setState(initialState);
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (!sessionId) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.cancelZmodem?.(sessionId);
|
||||
}, [sessionId]);
|
||||
|
||||
return { ...state, cancel };
|
||||
}
|
||||
@@ -10,9 +10,23 @@ import {
|
||||
sanitizeCredentialValue,
|
||||
} from "../../../domain/credentials";
|
||||
import { resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import { detectVendorFromSshVersion } from "../../../domain/host";
|
||||
|
||||
/** Timeout of distro detection task */
|
||||
const DISTRO_DETECT_TIMEOUT = 8000; // ms
|
||||
/**
|
||||
* Per-connection token for stale-timer detection. The renderer reuses the
|
||||
* same sessionId across reconnects within a tab, so comparing sessionIds
|
||||
* cannot distinguish "the current attempt" from "a previous attempt on
|
||||
* the same slot". We assign each startSSH call a fresh token object and
|
||||
* store it in this module-local map, keyed by sessionId. A timer that
|
||||
* was scheduled under an older token will see a different value here and
|
||||
* bail out. The map entry for a sessionId is overwritten on each new
|
||||
* connect and stays around until the app exits — since there is only one
|
||||
* entry per active session, the memory cost is negligible.
|
||||
*/
|
||||
const connectionTokensBySessionId = new Map<string, object>();
|
||||
|
||||
const isConnectionTokenCurrent = (sessionId: string, token: object): boolean =>
|
||||
connectionTokensBySessionId.get(sessionId) === token;
|
||||
|
||||
type TerminalBackendApi = {
|
||||
backendAvailable: () => boolean;
|
||||
@@ -38,6 +52,17 @@ type TerminalBackendApi = {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
}>;
|
||||
getSessionRemoteInfo?: (sessionId: string) => Promise<{
|
||||
success: boolean;
|
||||
remoteSshVersion?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
getSessionDistroInfo?: (sessionId: string) => Promise<{
|
||||
success: boolean;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
onSessionData: (sessionId: string, cb: (data: string) => void) => () => void;
|
||||
onSessionExit: (
|
||||
sessionId: string,
|
||||
@@ -172,7 +197,7 @@ const attachSessionToTerminal = (
|
||||
term: XTerm,
|
||||
id: string,
|
||||
opts?: {
|
||||
onExitMessage?: (evt: { exitCode?: number; signal?: number }) => string;
|
||||
onExitMessage?: (evt: { exitCode?: number; signal?: number; error?: string; reason?: string }) => string;
|
||||
onConnected?: () => void;
|
||||
// For serial: convert lone LF to CRLF to avoid "staircase effect"
|
||||
convertLfToCrlf?: boolean;
|
||||
@@ -209,6 +234,9 @@ const attachSessionToTerminal = (
|
||||
|
||||
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, (evt) => {
|
||||
ctx.updateStatus("disconnected");
|
||||
if (evt.error) {
|
||||
ctx.setError(evt.error);
|
||||
}
|
||||
term.writeln(opts?.onExitMessage?.(evt) ?? "\r\n[session closed]");
|
||||
|
||||
if (ctx.onTerminalDataCapture && ctx.serializeAddonRef.current) {
|
||||
@@ -220,32 +248,73 @@ const attachSessionToTerminal = (
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up the connection token for this sessionId so stale timers
|
||||
// that haven't fired yet will fail the isConnectionTokenCurrent check
|
||||
// (previously they would see the old token still in the map and pass).
|
||||
connectionTokensBySessionId.delete(ctx.sessionId);
|
||||
|
||||
ctx.onSessionExit?.(ctx.sessionId, evt);
|
||||
});
|
||||
};
|
||||
|
||||
const runDistroDetection = async (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
auth: { username: string; password?: string; key?: SSHKey; passphrase?: string },
|
||||
sessionId: string,
|
||||
connectionToken: object,
|
||||
) => {
|
||||
if (!ctx.terminalBackend.execAvailable()) return;
|
||||
// Stale-session guard: the renderer reuses ctx.sessionId across
|
||||
// reconnects in the same tab, so comparing sessionIds is not enough.
|
||||
// We compare against a per-connection token instead; if a newer
|
||||
// connect attempt has run, it will have replaced the token in the
|
||||
// module-level map and this check will fail. Repeated after every
|
||||
// await because the session can change during an async call.
|
||||
const isStillCurrent = () => isConnectionTokenCurrent(sessionId, connectionToken);
|
||||
|
||||
if (!isStillCurrent()) return;
|
||||
|
||||
// Step 1: try to classify from the SSH server identification string
|
||||
// captured at handshake time. This is free (no extra channel) and
|
||||
// reliably identifies most network-device vendors (Cisco IOS, Huawei
|
||||
// VRP, HPE Comware, MikroTik, Fortinet, etc.) so we can skip the
|
||||
// POSIX-shell probe entirely for those hosts — which otherwise fails
|
||||
// and, on devices like Cisco / Juniper with AAA logging, generates an
|
||||
// extra session log entry per connect.
|
||||
try {
|
||||
const res = await ctx.terminalBackend.execCommand({
|
||||
hostname: ctx.host.hostname,
|
||||
username: auth.username || "root",
|
||||
port: ctx.host.port || 22,
|
||||
password: auth.password,
|
||||
privateKey: auth.key?.privateKey,
|
||||
passphrase: auth.passphrase ?? auth.key?.passphrase,
|
||||
command: "cat /etc/os-release 2>/dev/null || uname -a",
|
||||
timeout: DISTRO_DETECT_TIMEOUT,
|
||||
});
|
||||
const data = `${res.stdout || ""}\n${res.stderr || ""}`;
|
||||
const idMatch = data.match(/^ID="?([\w-]+)"?$/im);
|
||||
const distro = idMatch
|
||||
? idMatch[1]
|
||||
: (data.split(/\s+/)[0] || "").toLowerCase();
|
||||
if (distro) ctx.onOsDetected?.(ctx.host.id, distro);
|
||||
if (ctx.terminalBackend.getSessionRemoteInfo && sessionId) {
|
||||
const info = await ctx.terminalBackend.getSessionRemoteInfo(sessionId);
|
||||
if (!isStillCurrent()) return;
|
||||
const vendor = detectVendorFromSshVersion(info?.remoteSshVersion);
|
||||
if (vendor) {
|
||||
ctx.onOsDetected?.(ctx.host.id, vendor);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("SSH banner vendor detection failed", err);
|
||||
}
|
||||
|
||||
if (!isStillCurrent()) return;
|
||||
|
||||
// Step 2: unknown or generic OpenSSH/Dropbear — fall back to the
|
||||
// /etc/os-release probe to pick a distro-specific icon. We deliberately
|
||||
// use `getSessionDistroInfo` which runs the probe on the *existing*
|
||||
// SSH connection's exec channel instead of spinning up a brand new
|
||||
// SSH client the way `execCommand` would. That saves a full handshake
|
||||
// round-trip on every connect, and on OpenSSH-fronted network devices
|
||||
// that we couldn't identify from the banner (JUNOS, NX-OS, EOS) it
|
||||
// avoids one extra AAA session log entry per connect.
|
||||
try {
|
||||
if (ctx.terminalBackend.getSessionDistroInfo && sessionId) {
|
||||
const res = await ctx.terminalBackend.getSessionDistroInfo(sessionId);
|
||||
if (!isStillCurrent()) return;
|
||||
if (!res?.success) return;
|
||||
const data = `${res.stdout || ""}\n${res.stderr || ""}`;
|
||||
const idMatch = data.match(/^ID="?([\w-]+)"?$/im);
|
||||
const distro = idMatch
|
||||
? idMatch[1]
|
||||
: (data.split(/\s+/)[0] || "").toLowerCase();
|
||||
if (distro) ctx.onOsDetected?.(ctx.host.id, distro);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("OS probe failed", err);
|
||||
}
|
||||
@@ -296,8 +365,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
const effectivePassphrase = sanitizeCredentialValue(resolvedAuth.passphrase);
|
||||
const hasEncryptedPrimaryPassword = isEncryptedCredentialPlaceholder(resolvedAuth.password);
|
||||
const hasEncryptedPrimaryKey = isEncryptedCredentialPlaceholder(key?.privateKey);
|
||||
let usedKey: SSHKey | undefined;
|
||||
let usedPassword: string | undefined;
|
||||
|
||||
const isAuthError = (err: unknown): boolean => {
|
||||
if (!(err instanceof Error)) return false;
|
||||
@@ -569,7 +636,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
if (hasKeyMaterial) {
|
||||
try {
|
||||
id = await startAttempt({ key });
|
||||
usedKey = key;
|
||||
} catch (err) {
|
||||
if (isAuthError(err) && hasPassword) {
|
||||
ctx.setProgressLogs((prev) => [
|
||||
@@ -577,14 +643,12 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
"Key auth failed. Trying password...",
|
||||
]);
|
||||
id = await startAttempt({ password: effectivePassword });
|
||||
usedPassword = effectivePassword;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
id = await startAttempt({ password: effectivePassword });
|
||||
usedPassword = effectivePassword;
|
||||
}
|
||||
|
||||
if (unsubscribeChainProgress) unsubscribeChainProgress();
|
||||
@@ -611,17 +675,20 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
}, 600);
|
||||
}
|
||||
|
||||
// Run OS detection only after successful connection
|
||||
setTimeout(
|
||||
() =>
|
||||
void runDistroDetection(ctx, {
|
||||
username: effectiveUsername,
|
||||
password: usedPassword,
|
||||
key: usedKey,
|
||||
passphrase: effectivePassphrase,
|
||||
}),
|
||||
600,
|
||||
);
|
||||
// Run OS detection only after successful connection. Mint a fresh
|
||||
// token for this specific connection attempt and register it as
|
||||
// the current one for this sessionId slot; any previous timer
|
||||
// scheduled against an earlier token will see the replacement
|
||||
// and bail out. The detection function re-checks the token after
|
||||
// every async await so a reconnect mid-probe is also caught.
|
||||
{
|
||||
const connectionToken = {};
|
||||
connectionTokensBySessionId.set(id, connectionToken);
|
||||
setTimeout(() => {
|
||||
if (!isConnectionTokenCurrent(id, connectionToken)) return;
|
||||
void runDistroDetection(ctx, id, connectionToken);
|
||||
}, 600);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const authError = isAuthError(err);
|
||||
@@ -761,15 +828,21 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
}
|
||||
|
||||
try {
|
||||
// Get local shell configuration from terminal settings
|
||||
const localShell = ctx.terminalSettings?.localShell;
|
||||
// Per-session shell (from QuickSwitcher discovery or split/copy) takes priority.
|
||||
// The global terminalSettings.localShell may contain a shell ID (e.g., "wsl-ubuntu")
|
||||
// which was already resolved to command+args and stored on the session object by App.tsx.
|
||||
// Only pass shell/shellArgs when we have concrete per-session values;
|
||||
// otherwise omit them so the backend uses its own default shell detection.
|
||||
const sessionShell = ctx.host.localShell;
|
||||
const sessionShellArgs = ctx.host.localShellArgs;
|
||||
const localStartDir = ctx.terminalSettings?.localStartDir;
|
||||
|
||||
const id = await ctx.terminalBackend.startLocalSession({
|
||||
sessionId: ctx.sessionId,
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
shell: localShell,
|
||||
shell: sessionShell || undefined,
|
||||
shellArgs: sessionShellArgs || undefined,
|
||||
cwd: localStartDir,
|
||||
env: {
|
||||
TERM: ctx.terminalSettings?.terminalEmulationType ?? "xterm-256color",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
@@ -26,10 +26,17 @@ import {
|
||||
import {
|
||||
resolveHostTerminalFontFamilyId,
|
||||
resolveHostTerminalFontSize,
|
||||
resolveHostTerminalFontWeight,
|
||||
} from "../../../domain/terminalAppearance";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { isMacPlatform, normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import {
|
||||
clearTerminalViewport,
|
||||
isEraseViewportSequence,
|
||||
isEraseScrollbackSequence,
|
||||
preserveTerminalViewportInScrollback,
|
||||
} from "../clearTerminalViewport";
|
||||
import type {
|
||||
Host,
|
||||
KeyBinding,
|
||||
@@ -128,6 +135,21 @@ const detectPlatform = (): XTermPlatform => {
|
||||
return "darwin";
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the primary font family from a CSS font-family string that may
|
||||
* include fallback fonts. `document.fonts.check` returns `false` when *any*
|
||||
* listed font is still loading, so passing the entire CJK fallback stack
|
||||
* causes false negatives during early terminal creation – which in turn makes
|
||||
* `fontWeightBold` fall back to the normal weight and renders bold text too
|
||||
* thin.
|
||||
*/
|
||||
export const primaryFontFamily = (fontFamily: string): string => {
|
||||
// Split on commas that are NOT inside quotes to handle font names like "Foo, Bar"
|
||||
const match = fontFamily.match(/^(?:"[^"]*"|'[^']*'|[^,])+/);
|
||||
const first = match?.[0]?.trim();
|
||||
return first || fontFamily;
|
||||
};
|
||||
|
||||
export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime => {
|
||||
const platform = detectPlatform();
|
||||
const deviceMemoryGb =
|
||||
@@ -162,7 +184,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const cursorBlink = settings?.cursorBlink ?? true;
|
||||
const scrollback = settings?.scrollback ?? 10000;
|
||||
const drawBoldTextInBrightColors = settings?.drawBoldInBrightColors ?? true;
|
||||
const fontWeight = settings?.fontWeight ?? 400;
|
||||
const fontWeight = resolveHostTerminalFontWeight(ctx.host, settings?.fontWeight ?? 400);
|
||||
const fontWeightBold = settings?.fontWeightBold ?? 700;
|
||||
const lineHeight = 1 + (settings?.linePadding ?? 0) / 10;
|
||||
const minimumContrastRatio = settings?.minimumContrastRatio ?? 1;
|
||||
@@ -179,7 +201,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
if (typeof document === "undefined" || !document.fonts?.check) {
|
||||
return fontWeightBold;
|
||||
}
|
||||
const weightSpec = `${fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
|
||||
const weightSpec = `${fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
|
||||
return document.fonts.check(weightSpec) ? fontWeightBold : fontWeight;
|
||||
})();
|
||||
|
||||
@@ -188,6 +210,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
...(windowsPty ? { windowsPty } : {}),
|
||||
// Override ignoreBracketedPasteMode if user explicitly disables bracketed paste
|
||||
ignoreBracketedPasteMode: settings?.disableBracketedPaste ?? performanceConfig.options.ignoreBracketedPasteMode,
|
||||
// Rescale glyphs that would visually overlap into the next cell (CJK compliance)
|
||||
rescaleOverlappingGlyphs: true,
|
||||
fontSize: effectiveFontSize,
|
||||
fontFamily,
|
||||
fontWeight: fontWeight as
|
||||
@@ -230,6 +254,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
theme: {
|
||||
...ctx.terminalTheme.colors,
|
||||
selectionBackground: ctx.terminalTheme.colors.selection,
|
||||
// Scrollbar theming (xterm 6.0) — derive from foreground color
|
||||
scrollbarSliderBackground: ctx.terminalTheme.colors.foreground + '33', // 20% opacity
|
||||
scrollbarSliderHoverBackground: ctx.terminalTheme.colors.foreground + '66', // 40% opacity
|
||||
scrollbarSliderActiveBackground: ctx.terminalTheme.colors.foreground + '80', // 50% opacity
|
||||
},
|
||||
});
|
||||
|
||||
@@ -307,19 +335,19 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
webglLoaded = true;
|
||||
} catch (webglErr) {
|
||||
logger.warn(
|
||||
"[XTerm] WebGL addon failed, using canvas renderer. Error:",
|
||||
"[XTerm] WebGL addon failed, using DOM renderer. Error:",
|
||||
webglErr instanceof Error ? webglErr.message : webglErr,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
"[XTerm] Skipping WebGL addon (canvas preferred for macOS profile or low-memory devices)",
|
||||
"[XTerm] Skipping WebGL addon (DOM preferred for low-memory devices)",
|
||||
);
|
||||
}
|
||||
|
||||
scopedWindow.__xtermWebGLLoaded = webglLoaded;
|
||||
scopedWindow.__xtermRendererPreference = performanceConfig.preferCanvasRenderer
|
||||
? "canvas"
|
||||
scopedWindow.__xtermRendererPreference = performanceConfig.preferDOMRenderer
|
||||
? "dom"
|
||||
: "webgl";
|
||||
|
||||
const webLinksAddon = new WebLinksAddon((event, uri) => {
|
||||
@@ -354,9 +382,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
});
|
||||
term.loadAddon(webLinksAddon);
|
||||
|
||||
// Enable Unicode 11 for better Nerd Fonts / Powerline / CJK character width handling
|
||||
term.loadAddon(new Unicode11Addon());
|
||||
term.unicode.activeVersion = '11';
|
||||
// Enable Unicode graphemes for accurate CJK / emoji / Nerd Font character width handling
|
||||
const unicodeGraphemes = new UnicodeGraphemesAddon();
|
||||
term.loadAddon(unicodeGraphemes);
|
||||
term.unicode.activeVersion = '15-graphemes';
|
||||
|
||||
logRenderer();
|
||||
|
||||
@@ -475,7 +504,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
break;
|
||||
}
|
||||
case "clearBuffer": {
|
||||
term.clear();
|
||||
clearTerminalViewport(term);
|
||||
break;
|
||||
}
|
||||
case "searchTerminal": {
|
||||
@@ -562,7 +591,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
} else {
|
||||
// Character mode (default): send immediately
|
||||
ctx.terminalBackend.writeToSession(id, data);
|
||||
// When backspaceBehavior is configured, remap the Backspace key output
|
||||
let outData = data;
|
||||
if (data === "\x7f" && ctx.host.backspaceBehavior === "ctrl-h") {
|
||||
outData = "\x08";
|
||||
}
|
||||
ctx.terminalBackend.writeToSession(id, outData);
|
||||
|
||||
// Local echo for serial connections only when explicitly enabled
|
||||
if (ctx.host.protocol === "serial" && ctx.serialLocalEcho) {
|
||||
@@ -579,7 +613,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
|
||||
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
|
||||
ctx.onBroadcastInputRef.current(data, ctx.sessionId);
|
||||
// Use remapped data so broadcast peers also receive the correct byte
|
||||
const broadcastData = (data === "\x7f" && ctx.host.backspaceBehavior === "ctrl-h") ? "\x08" : data;
|
||||
ctx.onBroadcastInputRef.current(broadcastData, ctx.sessionId);
|
||||
}
|
||||
|
||||
scrollToBottomAfterInput(data);
|
||||
@@ -611,6 +647,17 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
// OSC 7 format: \x1b]7;file://hostname/path\x07 or \x1b]7;file://hostname/path\x1b\\
|
||||
let currentCwd: string | undefined = undefined;
|
||||
|
||||
const eraseScrollbackDisposable = term.parser.registerCsiHandler({ final: "J" }, (params) => {
|
||||
if (isEraseViewportSequence(params)) {
|
||||
preserveTerminalViewportInScrollback(term);
|
||||
return false;
|
||||
}
|
||||
if (!isEraseScrollbackSequence(params)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Register OSC 7 handler using xterm.js parser
|
||||
// OSC 7 is the standard way for shells to report the current working directory
|
||||
const osc7Disposable = term.parser.registerOscHandler(7, (data) => {
|
||||
@@ -733,6 +780,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
dispose: () => {
|
||||
cleanupMiddleClick?.();
|
||||
keywordHighlighter.dispose();
|
||||
eraseScrollbackDisposable.dispose();
|
||||
osc7Disposable.dispose();
|
||||
osc52Disposable.dispose();
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ArrowLeft, MoreVertical, X } from 'lucide-react';
|
||||
import React, { createContext, ReactNode, useCallback, useContext, useState } from 'react';
|
||||
import React, { createContext, ReactNode, useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
import { ScrollArea } from './scroll-area';
|
||||
@@ -44,6 +44,12 @@ interface AsidePanelProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
width?: string;
|
||||
layout?: AsidePanelLayout;
|
||||
/**
|
||||
* Optional stable identifier emitted as `data-section` on the panel
|
||||
* root. Used as a targeting hook for Custom CSS (Settings → Appearance).
|
||||
*/
|
||||
dataSection?: string;
|
||||
}
|
||||
|
||||
interface AsidePanelHeaderProps {
|
||||
@@ -101,8 +107,8 @@ export const AsidePanelContent: React.FC<{ children: ReactNode; className?: stri
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<ScrollArea className={cn("flex-1 min-w-0", className)}>
|
||||
<div className="p-4 space-y-4 min-w-0 overflow-x-hidden">
|
||||
<ScrollArea className={cn("flex-1 min-w-0 [&>[data-radix-scroll-area-viewport]>div]:!block [&>[data-radix-scroll-area-viewport]>div]:!min-w-0", className)}>
|
||||
<div className="p-4 space-y-4 min-w-0 overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
@@ -171,14 +177,40 @@ interface AsidePanelStackProps {
|
||||
initialItem: AsideContentItem;
|
||||
className?: string;
|
||||
width?: string;
|
||||
layout?: AsidePanelLayout;
|
||||
/**
|
||||
* Optional stable identifier emitted as `data-section` on the panel
|
||||
* root. Used as a targeting hook for Custom CSS.
|
||||
*/
|
||||
dataSection?: string;
|
||||
}
|
||||
|
||||
export type AsidePanelLayout = 'overlay' | 'inline';
|
||||
|
||||
const resolveInlineWidth = (width: string) => {
|
||||
const arbitraryWidthMatch = width.match(/w-\[(.+)\]/);
|
||||
if (arbitraryWidthMatch) {
|
||||
return arbitraryWidthMatch[1];
|
||||
}
|
||||
|
||||
switch (width) {
|
||||
case 'w-full':
|
||||
return '100%';
|
||||
case 'w-screen':
|
||||
return '100vw';
|
||||
default:
|
||||
return '380px';
|
||||
}
|
||||
};
|
||||
|
||||
export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
initialItem,
|
||||
className,
|
||||
width = 'w-[380px]',
|
||||
layout = 'overlay',
|
||||
dataSection,
|
||||
}) => {
|
||||
const [stack, setStack] = useState<AsideContentItem[]>([initialItem]);
|
||||
|
||||
@@ -205,6 +237,13 @@ export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
|
||||
|
||||
const currentItem = stack[stack.length - 1];
|
||||
const canGoBack = stack.length > 1;
|
||||
const inlineWidth = useMemo(() => resolveInlineWidth(width), [width]);
|
||||
const inlineStyle = layout === 'inline'
|
||||
? ({
|
||||
width: inlineWidth,
|
||||
['--aside-inline-width' as string]: inlineWidth,
|
||||
} as React.CSSProperties)
|
||||
: undefined;
|
||||
|
||||
// Reset stack when panel closes/opens
|
||||
React.useEffect(() => {
|
||||
@@ -218,10 +257,14 @@ export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
|
||||
return (
|
||||
<AsidePanelContext.Provider value={{ push, pop, replace, clear, canGoBack, currentItem }}>
|
||||
<div className={cn(
|
||||
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
|
||||
width,
|
||||
layout === 'inline'
|
||||
? "relative split-panel-enter shrink-0 h-full min-h-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden shadow-[-16px_0_32px_hsl(var(--foreground)/0.08)]"
|
||||
: "absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
|
||||
layout === 'overlay' && width,
|
||||
className
|
||||
)}>
|
||||
)}
|
||||
style={inlineStyle}
|
||||
data-section={dataSection}>
|
||||
<AsidePanelHeader
|
||||
title={currentItem.title}
|
||||
subtitle={currentItem.subtitle}
|
||||
@@ -248,15 +291,29 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
|
||||
children,
|
||||
className,
|
||||
width = 'w-[380px]',
|
||||
layout = 'overlay',
|
||||
dataSection,
|
||||
}) => {
|
||||
if (!open) return null;
|
||||
|
||||
const inlineWidth = resolveInlineWidth(width);
|
||||
const inlineStyle = layout === 'inline'
|
||||
? ({
|
||||
width: inlineWidth,
|
||||
['--aside-inline-width' as string]: inlineWidth,
|
||||
} as React.CSSProperties)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
|
||||
width,
|
||||
layout === 'inline'
|
||||
? "relative split-panel-enter shrink-0 h-full min-h-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden shadow-[-16px_0_32px_hsl(var(--foreground)/0.08)]"
|
||||
: "absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
|
||||
layout === 'overlay' && width,
|
||||
className
|
||||
)}>
|
||||
)}
|
||||
style={inlineStyle}
|
||||
data-section={dataSection}>
|
||||
{title && (
|
||||
<AsidePanelHeader
|
||||
title={title}
|
||||
|
||||
@@ -48,8 +48,19 @@ const DialogContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close
|
||||
data-dialog-close="true"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{t("common.close")}
|
||||
</DialogPrimitive.Close>
|
||||
{!hideCloseButton && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 transition-all hover:bg-muted hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none text-muted-foreground">
|
||||
<DialogPrimitive.Close
|
||||
data-dialog-close="true"
|
||||
className="absolute right-4 top-4 rounded-md p-1 transition-all hover:bg-muted hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none text-muted-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">{t("common.close")}</span>
|
||||
</DialogPrimitive.Close>
|
||||
|
||||
@@ -88,5 +88,17 @@ export const findSyncPayloadEncryptedCredentialPaths = (
|
||||
}
|
||||
});
|
||||
|
||||
payload.groupConfigs?.forEach((config, index) => {
|
||||
if (isEncryptedCredentialPlaceholder(config.password)) {
|
||||
issues.push(`groupConfigs[${index}].password`);
|
||||
}
|
||||
if (isEncryptedCredentialPlaceholder(config.telnetPassword)) {
|
||||
issues.push(`groupConfigs[${index}].telnetPassword`);
|
||||
}
|
||||
if (isEncryptedCredentialPlaceholder(config.proxyConfig?.password)) {
|
||||
issues.push(`groupConfigs[${index}].proxyConfig.password`);
|
||||
}
|
||||
});
|
||||
|
||||
return issues;
|
||||
};
|
||||
|
||||
80
domain/groupConfig.ts
Normal file
80
domain/groupConfig.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { GroupConfig, Host } from './models';
|
||||
|
||||
/**
|
||||
* Resolve merged group defaults by walking the ancestor chain.
|
||||
* For group "A/B/C", merges configs from A, A/B, A/B/C (child overrides parent).
|
||||
*/
|
||||
export function resolveGroupDefaults(
|
||||
groupPath: string,
|
||||
groupConfigs: GroupConfig[],
|
||||
): Partial<GroupConfig> {
|
||||
const configMap = new Map(groupConfigs.map((c) => [c.path, c]));
|
||||
const parts = groupPath.split('/').filter(Boolean);
|
||||
const merged: Record<string, unknown> = {};
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const ancestorPath = parts.slice(0, i + 1).join('/');
|
||||
const config = configMap.get(ancestorPath);
|
||||
if (config) {
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (
|
||||
(key === 'theme' && config.themeOverride === false) ||
|
||||
(key === 'fontFamily' && config.fontFamilyOverride === false) ||
|
||||
(key === 'fontSize' && config.fontSizeOverride === false) ||
|
||||
(key === 'fontWeight' && config.fontWeightOverride === false)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (key !== 'path' && value !== undefined) {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
if (config.themeOverride === false) {
|
||||
delete merged.themeOverride;
|
||||
}
|
||||
if (config.fontFamilyOverride === false) {
|
||||
delete merged.fontFamilyOverride;
|
||||
}
|
||||
if (config.fontSizeOverride === false) {
|
||||
delete merged.fontSizeOverride;
|
||||
}
|
||||
if (config.fontWeightOverride === false) {
|
||||
delete merged.fontWeightOverride;
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged as Partial<GroupConfig>;
|
||||
}
|
||||
|
||||
const INHERITABLE_KEYS: (keyof GroupConfig)[] = [
|
||||
'username', 'password', 'savePassword', 'authMethod', 'identityId', 'identityFileId', 'identityFilePaths',
|
||||
'port', 'protocol', 'agentForwarding', 'proxyConfig', 'hostChain', 'startupCommand',
|
||||
'legacyAlgorithms', 'environmentVariables', 'charset', 'moshEnabled', 'moshServerPath',
|
||||
'telnetEnabled', 'telnetPort', 'telnetUsername', 'telnetPassword',
|
||||
'theme', 'themeOverride', 'fontFamily', 'fontFamilyOverride', 'fontSize', 'fontSizeOverride', 'fontWeight', 'fontWeightOverride',
|
||||
'backspaceBehavior',
|
||||
];
|
||||
|
||||
/**
|
||||
* Apply group defaults to a host. Only fills in fields the host doesn't already have.
|
||||
* Returns a new host object — does NOT mutate the original.
|
||||
*/
|
||||
export function applyGroupDefaults(host: Host, groupDefaults: Partial<GroupConfig>): Host {
|
||||
const effective = { ...host };
|
||||
for (const key of INHERITABLE_KEYS) {
|
||||
const hostValue = (host as unknown as Record<string, unknown>)[key];
|
||||
const groupValue = (groupDefaults as unknown as Record<string, unknown>)[key];
|
||||
if ((hostValue === undefined || hostValue === '' || hostValue === null) && groupValue !== undefined) {
|
||||
(effective as unknown as Record<string, unknown>)[key] = groupValue;
|
||||
}
|
||||
}
|
||||
return effective;
|
||||
}
|
||||
|
||||
export function resolveGroupTerminalThemeId(
|
||||
groupDefaults: Partial<GroupConfig> | undefined,
|
||||
fallbackThemeId: string,
|
||||
): string {
|
||||
if (!groupDefaults) return fallbackThemeId;
|
||||
return groupDefaults.theme || fallbackThemeId;
|
||||
}
|
||||
@@ -17,6 +17,26 @@ export const LINUX_DISTRO_OPTIONS = [
|
||||
'kali',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Known network-device vendor IDs that Netcatty can detect from the SSH
|
||||
* server identification string. When a host is classified as one of these,
|
||||
* features that assume a POSIX shell (e.g. the periodic server stats poll)
|
||||
* are disabled, because the stats command would either be rejected outright
|
||||
* or generate one AAA session log per poll on the remote device.
|
||||
*/
|
||||
export const NETWORK_DEVICE_OPTIONS = [
|
||||
'cisco',
|
||||
'juniper',
|
||||
'huawei',
|
||||
'hpe',
|
||||
'mikrotik',
|
||||
'fortinet',
|
||||
'paloalto',
|
||||
'zyxel',
|
||||
] as const;
|
||||
|
||||
export type NetworkDeviceVendor = typeof NETWORK_DEVICE_OPTIONS[number];
|
||||
|
||||
export const normalizeDistroId = (value?: string) => {
|
||||
const v = (value || '').toLowerCase().trim();
|
||||
if (!v) return '';
|
||||
@@ -33,10 +53,87 @@ export const normalizeDistroId = (value?: string) => {
|
||||
if (v.includes('almalinux')) return 'almalinux';
|
||||
if (v.includes('oracle')) return 'oracle';
|
||||
if (v.includes('kali')) return 'kali';
|
||||
// Network device vendor IDs may arrive here after detection — preserve them.
|
||||
if ((NETWORK_DEVICE_OPTIONS as readonly string[]).includes(v)) return v;
|
||||
if (v === 'linux' || v.includes('linux')) return 'linux';
|
||||
return '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse the SSH server identification string (the `software` portion of
|
||||
* `SSH-<protocol>-<software>\r\n`, exposed by ssh2 as `conn._remoteVer`)
|
||||
* and return a normalized network-device vendor ID, or '' if the ident
|
||||
* does not match a known vendor.
|
||||
*
|
||||
* Matching patterns are sourced from the Nmap nmap-service-probes
|
||||
* database (match lines beginning with `match ssh m|^SSH-`), cross-
|
||||
* referenced with the ssh-audit project's software.py and vendor docs.
|
||||
* Only rules whose pattern can be reproduced from those sources are
|
||||
* included here.
|
||||
*
|
||||
* Empty string means "use the fallback linux/macos detection path" —
|
||||
* that is what happens for OpenSSH, Dropbear, JUNOS, Cisco NX-OS, and
|
||||
* Arista EOS, all of which either are POSIX systems or present as
|
||||
* plain `OpenSSH_*` with no distinct vendor marker.
|
||||
*/
|
||||
export const detectVendorFromSshVersion = (softwareVersion?: string): '' | NetworkDeviceVendor => {
|
||||
const s = (softwareVersion || '').trim();
|
||||
if (!s) return '';
|
||||
|
||||
// Cisco family — IOS, IOS XA, Wireless LAN Controller
|
||||
if (/^Cisco[-_]/i.test(s)) return 'cisco';
|
||||
if (/^CiscoIOS_/i.test(s)) return 'cisco';
|
||||
if (/^CISCO_WLC\b/.test(s)) return 'cisco';
|
||||
// Note: `IPSSH-*` is used by both Cisco and 3Com devices (per Nmap
|
||||
// `match ssh m|^SSH-([\d.]+)-IPSSH-([\d.]+)\|`), so we cannot map it
|
||||
// to a specific vendor icon from the banner alone. Users who want a
|
||||
// custom icon for such devices can set one via the Host Details
|
||||
// manual distro override. The stats-polling gate is still handled
|
||||
// correctly via `host.deviceType === 'network'`.
|
||||
|
||||
// Juniper NetScreen firewall (JUNOS itself uses OpenSSH and is caught
|
||||
// by the fallback failure-counter path in useServerStats).
|
||||
if (/^NetScreen\b/.test(s)) return 'juniper';
|
||||
|
||||
// Huawei VRP and related products
|
||||
if (/^HUAWEI[-_]/i.test(s)) return 'huawei';
|
||||
if (/^VRP-/i.test(s)) return 'huawei';
|
||||
|
||||
// HPE / H3C — Comware switches, Integrated Lights-Out (iLO), legacy 3Com
|
||||
if (/^Comware-/i.test(s)) return 'hpe';
|
||||
if (/^3Com\s*OS/i.test(s)) return 'hpe';
|
||||
if (/^mpSSH_/i.test(s)) return 'hpe';
|
||||
|
||||
// MikroTik RouterOS
|
||||
if (/^ROSSSH\b/.test(s)) return 'mikrotik';
|
||||
|
||||
// Fortinet FortiOS / FortiGate
|
||||
if (/^FortiSSH_/i.test(s)) return 'fortinet';
|
||||
|
||||
// Palo Alto Networks PAN-OS
|
||||
if (/^PaloAltoNetworks[_-]/i.test(s)) return 'paloalto';
|
||||
|
||||
// ZyXEL ZyWALL
|
||||
if (/^Zyxel\s*SSH/i.test(s)) return 'zyxel';
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Classify a distro/vendor ID into a high-level device class. Features that
|
||||
* assume a POSIX shell (periodic stats polling, /etc/os-release probing, etc.)
|
||||
* should only run when this returns `linux-like`.
|
||||
*/
|
||||
export type DeviceClass = 'linux-like' | 'network-device' | 'other';
|
||||
|
||||
export const classifyDistroId = (distroId?: string): DeviceClass => {
|
||||
const v = (distroId || '').toLowerCase().trim();
|
||||
if (!v) return 'other';
|
||||
if ((NETWORK_DEVICE_OPTIONS as readonly string[]).includes(v)) return 'network-device';
|
||||
if ((LINUX_DISTRO_OPTIONS as readonly string[]).includes(v)) return 'linux-like';
|
||||
return 'other';
|
||||
};
|
||||
|
||||
export const getEffectiveHostDistro = (
|
||||
host?: Pick<Host, 'distro' | 'manualDistro' | 'distroMode'> | null,
|
||||
) => {
|
||||
|
||||
@@ -62,7 +62,7 @@ export interface Host {
|
||||
id: string;
|
||||
label: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
port?: number;
|
||||
username: string;
|
||||
// Optional reference to a reusable identity (username + auth) stored in Keychain.
|
||||
identityId?: string;
|
||||
@@ -95,6 +95,8 @@ export interface Host {
|
||||
fontFamilyOverride?: boolean; // Explicitly override the global terminal font family for this host
|
||||
fontSize?: number; // Terminal font size for this host (pt)
|
||||
fontSizeOverride?: boolean; // Explicitly override the global terminal font size for this host
|
||||
fontWeight?: number; // Terminal font weight for this host (100-900)
|
||||
fontWeightOverride?: boolean; // Explicitly override the global terminal font weight for this host
|
||||
distro?: string; // detected distro id (e.g., ubuntu, debian)
|
||||
distroMode?: 'auto' | 'manual'; // whether distro icon comes from detection or manual override
|
||||
manualDistro?: string; // manually selected distro id when distroMode='manual'
|
||||
@@ -117,9 +119,20 @@ export interface Host {
|
||||
keywordHighlightEnabled?: boolean;
|
||||
// Legacy SSH algorithm support for older network equipment (switches, routers)
|
||||
legacyAlgorithms?: boolean;
|
||||
// What the Backspace key sends: undefined = xterm default (no interception), 'ctrl-h' = ^H (0x08)
|
||||
backspaceBehavior?: 'ctrl-h';
|
||||
// Local SSH key file paths (from SSH config IdentityFile or user-added)
|
||||
// Resolved at connection time — the app reads the file content when connecting.
|
||||
identityFilePaths?: string[];
|
||||
// Pin host to top of All hosts view for quick access
|
||||
pinned?: boolean;
|
||||
// Timestamp of last successful connection, used for Recently Connected section
|
||||
lastConnectedAt?: number;
|
||||
// Per-session shell override for local terminals (from shell discovery)
|
||||
localShell?: string;
|
||||
localShellArgs?: string[];
|
||||
localShellName?: string;
|
||||
localShellIcon?: string;
|
||||
}
|
||||
|
||||
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
|
||||
@@ -178,6 +191,42 @@ export interface GroupNode {
|
||||
totalHostCount?: number;
|
||||
}
|
||||
|
||||
/** Default configuration for a group. Hosts in this group inherit these values when not explicitly set. */
|
||||
export interface GroupConfig {
|
||||
path: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
savePassword?: boolean;
|
||||
authMethod?: 'password' | 'key' | 'certificate';
|
||||
identityId?: string;
|
||||
identityFileId?: string;
|
||||
identityFilePaths?: string[];
|
||||
port?: number;
|
||||
protocol?: 'ssh' | 'telnet';
|
||||
agentForwarding?: boolean;
|
||||
proxyConfig?: ProxyConfig;
|
||||
hostChain?: HostChainConfig;
|
||||
startupCommand?: string;
|
||||
legacyAlgorithms?: boolean;
|
||||
environmentVariables?: EnvVar[];
|
||||
charset?: string;
|
||||
moshEnabled?: boolean;
|
||||
moshServerPath?: string;
|
||||
telnetEnabled?: boolean;
|
||||
telnetPort?: number;
|
||||
telnetUsername?: string;
|
||||
telnetPassword?: string;
|
||||
theme?: string;
|
||||
themeOverride?: boolean;
|
||||
fontFamily?: string;
|
||||
fontFamilyOverride?: boolean;
|
||||
fontSize?: number;
|
||||
fontSizeOverride?: boolean;
|
||||
fontWeight?: number;
|
||||
fontWeightOverride?: boolean;
|
||||
backspaceBehavior?: 'ctrl-h';
|
||||
}
|
||||
|
||||
export interface SyncConfig {
|
||||
gistId: string;
|
||||
githubToken: string;
|
||||
@@ -451,7 +500,7 @@ export interface TerminalSettings {
|
||||
osc52Clipboard: 'off' | 'write-only' | 'read-write' | 'prompt'; // OSC-52 clipboard access: off, write-only (default), read-write, or prompt on read
|
||||
|
||||
// Rendering
|
||||
rendererType: 'auto' | 'webgl' | 'canvas'; // Terminal renderer: auto (detect based on hardware), webgl, or canvas
|
||||
rendererType: 'auto' | 'webgl' | 'dom'; // Terminal renderer: auto (detect based on hardware), webgl, or dom
|
||||
|
||||
// Autocomplete
|
||||
autocompleteEnabled: boolean; // Enable terminal command autocomplete
|
||||
@@ -527,8 +576,14 @@ export const normalizeTerminalSettings = (
|
||||
...(settings ?? {}),
|
||||
};
|
||||
|
||||
// Migrate legacy 'canvas' renderer to 'dom' (canvas removed in xterm.js 6.0)
|
||||
const rendererType = (mergedSettings.rendererType as string) === 'canvas'
|
||||
? 'dom' as const
|
||||
: mergedSettings.rendererType;
|
||||
|
||||
return {
|
||||
...mergedSettings,
|
||||
rendererType,
|
||||
autocompleteGhostText: mergedSettings.autocompletePopupMenu
|
||||
? false
|
||||
: mergedSettings.autocompleteGhostText,
|
||||
@@ -626,6 +681,10 @@ export interface TerminalSession {
|
||||
charset?: string; // Connection-time charset override (e.g. for quick-connect serial)
|
||||
// Serial-specific connection settings
|
||||
serialConfig?: SerialConfig;
|
||||
localShell?: string; // Shell command for local terminals (from discovery)
|
||||
localShellArgs?: string[]; // Shell args for local terminals (from discovery)
|
||||
localShellName?: string; // Display name for local shell (e.g., "Zsh", "Ubuntu (WSL)")
|
||||
localShellIcon?: string; // Icon identifier for local shell (e.g., "zsh", "ubuntu")
|
||||
}
|
||||
|
||||
export interface RemoteFile {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user