Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
160
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';
|
||||
@@ -35,6 +36,7 @@ import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './componen
|
||||
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';
|
||||
@@ -185,6 +187,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
terminalFontSize,
|
||||
setTerminalFontSize,
|
||||
terminalSettings,
|
||||
updateTerminalSetting,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
isHotkeyRecording,
|
||||
@@ -200,10 +203,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 +243,11 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
deleteConnectionLog,
|
||||
clearUnsavedConnectionLogs,
|
||||
updateHostDistro,
|
||||
updateHostLastConnected,
|
||||
convertKnownHostToHost,
|
||||
importDataFromString,
|
||||
groupConfigs,
|
||||
updateGroupConfigs,
|
||||
} = useVaultState();
|
||||
|
||||
const {
|
||||
@@ -305,6 +312,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],
|
||||
@@ -352,7 +361,6 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, [activeTabId, currentTerminalTheme, hostById, sessionById, themeById, workspaceById]);
|
||||
|
||||
useImmersiveMode({
|
||||
isImmersive: immersiveMode,
|
||||
activeTabId,
|
||||
activeTerminalTheme,
|
||||
restoreOriginalTheme: reapplyCurrentTheme,
|
||||
@@ -382,6 +390,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
snippetPackages,
|
||||
portForwardingRules: portForwardingRulesForSync,
|
||||
knownHosts,
|
||||
groupConfigs,
|
||||
settingsVersion: settings.settingsVersion,
|
||||
onApplyPayload: (payload) => {
|
||||
applySyncPayload(payload, {
|
||||
@@ -426,7 +435,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 +451,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 +472,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 +491,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 +642,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
groupConfigs,
|
||||
});
|
||||
|
||||
// Sync tray menu data + handle tray actions
|
||||
@@ -802,22 +834,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) => {
|
||||
@@ -1032,6 +1079,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 +1103,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 +1129,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 +1162,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 +1177,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 +1242,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 +1268,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) => {
|
||||
@@ -1278,7 +1359,7 @@ 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}
|
||||
hosts={hosts}
|
||||
@@ -1299,7 +1380,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 +1410,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onConnectSerial={handleConnectSerial}
|
||||
onDeleteHost={handleDeleteHost}
|
||||
onConnect={handleConnectToHost}
|
||||
groupConfigs={groupConfigs}
|
||||
onUpdateGroupConfigs={updateGroupConfigs}
|
||||
onUpdateHosts={updateHosts}
|
||||
onUpdateKeys={updateKeys}
|
||||
onUpdateIdentities={updateIdentities}
|
||||
@@ -1355,6 +1438,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
groupConfigs={groupConfigs}
|
||||
updateHosts={updateHosts}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
@@ -1369,6 +1453,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
<TerminalLayerMount
|
||||
hosts={hosts}
|
||||
groupConfigs={groupConfigs}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
snippets={snippets}
|
||||
@@ -1387,8 +1472,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])}
|
||||
@@ -1451,8 +1537,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
}}
|
||||
onCreateLocalTerminal={() => {
|
||||
handleCreateLocalTerminal();
|
||||
onCreateLocalTerminal={(shell) => {
|
||||
handleCreateLocalTerminal(shell);
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
}}
|
||||
|
||||
@@ -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,9 @@ const en: Messages = {
|
||||
'settings.application.github.subtitle': 'Source code',
|
||||
'settings.application.whatsNew': "What's new",
|
||||
'settings.application.whatsNew.subtitle': 'Show release notes',
|
||||
'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,9 +235,6 @@ 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.',
|
||||
@@ -327,6 +328,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 +343,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 +366,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
|
||||
@@ -461,8 +475,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 +516,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',
|
||||
@@ -882,6 +916,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',
|
||||
@@ -979,6 +1015,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',
|
||||
@@ -1186,6 +1224,7 @@ 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',
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ const zhCN: Messages = {
|
||||
'common.connect': '连接',
|
||||
'common.terminal': '终端',
|
||||
'common.create': '创建',
|
||||
'common.add': '添加',
|
||||
'common.rename': '重命名',
|
||||
'common.refresh': '刷新',
|
||||
'common.continue': '继续',
|
||||
@@ -180,6 +181,9 @@ const zhCN: Messages = {
|
||||
'settings.application.github.subtitle': '源代码',
|
||||
'settings.application.whatsNew': '更新内容',
|
||||
'settings.application.whatsNew.subtitle': '查看发布说明',
|
||||
'settings.vault.title': '主机库',
|
||||
'settings.vault.showRecentHosts': '显示最近连接的主机',
|
||||
'settings.vault.showRecentHostsDesc': '在主机列表顶部显示最近连接过的主机',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': '发现新版本',
|
||||
@@ -215,9 +219,6 @@ const zhCN: Messages = {
|
||||
'settings.appearance.themeColor.desc': '为浅色与深色主题选择预设配色',
|
||||
'settings.appearance.themeColor.light': '浅色主题',
|
||||
'settings.appearance.themeColor.dark': '深色主题',
|
||||
'settings.appearance.immersiveMode': '沉浸模式',
|
||||
'settings.appearance.immersiveMode.desc':
|
||||
'启用后,UI 外观(标签栏、侧边栏、状态栏)会自动适配当前终端主题的配色,营造视觉一体化的沉浸体验。',
|
||||
'settings.appearance.customCss': '自定义 CSS',
|
||||
'settings.appearance.customCss.desc': '使用自定义 CSS 个性化界面,修改会立即生效。',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
@@ -294,8 +295,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 +336,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': '该主机未保存密码',
|
||||
@@ -542,6 +563,8 @@ const zhCN: Messages = {
|
||||
'qs.search.placeholder': '搜索主机或标签页',
|
||||
'qs.jumpTo': '跳转到',
|
||||
'qs.localTerminal': '本地终端',
|
||||
'qs.localShells': '本地 Shell',
|
||||
'qs.default': '默认',
|
||||
|
||||
// Select Host panel
|
||||
'selectHost.title': '选择主机',
|
||||
@@ -635,6 +658,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': '直连',
|
||||
@@ -814,6 +839,7 @@ const zhCN: Messages = {
|
||||
'terminal.themeModal.globalTheme': '全局主题',
|
||||
'terminal.themeModal.globalFont': '全局字体',
|
||||
'terminal.themeModal.fontSize': '字体大小',
|
||||
'terminal.themeModal.fontWeight': '字体粗细',
|
||||
'terminal.themeModal.livePreview': '实时预览',
|
||||
'terminal.themeModal.themeType': '{type} 主题',
|
||||
|
||||
@@ -1283,6 +1309,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)。留空使用系统默认。',
|
||||
@@ -1290,6 +1324,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': '用户主目录',
|
||||
@@ -1308,7 +1347,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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -32,6 +32,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;
|
||||
|
||||
@@ -95,6 +96,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
snippetPackages: config.snippetPackages,
|
||||
portForwardingRules: effectivePFRules,
|
||||
knownHosts: effectiveKnownHosts,
|
||||
groupConfigs: config.groupConfigs,
|
||||
};
|
||||
}, [
|
||||
config.hosts,
|
||||
@@ -105,6 +107,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
config.snippetPackages,
|
||||
config.portForwardingRules,
|
||||
config.knownHosts,
|
||||
config.groupConfigs,
|
||||
]);
|
||||
|
||||
// Build sync payload
|
||||
|
||||
@@ -151,12 +151,10 @@ function removeImmersiveStyle() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useImmersiveMode({
|
||||
isImmersive,
|
||||
activeTabId,
|
||||
activeTerminalTheme,
|
||||
restoreOriginalTheme,
|
||||
}: {
|
||||
isImmersive: boolean;
|
||||
activeTabId: string;
|
||||
activeTerminalTheme: TerminalTheme | null;
|
||||
restoreOriginalTheme: () => void;
|
||||
@@ -170,18 +168,18 @@ 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);
|
||||
}
|
||||
}, [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 +196,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);
|
||||
|
||||
@@ -33,7 +33,7 @@ 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';
|
||||
@@ -125,7 +125,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);
|
||||
@@ -340,20 +340,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 +451,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;
|
||||
@@ -625,9 +603,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));
|
||||
}
|
||||
@@ -671,7 +646,7 @@ export const useSettingsState = () => {
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, immersiveMode,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
});
|
||||
settingsSnapshotRef.current = {
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
@@ -680,7 +655,7 @@ export const useSettingsState = () => {
|
||||
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)
|
||||
@@ -849,13 +824,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') {
|
||||
@@ -1247,8 +1215,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 +1225,7 @@ export const useSettingsState = () => {
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
customThemes, immersiveMode, workspaceFocusStyle,
|
||||
customThemes, workspaceFocusStyle,
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
1144
components/GroupDetailsPanel.tsx
Normal file
@@ -99,6 +99,7 @@ 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>;
|
||||
}
|
||||
|
||||
const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
@@ -116,6 +117,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onCancel,
|
||||
onCreateGroup,
|
||||
onCreateTag,
|
||||
groupDefaults,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
@@ -126,13 +128,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
|
||||
@@ -282,12 +284,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 +313,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 +361,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,
|
||||
@@ -752,8 +750,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 +804,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 +823,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 +1262,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 +1287,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 +1400,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 +1605,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 +1765,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 +1788,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 +1852,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,6 +32,7 @@ interface HostTreeViewProps {
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
|
||||
isMultiSelectMode?: boolean;
|
||||
selectedHostIds?: Set<string>;
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
@@ -56,6 +57,7 @@ interface TreeNodeProps {
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
|
||||
isMultiSelectMode?: boolean;
|
||||
selectedHostIds?: Set<string>;
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
@@ -81,6 +83,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
toggleHostSelection,
|
||||
@@ -176,6 +179,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,6 +238,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
@@ -244,6 +257,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
@@ -264,6 +278,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 +293,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
moveHostToGroup: _moveHostToGroup,
|
||||
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
toggleHostSelection,
|
||||
@@ -348,6 +364,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 +389,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,6 +421,7 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
toggleHostSelection,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,16 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
keyBindings,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
|
||||
const filteredShells = useMemo(() => {
|
||||
if (!query.trim()) return discoveredShells;
|
||||
const q = query.toLowerCase();
|
||||
return discoveredShells.filter(
|
||||
(s) => s.name.toLowerCase().includes(q) || s.id.toLowerCase().includes(q)
|
||||
);
|
||||
}, [discoveredShells, query]);
|
||||
|
||||
// Get hotkey display strings
|
||||
const getHotkeyLabel = useCallback((actionId: string) => {
|
||||
const binding = keyBindings?.find(k => k.id === actionId);
|
||||
@@ -155,13 +166,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 +192,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 +231,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 +398,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 +465,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>
|
||||
|
||||
@@ -113,6 +113,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
knownHosts,
|
||||
groupConfigs,
|
||||
importDataFromString,
|
||||
clearVaultData,
|
||||
} = useVaultState();
|
||||
@@ -132,8 +133,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 +155,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 +282,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setUiLanguage={settings.setUiLanguage}
|
||||
customCSS={settings.customCSS}
|
||||
setCustomCSS={settings.setCustomCSS}
|
||||
isImmersive={isImmersive}
|
||||
onToggleImmersive={toggleImmersive}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -671,6 +671,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
handleSaveTextFile={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
showFileOpenerDialog={showFileOpenerDialog}
|
||||
setShowFileOpenerDialog={setShowFileOpenerDialog}
|
||||
fileOpenerTarget={fileOpenerTarget}
|
||||
@@ -700,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";
|
||||
|
||||
@@ -51,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";
|
||||
@@ -67,6 +69,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
groupConfigs = [],
|
||||
updateHosts,
|
||||
sftpDefaultViewMode,
|
||||
sftpDoubleClickBehavior,
|
||||
@@ -104,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 {
|
||||
@@ -454,6 +467,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
handleSaveTextFile={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
showFileOpenerDialog={showFileOpenerDialog}
|
||||
setShowFileOpenerDialog={setShowFileOpenerDialog}
|
||||
fileOpenerTarget={fileOpenerTarget}
|
||||
@@ -471,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 &&
|
||||
|
||||
@@ -576,10 +576,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
|
||||
@@ -923,6 +928,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]);
|
||||
@@ -936,7 +944,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
|
||||
@@ -954,7 +962,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
|
||||
return document.fonts.check(weightSpec)
|
||||
? terminalSettings.fontWeightBold
|
||||
: terminalSettings.fontWeight;
|
||||
: effectiveFontWeight;
|
||||
})();
|
||||
|
||||
termRef.current.options.fontWeightBold = resolvedFontWeightBold as
|
||||
@@ -989,7 +997,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
lastFittedSizeRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [effectiveFontSize, resolvedFontFamily, terminalSettings]);
|
||||
}, [effectiveFontSize, effectiveFontWeight, resolvedFontFamily, terminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
@@ -1041,7 +1049,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
|
||||
const resolvedBold = document.fonts.check(weightSpec)
|
||||
? terminalSettings.fontWeightBold
|
||||
: terminalSettings.fontWeight;
|
||||
: effectiveFontWeight;
|
||||
termRef.current.options.fontWeightBold = resolvedBold as
|
||||
| 100
|
||||
| 200
|
||||
@@ -1072,7 +1080,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [effectiveFontSize, resizeSession, terminalSettings]);
|
||||
}, [effectiveFontSize, effectiveFontWeight, resizeSession, terminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !containerRef.current || !fitAddonRef.current) return;
|
||||
@@ -1115,6 +1123,26 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return () => clearTimeout(timer);
|
||||
}, [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) {
|
||||
@@ -1572,7 +1600,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)',
|
||||
|
||||
@@ -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';
|
||||
@@ -338,6 +342,7 @@ AIChatPanelsHost.displayName = 'AIChatPanelsHost';
|
||||
|
||||
interface TerminalLayerProps {
|
||||
hosts: Host[];
|
||||
groupConfigs: GroupConfig[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
snippets: Snippet[];
|
||||
@@ -356,6 +361,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 +397,7 @@ interface TerminalLayerProps {
|
||||
|
||||
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
hosts,
|
||||
groupConfigs,
|
||||
keys,
|
||||
identities,
|
||||
snippets,
|
||||
@@ -409,6 +416,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onUpdateTerminalThemeId,
|
||||
onUpdateTerminalFontFamilyId,
|
||||
onUpdateTerminalFontSize,
|
||||
onUpdateTerminalFontWeight,
|
||||
onCloseSession,
|
||||
onUpdateSessionStatus,
|
||||
onUpdateHostDistro,
|
||||
@@ -770,8 +778,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 +818,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 +835,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>();
|
||||
@@ -1366,9 +1391,11 @@ 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 activeTopTabsThemeId = activeSidePanelTab === 'theme' && previewTargetSessionId
|
||||
? (activeThemePreviewId ?? focusedThemeId)
|
||||
: null;
|
||||
: (isVisible ? focusedThemeId : null);
|
||||
const appliedPreviewSessionRef = useRef<string | null>(null);
|
||||
const customThemes = useCustomThemes();
|
||||
const applyTerminalPreviewVars = useCallback((sessionId: string | null, themeId: string | null) => {
|
||||
@@ -1460,9 +1487,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}, [activeTopTabsThemeId, applyTopTabsPreviewVars]);
|
||||
|
||||
useEffect(() => {
|
||||
const panelOpen = activeSidePanelTab === 'theme' && !!previewTargetSessionId;
|
||||
const shouldKeepPreview =
|
||||
activeSidePanelTab === 'theme' &&
|
||||
!!previewTargetSessionId &&
|
||||
panelOpen &&
|
||||
!!themePreview.targetSessionId &&
|
||||
!!themePreview.themeId;
|
||||
|
||||
@@ -1473,8 +1500,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
clearTerminalPreviewVars(appliedSessionId);
|
||||
appliedPreviewSessionRef.current = null;
|
||||
}
|
||||
clearTopTabsPreviewVars();
|
||||
|
||||
if (themePreview.targetSessionId || themePreview.themeId) {
|
||||
setThemePreview({ targetSessionId: null, themeId: null });
|
||||
}
|
||||
@@ -1551,6 +1576,29 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onUpdateHost(clearHostFontSizeOverride(focusedHost));
|
||||
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
|
||||
|
||||
const handleFontWeightChangeForFocusedSession = useCallback((newFontWeight: number) => {
|
||||
if (!focusedHost || newFontWeight === focusedFontWeight) return;
|
||||
startTransition(() => {
|
||||
if (isFocusedHostLocal) {
|
||||
onUpdateTerminalFontWeight?.(newFontWeight);
|
||||
return;
|
||||
}
|
||||
// Patch only fontWeight fields on the 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, isFocusedHostLocal, onUpdateTerminalFontWeight, onUpdateHost, hostMap]);
|
||||
|
||||
const handleFontWeightResetForFocusedSession = useCallback(() => {
|
||||
if (!focusedHost || isFocusedHostLocal) return;
|
||||
const rawHost = hostMap.get(focusedHost.id);
|
||||
if (rawHost) {
|
||||
onUpdateHost(clearHostFontWeightOverride(rawHost));
|
||||
}
|
||||
}, [focusedHost, isFocusedHostLocal, 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
|
||||
// or hiding the panel never tears down approval handling mid-execution.
|
||||
@@ -2021,15 +2069,19 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
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>
|
||||
|
||||
@@ -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
|
||||
@@ -138,6 +141,8 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
onSave,
|
||||
editorWordWrap,
|
||||
onToggleWordWrap,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { readClipboardText: readClipboardTextFromBridge } = useClipboardBackend();
|
||||
@@ -216,6 +221,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 +357,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 +405,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">
|
||||
|
||||
124
components/ThemeList.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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 } 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 { 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 };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 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,10 @@
|
||||
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,
|
||||
} from './ui/aside-panel';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { ThemeList } from './ThemeList';
|
||||
|
||||
interface ThemeSelectPanelProps {
|
||||
open: boolean;
|
||||
@@ -18,40 +15,6 @@ interface ThemeSelectPanelProps {
|
||||
showBackButton?: boolean;
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -60,51 +23,6 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
|
||||
onBack,
|
||||
showBackButton = true,
|
||||
}) => {
|
||||
// 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}
|
||||
@@ -116,8 +34,10 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
|
||||
<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';
|
||||
@@ -54,7 +55,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 +69,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) {
|
||||
@@ -522,7 +534,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 +552,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 +633,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 */}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CheckSquare,
|
||||
ChevronDown,
|
||||
ClipboardCopy,
|
||||
Clock,
|
||||
Copy,
|
||||
Download,
|
||||
Edit2,
|
||||
@@ -15,11 +16,13 @@ import {
|
||||
LayoutGrid,
|
||||
List,
|
||||
Network,
|
||||
Pin,
|
||||
Plug,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
Square,
|
||||
Star,
|
||||
TerminalSquare,
|
||||
Trash2,
|
||||
Upload,
|
||||
@@ -27,19 +30,21 @@ import {
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { Suspense, lazy, memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { getEffectiveHostDistro, sanitizeHost } from "../domain/host";
|
||||
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
|
||||
import type { VaultImportFormat } from "../domain/vaultImport";
|
||||
import { STORAGE_KEY_VAULT_HOSTS_VIEW_MODE, STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED, STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED } from "../infrastructure/config/storageKeys";
|
||||
import { STORAGE_KEY_VAULT_HOSTS_VIEW_MODE, STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED, STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED, STORAGE_KEY_SHOW_RECENT_HOSTS } from "../infrastructure/config/storageKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
|
||||
import {
|
||||
ConnectionLog,
|
||||
GroupConfig,
|
||||
GroupNode,
|
||||
Host,
|
||||
HostProtocol,
|
||||
@@ -54,6 +59,7 @@ import {
|
||||
} from "../types";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import GroupDetailsPanel from "./GroupDetailsPanel";
|
||||
import HostDetailsPanel from "./HostDetailsPanel";
|
||||
import { HostTreeView } from "./HostTreeView";
|
||||
import KeychainManager from "./KeychainManager";
|
||||
@@ -135,6 +141,8 @@ interface VaultViewProps {
|
||||
onClearUnsavedConnectionLogs: () => void;
|
||||
onOpenLogView: (log: ConnectionLog) => void;
|
||||
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
|
||||
groupConfigs: GroupConfig[];
|
||||
onUpdateGroupConfigs: (configs: GroupConfig[]) => void;
|
||||
// Optional: navigate to a specific section on mount or when changed
|
||||
navigateToSection?: VaultSection | null;
|
||||
onNavigateToSectionHandled?: () => void;
|
||||
@@ -179,11 +187,15 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onClearUnsavedConnectionLogs,
|
||||
onOpenLogView,
|
||||
onRunSnippet,
|
||||
groupConfigs,
|
||||
onUpdateGroupConfigs,
|
||||
navigateToSection,
|
||||
onNavigateToSectionHandled,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const hostsRef = useRef(hosts);
|
||||
hostsRef.current = hosts;
|
||||
const [currentSection, setCurrentSection] = useState<VaultSection>("hosts");
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedGroupPath, setSelectedGroupPath] = useState<string | null>(
|
||||
@@ -210,6 +222,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
false,
|
||||
);
|
||||
|
||||
const [isBreadcrumbDragOver, setIsBreadcrumbDragOver] = useState(false);
|
||||
|
||||
const [showRecentHosts, _setShowRecentHosts] = useStoredBoolean(
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
true,
|
||||
);
|
||||
|
||||
// Handle external navigation requests
|
||||
useEffect(() => {
|
||||
if (navigateToSection) {
|
||||
@@ -234,6 +253,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const [editingHost, setEditingHost] = useState<Host | null>(null);
|
||||
const [newHostGroupPath, setNewHostGroupPath] = useState<string | null>(null);
|
||||
|
||||
// Group panel state
|
||||
const [isGroupPanelOpen, setIsGroupPanelOpen] = useState(false);
|
||||
const [editingGroupPath, setEditingGroupPath] = useState<string | null>(null);
|
||||
|
||||
// Compute inherited group defaults for the host being edited
|
||||
const editingHostGroupDefaults = useMemo(() => {
|
||||
const group = editingHost?.group || newHostGroupPath || selectedGroupPath;
|
||||
if (!group) return undefined;
|
||||
return resolveGroupDefaults(group, groupConfigs);
|
||||
}, [editingHost, newHostGroupPath, selectedGroupPath, groupConfigs]);
|
||||
|
||||
// Quick connect state
|
||||
const [quickConnectTarget, setQuickConnectTarget] = useState<{
|
||||
hostname: string;
|
||||
@@ -278,30 +308,37 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
[isSearchQuickConnect, handleConnectClick],
|
||||
);
|
||||
|
||||
// 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 = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
|
||||
: 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;
|
||||
}, []);
|
||||
}, [groupConfigs]);
|
||||
|
||||
// Handle host connect with protocol selection
|
||||
const handleHostConnect = useCallback(
|
||||
(host: Host) => {
|
||||
if (hasMultipleProtocols(host)) {
|
||||
setProtocolSelectHost(host);
|
||||
// Pass effective host to protocol dialog so it shows correct ports/protocols
|
||||
const effective = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
|
||||
: host;
|
||||
setProtocolSelectHost(effective);
|
||||
} else {
|
||||
onConnect(host);
|
||||
}
|
||||
},
|
||||
[hasMultipleProtocols, onConnect],
|
||||
[hasMultipleProtocols, onConnect, groupConfigs],
|
||||
);
|
||||
|
||||
// Handle protocol selection
|
||||
@@ -342,12 +379,16 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
);
|
||||
|
||||
const handleNewHost = useCallback(() => {
|
||||
setIsGroupPanelOpen(false);
|
||||
setEditingGroupPath(null);
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
setIsHostPanelOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleEditHost = useCallback((host: Host) => {
|
||||
setIsGroupPanelOpen(false);
|
||||
setEditingGroupPath(null);
|
||||
setEditingHost(host);
|
||||
setIsHostPanelOpen(true);
|
||||
}, []);
|
||||
@@ -359,6 +400,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
id: crypto.randomUUID(),
|
||||
label: `${host.label} (${t('action.copy')})`,
|
||||
createdAt: Date.now(),
|
||||
pinned: undefined,
|
||||
lastConnectedAt: undefined,
|
||||
};
|
||||
// Open the edit panel with the duplicated host for modification
|
||||
setEditingHost(duplicatedHost);
|
||||
@@ -398,38 +441,42 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
|
||||
// Copy host credentials to clipboard
|
||||
const handleCopyCredentials = useCallback((host: Host) => {
|
||||
// Apply group defaults so inherited credentials are included
|
||||
const effective = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
|
||||
: host;
|
||||
// Only use telnet-specific port and credentials when protocol is explicitly telnet
|
||||
// Don't treat telnetEnabled as primary - that's just an optional protocol
|
||||
const isTelnet = host.protocol === "telnet";
|
||||
const isTelnet = effective.protocol === "telnet";
|
||||
|
||||
const defaultPort = isTelnet ? 23 : 22;
|
||||
const effectivePort = isTelnet
|
||||
? (host.telnetPort ?? host.port ?? 23)
|
||||
: (host.port ?? 22);
|
||||
? (effective.telnetPort ?? effective.port ?? 23)
|
||||
: (effective.port ?? 22);
|
||||
|
||||
// Bracket IPv6 addresses when appending non-default port
|
||||
let address: string;
|
||||
if (effectivePort !== defaultPort) {
|
||||
const isIPv6 = host.hostname.includes(":") && !host.hostname.startsWith("[");
|
||||
const hostname = isIPv6 ? `[${host.hostname}]` : host.hostname;
|
||||
const isIPv6 = effective.hostname.includes(":") && !effective.hostname.startsWith("[");
|
||||
const hostname = isIPv6 ? `[${effective.hostname}]` : effective.hostname;
|
||||
address = `${hostname}:${effectivePort}`;
|
||||
} else {
|
||||
address = host.hostname;
|
||||
address = effective.hostname;
|
||||
}
|
||||
|
||||
// Resolve credentials from identity if configured, otherwise use host credentials
|
||||
// For telnet hosts, use telnet-specific credentials
|
||||
const identity = host.identityId
|
||||
? identities.find((i) => i.id === host.identityId)
|
||||
const identity = effective.identityId
|
||||
? identities.find((i) => i.id === effective.identityId)
|
||||
: undefined;
|
||||
|
||||
const username = isTelnet
|
||||
? (host.telnetUsername?.trim() || host.username?.trim())
|
||||
: (identity?.username?.trim() || host.username?.trim());
|
||||
? (effective.telnetUsername?.trim() || effective.username?.trim())
|
||||
: (identity?.username?.trim() || effective.username?.trim());
|
||||
|
||||
const password = isTelnet
|
||||
? (host.telnetPassword || host.password)
|
||||
: (identity?.password || host.password);
|
||||
? (effective.telnetPassword || effective.password)
|
||||
: (identity?.password || effective.password);
|
||||
|
||||
if (!password) {
|
||||
toast.warning(t('vault.hosts.copyCredentials.toast.noPassword'));
|
||||
@@ -440,7 +487,19 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
toast.success(t('vault.hosts.copyCredentials.toast.success'));
|
||||
});
|
||||
}, [identities, t]);
|
||||
}, [identities, groupConfigs, t]);
|
||||
|
||||
const [lastPinnedId, setLastPinnedId] = useState<string | null>(null);
|
||||
const toggleHostPinned = useCallback((hostId: string) => {
|
||||
const host = hostsRef.current.find((h) => h.id === hostId);
|
||||
const isPinning = host && !host.pinned;
|
||||
startTransition(() => {
|
||||
onUpdateHosts(hostsRef.current.map((h) =>
|
||||
h.id === hostId ? { ...h, pinned: !h.pinned } : h
|
||||
));
|
||||
});
|
||||
setLastPinnedId(isPinning ? hostId : null);
|
||||
}, [onUpdateHosts]);
|
||||
|
||||
const toggleHostSelection = useCallback((hostId: string) => {
|
||||
setSelectedHostIds(prev => {
|
||||
@@ -826,6 +885,63 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
return filtered;
|
||||
}, [hosts, selectedGroupPath, search, selectedTags, sortMode]);
|
||||
|
||||
// Pinned hosts for root-level display (not inside a subgroup)
|
||||
// Respects active search and tag filters
|
||||
const pinnedHosts = useMemo(() => {
|
||||
if (selectedGroupPath) return [];
|
||||
let filtered = hosts.filter((h) => h.pinned);
|
||||
if (search.trim()) {
|
||||
const s = search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(h) =>
|
||||
h.label.toLowerCase().includes(s) ||
|
||||
h.hostname.toLowerCase().includes(s) ||
|
||||
h.tags.some((t) => t.toLowerCase().includes(s)),
|
||||
);
|
||||
}
|
||||
if (selectedTags.length > 0) {
|
||||
filtered = filtered.filter((h) =>
|
||||
selectedTags.some((t) => h.tags?.includes(t)),
|
||||
);
|
||||
}
|
||||
return filtered.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}, [hosts, selectedGroupPath, search, selectedTags]);
|
||||
|
||||
// Recently connected hosts for root-level display
|
||||
// Respects active search and tag filters
|
||||
const recentHosts = useMemo(() => {
|
||||
if (selectedGroupPath) return [];
|
||||
let filtered = hosts.filter((h) => h.lastConnectedAt);
|
||||
if (search.trim()) {
|
||||
const s = search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(h) =>
|
||||
h.label.toLowerCase().includes(s) ||
|
||||
h.hostname.toLowerCase().includes(s) ||
|
||||
h.tags.some((t) => t.toLowerCase().includes(s)),
|
||||
);
|
||||
}
|
||||
if (selectedTags.length > 0) {
|
||||
filtered = filtered.filter((h) =>
|
||||
selectedTags.some((t) => h.tags?.includes(t)),
|
||||
);
|
||||
}
|
||||
return filtered
|
||||
.sort((a, b) => (b.lastConnectedAt || 0) - (a.lastConnectedAt || 0))
|
||||
.slice(0, 20);
|
||||
}, [hosts, selectedGroupPath, search, selectedTags]);
|
||||
|
||||
// IDs of hosts already shown in Pinned/Recent sections at root level,
|
||||
// so the main host list can exclude them to avoid duplicates.
|
||||
const pinnedRecentIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const h of pinnedHosts) ids.add(h.id);
|
||||
if (showRecentHosts) {
|
||||
for (const h of recentHosts) ids.add(h.id);
|
||||
}
|
||||
return ids;
|
||||
}, [pinnedHosts, recentHosts, showRecentHosts]);
|
||||
|
||||
// For tree view: apply search, tag filter, and sorting, but not group filtering
|
||||
const treeViewHosts = useMemo(() => {
|
||||
let filtered = hosts;
|
||||
@@ -1118,6 +1234,68 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
setIsRenameGroupOpen(false);
|
||||
};
|
||||
|
||||
const handleEditGroupConfig = useCallback((groupPath: string) => {
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
setEditingGroupPath(groupPath);
|
||||
setIsGroupPanelOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleSaveGroupConfig = useCallback((config: GroupConfig, _newName?: string, _newParent?: string | null) => {
|
||||
const oldPath = editingGroupPath!;
|
||||
const newPath = config.path; // Panel already computed the correct path
|
||||
|
||||
// Validate no duplicate path on rename/reparent
|
||||
if (newPath !== oldPath && customGroups.includes(newPath)) {
|
||||
toast.error(t('vault.groups.errors.duplicatePath'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Save config (use new path)
|
||||
const updatedConfigs = [...groupConfigs.filter(c => c.path !== oldPath), config];
|
||||
|
||||
// Handle path change (rename or parent change)
|
||||
if (newPath !== oldPath) {
|
||||
// Update groups, hosts, managed sources, and configs for path change
|
||||
const updatedGroups = customGroups.map((g) => {
|
||||
if (g === oldPath) return newPath;
|
||||
if (g.startsWith(oldPath + '/')) return newPath + g.slice(oldPath.length);
|
||||
return g;
|
||||
});
|
||||
const updatedHosts = hosts.map((h) => {
|
||||
const g = h.group || '';
|
||||
if (g === oldPath) return { ...h, group: newPath };
|
||||
if (g.startsWith(oldPath + '/')) return { ...h, group: newPath + g.slice(oldPath.length) };
|
||||
return h;
|
||||
});
|
||||
const updatedManagedSources = managedSources.map((s) => {
|
||||
if (s.groupName === oldPath) return { ...s, groupName: newPath };
|
||||
if (s.groupName.startsWith(oldPath + '/')) return { ...s, groupName: newPath + s.groupName.slice(oldPath.length) };
|
||||
return s;
|
||||
});
|
||||
if (updatedManagedSources.some((s, i) => s !== managedSources[i])) {
|
||||
onUpdateManagedSources(updatedManagedSources);
|
||||
}
|
||||
onUpdateCustomGroups(Array.from(new Set(updatedGroups)));
|
||||
onUpdateHosts(updatedHosts);
|
||||
// Update child config paths too
|
||||
const finalConfigs = updatedConfigs.map(c => {
|
||||
if (c.path.startsWith(oldPath + '/')) return { ...c, path: newPath + c.path.slice(oldPath.length) };
|
||||
return c;
|
||||
});
|
||||
onUpdateGroupConfigs(finalConfigs);
|
||||
if (selectedGroupPath === oldPath) setSelectedGroupPath(newPath);
|
||||
if (selectedGroupPath?.startsWith(oldPath + '/')) {
|
||||
setSelectedGroupPath(newPath + selectedGroupPath.slice(oldPath.length));
|
||||
}
|
||||
} else {
|
||||
onUpdateGroupConfigs(updatedConfigs);
|
||||
}
|
||||
|
||||
setIsGroupPanelOpen(false);
|
||||
setEditingGroupPath(null);
|
||||
}, [groupConfigs, editingGroupPath, customGroups, hosts, managedSources, selectedGroupPath, onUpdateGroupConfigs, onUpdateCustomGroups, onUpdateHosts, onUpdateManagedSources, t]);
|
||||
|
||||
const deleteGroupPath = async (path: string, deleteHosts: boolean = false) => {
|
||||
const keepGroups = customGroups.filter(
|
||||
(g) => !(g === path || g.startsWith(path + "/")),
|
||||
@@ -1172,6 +1350,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
|
||||
onUpdateCustomGroups(keepGroups);
|
||||
onUpdateHosts(keepHosts);
|
||||
// Remove configs for deleted group and its children
|
||||
const updatedGroupConfigs = groupConfigs.filter(
|
||||
(c) => c.path !== path && !c.path.startsWith(path + '/')
|
||||
);
|
||||
if (updatedGroupConfigs.length !== groupConfigs.length) {
|
||||
onUpdateGroupConfigs(updatedGroupConfigs);
|
||||
}
|
||||
if (
|
||||
selectedGroupPath &&
|
||||
(selectedGroupPath === path || selectedGroupPath.startsWith(path + "/"))
|
||||
@@ -1184,23 +1369,27 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const name = sourcePath.split("/").filter(Boolean).pop() || "";
|
||||
const newPath = targetParent ? `${targetParent}/${name}` : name;
|
||||
if (newPath === sourcePath || newPath.startsWith(sourcePath + "/")) return;
|
||||
if (customGroups.includes(newPath)) {
|
||||
toast.error(t('vault.groups.errors.duplicatePath'));
|
||||
return;
|
||||
}
|
||||
const updatedGroups = customGroups.map((g) => {
|
||||
if (g === sourcePath) return newPath;
|
||||
if (g.startsWith(sourcePath + "/")) return g.replace(sourcePath, newPath);
|
||||
if (g.startsWith(sourcePath + "/")) return newPath + g.slice(sourcePath.length);
|
||||
return g;
|
||||
});
|
||||
const updatedHosts = hosts.map((h) => {
|
||||
const g = h.group || "";
|
||||
if (g === sourcePath) return { ...h, group: newPath };
|
||||
if (g.startsWith(sourcePath + "/"))
|
||||
return { ...h, group: g.replace(sourcePath, newPath) };
|
||||
return { ...h, group: newPath + g.slice(sourcePath.length) };
|
||||
return h;
|
||||
});
|
||||
// Update managed sources if any match the moved group path
|
||||
const updatedManagedSources = managedSources.map((s) => {
|
||||
if (s.groupName === sourcePath) return { ...s, groupName: newPath };
|
||||
if (s.groupName.startsWith(sourcePath + "/"))
|
||||
return { ...s, groupName: s.groupName.replace(sourcePath, newPath) };
|
||||
return { ...s, groupName: newPath + s.groupName.slice(sourcePath.length) };
|
||||
return s;
|
||||
});
|
||||
if (updatedManagedSources.some((s, i) => s !== managedSources[i])) {
|
||||
@@ -1208,6 +1397,16 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}
|
||||
onUpdateCustomGroups(Array.from(new Set(updatedGroups)));
|
||||
onUpdateHosts(updatedHosts);
|
||||
// Update group configs for moved paths
|
||||
const updatedGroupConfigs = groupConfigs.map((c) => {
|
||||
if (c.path === sourcePath) return { ...c, path: newPath };
|
||||
if (c.path.startsWith(sourcePath + '/'))
|
||||
return { ...c, path: newPath + c.path.slice(sourcePath.length) };
|
||||
return c;
|
||||
});
|
||||
if (updatedGroupConfigs.some((c, i) => c !== groupConfigs[i])) {
|
||||
onUpdateGroupConfigs(updatedGroupConfigs);
|
||||
}
|
||||
if (
|
||||
selectedGroupPath &&
|
||||
(selectedGroupPath === sourcePath ||
|
||||
@@ -1639,8 +1838,24 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
{viewMode !== "tree" && (
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<button
|
||||
className="text-primary hover:underline"
|
||||
className={cn(
|
||||
"text-primary hover:underline transition-all rounded px-1 -mx-1",
|
||||
isBreadcrumbDragOver && "ring-2 ring-primary bg-primary/10",
|
||||
)}
|
||||
onClick={() => setSelectedGroupPath(null)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsBreadcrumbDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsBreadcrumbDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsBreadcrumbDragOver(false);
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
if (groupPath) moveGroup(groupPath, null);
|
||||
if (hostId) moveHostToGroup(hostId, null);
|
||||
}}
|
||||
>
|
||||
{t("vault.hosts.allHosts")}
|
||||
</button>
|
||||
@@ -1674,6 +1889,201 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* Pinned hosts section - only at root level */}
|
||||
{viewMode !== "tree" && !selectedGroupPath && pinnedHosts.length > 0 && (
|
||||
<section className="space-y-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground inline-flex items-center gap-1.5">
|
||||
<Pin size={14} className="shrink-0 -translate-y-[1px]" />
|
||||
{t("vault.hosts.pinned")}
|
||||
</h3>
|
||||
<div className={cn(
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
: "flex flex-col gap-0",
|
||||
)}>
|
||||
{pinnedHosts.map((host) => {
|
||||
const safeHost = sanitizeHost(host);
|
||||
const effectiveDistro = getEffectiveHostDistro(safeHost);
|
||||
const distroBadge = {
|
||||
text: (safeHost.os || "L")[0].toUpperCase(),
|
||||
label: effectiveDistro || safeHost.os || "Linux",
|
||||
};
|
||||
return (
|
||||
<ContextMenu key={host.id}>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer relative",
|
||||
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",
|
||||
)}
|
||||
style={lastPinnedId === host.id ? { animation: "pop-in 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both" } : undefined}
|
||||
onAnimationEnd={() => { if (lastPinnedId === host.id) setLastPinnedId(null); }}
|
||||
draggable={!isMultiSelectMode}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("host-id", host.id);
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isMultiSelectMode) {
|
||||
toggleHostSelection(host.id);
|
||||
} else {
|
||||
handleHostConnect(safeHost);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{viewMode === "grid" && (
|
||||
<Star size={10} className="absolute top-1.5 right-1.5 text-amber-400 fill-amber-400" />
|
||||
)}
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
{isMultiSelectMode && (
|
||||
<div className="shrink-0">
|
||||
{selectedHostIds.has(host.id) ? (
|
||||
<CheckSquare size={18} className="text-primary" />
|
||||
) : (
|
||||
<Square size={18} className="text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DistroAvatar host={safeHost} fallback={distroBadge.text} />
|
||||
<div className="min-w-0 flex flex-col justify-center gap-0.5 flex-1">
|
||||
<span className="text-sm font-semibold truncate leading-5">
|
||||
{safeHost.label}
|
||||
</span>
|
||||
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
|
||||
{safeHost.username}@{safeHost.hostname}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditHost(host);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => handleHostConnect(host)}>
|
||||
<Plug className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => handleEditHost(host)}>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => toggleHostPinned(host.id)}>
|
||||
<Pin className="mr-2 h-4 w-4" /> {t('vault.hosts.unpin')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={() => onDeleteHost(host.id)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{/* Recently Connected section - only at root level, toggleable */}
|
||||
{viewMode !== "tree" && !selectedGroupPath && showRecentHosts && recentHosts.length > 0 && (
|
||||
<section className="space-y-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground inline-flex items-center gap-1.5">
|
||||
<Clock size={14} className="shrink-0 -translate-y-[1px]" />
|
||||
{t("vault.hosts.recentlyConnected")}
|
||||
</h3>
|
||||
<div className={cn(
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
: "flex flex-col gap-0",
|
||||
)}>
|
||||
{recentHosts.map((host) => {
|
||||
const safeHost = sanitizeHost(host);
|
||||
const effectiveDistro = getEffectiveHostDistro(safeHost);
|
||||
const distroBadge = {
|
||||
text: (safeHost.os || "L")[0].toUpperCase(),
|
||||
label: effectiveDistro || safeHost.os || "Linux",
|
||||
};
|
||||
return (
|
||||
<ContextMenu key={host.id}>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer relative",
|
||||
viewMode === "grid"
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
||||
)}
|
||||
draggable={!isMultiSelectMode}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("host-id", host.id);
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isMultiSelectMode) {
|
||||
toggleHostSelection(host.id);
|
||||
} else {
|
||||
handleHostConnect(safeHost);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
{isMultiSelectMode && (
|
||||
<div className="shrink-0">
|
||||
{selectedHostIds.has(host.id) ? (
|
||||
<CheckSquare size={18} className="text-primary" />
|
||||
) : (
|
||||
<Square size={18} className="text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DistroAvatar host={safeHost} fallback={distroBadge.text} />
|
||||
<div className="min-w-0 flex flex-col justify-center gap-0.5 flex-1">
|
||||
<span className="text-sm font-semibold truncate leading-5">
|
||||
{safeHost.label}
|
||||
</span>
|
||||
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
|
||||
{safeHost.username}@{safeHost.hostname}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditHost(host);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => handleHostConnect(host)}>
|
||||
<Plug className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => handleEditHost(host)}>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => toggleHostPinned(host.id)}>
|
||||
<Pin className="mr-2 h-4 w-4" /> {host.pinned ? t('vault.hosts.unpin') : t('vault.hosts.pinToTop')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={() => onDeleteHost(host.id)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{viewMode !== "tree" && displayedGroups.length > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
@@ -1756,6 +2166,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
{t("vault.groups.hostsCount", { count: node.totalHostCount ?? node.hosts.length })}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditGroupConfig(node.path);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
@@ -1770,14 +2191,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<FolderPlus className="mr-2 h-4 w-4" /> {t("vault.groups.newSubgroup")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
setRenameTargetPath(node.path);
|
||||
setRenameGroupName(node.name);
|
||||
setRenameGroupError(null);
|
||||
setIsRenameGroupOpen(true);
|
||||
}}
|
||||
onClick={() => handleEditGroupConfig(node.path)}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t("vault.groups.rename")}
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t("vault.groups.settings")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
@@ -1867,6 +2283,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onDuplicateHost={handleDuplicateHost}
|
||||
onDeleteHost={(host) => onDeleteHost(host.id)}
|
||||
onCopyCredentials={handleCopyCredentials}
|
||||
|
||||
onNewHost={(groupPath) => {
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(groupPath || null);
|
||||
@@ -1877,13 +2294,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
setNewFolderName("");
|
||||
setIsNewFolderOpen(true);
|
||||
}}
|
||||
onEditGroup={(groupPath) => {
|
||||
setRenameTargetPath(groupPath);
|
||||
const groupName = groupPath.split('/').pop() || '';
|
||||
setRenameGroupName(groupName);
|
||||
setRenameGroupError(null);
|
||||
setIsRenameGroupOpen(true);
|
||||
}}
|
||||
onEditGroup={(groupPath) => handleEditGroupConfig(groupPath)}
|
||||
onDeleteGroup={(groupPath) => {
|
||||
setDeleteTargetPath(groupPath);
|
||||
setIsDeleteGroupOpen(true);
|
||||
@@ -1906,7 +2317,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
{group.name || t("vault.groups.ungrouped")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
({group.hosts.length})
|
||||
({selectedGroupPath ? group.hosts.length : group.hosts.filter((h) => !pinnedRecentIds.has(h.id)).length})
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -1916,7 +2327,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
>
|
||||
{group.hosts.map((host) => {
|
||||
{group.hosts.filter((h) => selectedGroupPath || !pinnedRecentIds.has(h.id)).map((host) => {
|
||||
const safeHost = sanitizeHost(host);
|
||||
const effectiveDistro = getEffectiveHostDistro(safeHost);
|
||||
const distroBadge = {
|
||||
@@ -1928,7 +2339,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer",
|
||||
"group cursor-pointer relative",
|
||||
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",
|
||||
@@ -1946,6 +2357,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{host.pinned && viewMode === "grid" && (
|
||||
<Star size={10} className="absolute top-1.5 right-1.5 text-amber-400 fill-amber-400" />
|
||||
)}
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
{isMultiSelectMode && (
|
||||
<div
|
||||
@@ -1981,21 +2395,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
{safeHost.username}@{safeHost.hostname}
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "list" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditHost(host);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditHost(host);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
@@ -2020,6 +2430,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<ClipboardCopy className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => toggleHostPinned(host.id)}>
|
||||
<Pin className="mr-2 h-4 w-4" /> {host.pinned ? t('vault.hosts.unpin') : t('vault.hosts.pinToTop')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onDeleteHost(host.id)}
|
||||
@@ -2055,7 +2468,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
>
|
||||
{displayedHosts.map((host) => {
|
||||
{displayedHosts.filter((h) => selectedGroupPath || !pinnedRecentIds.has(h.id)).map((host) => {
|
||||
const safeHost = sanitizeHost(host);
|
||||
const effectiveDistro = getEffectiveHostDistro(safeHost);
|
||||
const distroBadge = {
|
||||
@@ -2067,7 +2480,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer",
|
||||
"group cursor-pointer relative",
|
||||
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",
|
||||
@@ -2085,6 +2498,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{host.pinned && viewMode === "grid" && (
|
||||
<Star size={10} className="absolute top-1.5 right-1.5 text-amber-400 fill-amber-400" />
|
||||
)}
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
{isMultiSelectMode && (
|
||||
<div
|
||||
@@ -2120,21 +2536,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
{safeHost.username}@{safeHost.hostname}
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "list" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditHost(host);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditHost(host);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
@@ -2159,6 +2571,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<ClipboardCopy className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => toggleHostPinned(host.id)}>
|
||||
<Pin className="mr-2 h-4 w-4" /> {host.pinned ? t('vault.hosts.unpin') : t('vault.hosts.pinToTop')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onDeleteHost(host.id)}
|
||||
@@ -2268,6 +2683,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
identities={identities}
|
||||
customGroups={customGroups}
|
||||
managedSources={managedSources}
|
||||
groupConfigs={groupConfigs}
|
||||
onSaveHost={(host) => onUpdateHosts([...hosts, host])}
|
||||
onCreateGroup={(groupPath) =>
|
||||
onUpdateCustomGroups(
|
||||
@@ -2299,6 +2715,26 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Group Details Panel */}
|
||||
{currentSection === "hosts" && isGroupPanelOpen && editingGroupPath && (
|
||||
<GroupDetailsPanel
|
||||
key={editingGroupPath}
|
||||
groupPath={editingGroupPath}
|
||||
config={groupConfigs.find(c => c.path === editingGroupPath)}
|
||||
availableKeys={keys}
|
||||
identities={identities}
|
||||
allHosts={hosts}
|
||||
groups={allGroupPaths}
|
||||
terminalThemeId={terminalThemeId}
|
||||
terminalFontSize={terminalFontSize}
|
||||
onSave={handleSaveGroupConfig}
|
||||
onCancel={() => {
|
||||
setIsGroupPanelOpen(false);
|
||||
setEditingGroupPath(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Host Details Panel - positioned at VaultView root level for correct top alignment */}
|
||||
{currentSection === "hosts" && isHostPanelOpen && editingHost?.protocol !== 'serial' && (
|
||||
<HostDetailsPanel
|
||||
@@ -2312,6 +2748,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
defaultGroup={editingHost ? undefined : (newHostGroupPath || selectedGroupPath)}
|
||||
terminalThemeId={terminalThemeId}
|
||||
terminalFontSize={terminalFontSize}
|
||||
groupDefaults={editingHostGroupDefaults}
|
||||
onSave={(host) => {
|
||||
// Check if host already exists in the list (for updates vs. new/duplicate)
|
||||
const hostExists = hosts.some((h) => h.id === host.id);
|
||||
@@ -2578,6 +3015,7 @@ const vaultViewAreEqual = (
|
||||
prev.connectionLogs === next.connectionLogs &&
|
||||
prev.sessions === next.sessions &&
|
||||
prev.managedSources === next.managedSources &&
|
||||
prev.groupConfigs === next.groupConfigs &&
|
||||
prev.terminalThemeId === next.terminalThemeId &&
|
||||
prev.terminalFontSize === next.terminalFontSize;
|
||||
|
||||
|
||||
@@ -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 { Button } from '../ui/button';
|
||||
import { Card } from '../ui/card';
|
||||
import { Input } from '../ui/input';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
|
||||
export interface ChainPanelProps {
|
||||
@@ -38,6 +39,14 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
|
||||
onCancel,
|
||||
}) => {
|
||||
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}
|
||||
@@ -52,16 +61,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 +73,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 +110,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" />
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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];
|
||||
@@ -106,6 +295,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 +413,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 +423,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 +449,7 @@ export default function SettingsTerminalTab(props: {
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [terminalSettings.localShell, t]);
|
||||
}, [terminalSettings.localShell, discoveredShells, t]);
|
||||
|
||||
// Validate directory path when it changes
|
||||
useEffect(() => {
|
||||
@@ -694,47 +903,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 +917,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 +1053,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 +1110,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -131,15 +131,19 @@ interface ThemeSidePanelProps {
|
||||
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;
|
||||
@@ -153,15 +157,19 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
currentFontFamilyId,
|
||||
globalFontFamilyId,
|
||||
currentFontSize,
|
||||
currentFontWeight,
|
||||
canResetTheme = false,
|
||||
canResetFontFamily = false,
|
||||
canResetFontSize = false,
|
||||
canResetFontWeight = false,
|
||||
onThemeChange,
|
||||
onThemeReset,
|
||||
onFontFamilyChange,
|
||||
onFontFamilyReset,
|
||||
onFontSizeChange,
|
||||
onFontSizeReset,
|
||||
onFontWeightChange,
|
||||
onFontWeightReset,
|
||||
isVisible = true,
|
||||
previewColors,
|
||||
}) => {
|
||||
@@ -497,10 +505,52 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
</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
|
||||
{allThemes.find(t => t.id === currentThemeId)?.name ?? currentThemeId} • {availableFonts.find(f => f.id === currentFontFamilyId)?.name ?? currentFontFamilyId} • {currentFontSize}px • {currentFontWeight}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -764,15 +764,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,6 +26,7 @@ import {
|
||||
import {
|
||||
resolveHostTerminalFontFamilyId,
|
||||
resolveHostTerminalFontSize,
|
||||
resolveHostTerminalFontWeight,
|
||||
} from "../../../domain/terminalAppearance";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { isMacPlatform, normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
|
||||
@@ -162,7 +163,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;
|
||||
@@ -188,6 +189,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 +233,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 +314,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 +361,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();
|
||||
|
||||
@@ -562,7 +570,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 +592,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);
|
||||
|
||||
@@ -101,8 +101,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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
52
domain/groupConfig.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
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 !== 'path' && value !== undefined) {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -165,6 +165,9 @@ export interface SyncPayload {
|
||||
customGroups: string[];
|
||||
snippetPackages?: string[];
|
||||
|
||||
// Group configs (connection defaults per host group)
|
||||
groupConfigs?: import('./models').GroupConfig[];
|
||||
|
||||
// Port forwarding rules
|
||||
portForwardingRules?: import('./models').PortForwardingRule[];
|
||||
|
||||
@@ -201,6 +204,8 @@ export interface SyncPayload {
|
||||
sftpGlobalBookmarks?: import('./models').SftpBookmark[];
|
||||
// Immersive mode
|
||||
immersiveMode?: boolean;
|
||||
// Vault: show recently connected hosts
|
||||
showRecentHosts?: boolean;
|
||||
};
|
||||
|
||||
// Sync metadata
|
||||
|
||||
@@ -384,8 +384,17 @@ export function mergeSyncPayloads(
|
||||
remote.portForwardingRules ?? [],
|
||||
);
|
||||
|
||||
// Merge group configs (keyed by path — wrap with virtual id for entity merge)
|
||||
type GCWithId = import('./models').GroupConfig & { id: string };
|
||||
const wrapGC = (arr: import('./models').GroupConfig[] | undefined): GCWithId[] =>
|
||||
(arr ?? []).map(gc => ({ ...gc, id: gc.path }));
|
||||
const unwrapGC = (arr: GCWithId[]): import('./models').GroupConfig[] =>
|
||||
arr.map(({ id: _id, ...rest }) => rest as import('./models').GroupConfig);
|
||||
const groupConfigsResult = mergeEntityArrays(wrapGC(b.groupConfigs), wrapGC(local.groupConfigs), wrapGC(remote.groupConfigs));
|
||||
|
||||
// Aggregate stats
|
||||
const entityResults = [hosts, keys, identities, snippets, knownHosts, portForwardingRules];
|
||||
const entityResults: Pick<EntityMergeResult<unknown>, 'added' | 'deleted' | 'modified' | 'conflicts'>[] =
|
||||
[hosts, keys, identities, snippets, knownHosts, portForwardingRules, groupConfigsResult];
|
||||
for (const r of entityResults) {
|
||||
summary.added.local += r.added.local;
|
||||
summary.added.remote += r.added.remote;
|
||||
@@ -430,6 +439,7 @@ export function mergeSyncPayloads(
|
||||
snippetPackages,
|
||||
knownHosts: knownHosts.merged,
|
||||
portForwardingRules: portForwardingRules.merged,
|
||||
groupConfigs: unwrapGC(groupConfigsResult.merged),
|
||||
settings,
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
|
||||
@@ -47,3 +47,15 @@ export const resolveHostTerminalFontFamilyId = (host: Host | null | undefined, d
|
||||
export const resolveHostTerminalFontSize = (host: Host | null | undefined, defaultFontSize: number): number =>
|
||||
hasHostFontSizeOverride(host) && host?.fontSize != null ? host.fontSize : defaultFontSize;
|
||||
|
||||
export const hasHostFontWeightOverride = (host?: Pick<Host, 'fontWeightOverride' | 'fontWeight'> | null): boolean =>
|
||||
hasEffectiveOverride(host?.fontWeightOverride, hasLegacyNumberValue(host?.fontWeight));
|
||||
|
||||
export const clearHostFontWeightOverride = (host: Host): Host => ({
|
||||
...host,
|
||||
fontWeight: undefined,
|
||||
fontWeightOverride: false,
|
||||
});
|
||||
|
||||
export const resolveHostTerminalFontWeight = (host: Host | null | undefined, defaultFontWeight: number): number =>
|
||||
hasHostFontWeightOverride(host) && host?.fontWeight != null ? host.fontWeight : defaultFontWeight;
|
||||
|
||||
|
||||
710
electron/bridges/shellDiscovery.cjs
Normal file
@@ -0,0 +1,710 @@
|
||||
/**
|
||||
* Shell Discovery — cross-platform shell detection
|
||||
*
|
||||
* Detects available shells on Windows (CMD, PowerShell, WSL, Git Bash, Cygwin)
|
||||
* and Unix/macOS (via /etc/shells). Registry access on Windows uses `reg.exe`
|
||||
* via child_process — no native npm dependency.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const EXEC_OPTS = { encoding: "utf8", timeout: 5000, windowsHide: true };
|
||||
|
||||
/** Module-level cache for later use by the unified discoverShells() (Task 3). */
|
||||
let cachedShells = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Query a specific value from a Windows registry key.
|
||||
* Returns the value string, or `null` on failure.
|
||||
*
|
||||
* @param {string} keyPath e.g. "HKLM\\SOFTWARE\\GitForWindows"
|
||||
* @param {string} valueName e.g. "InstallPath"
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function regQueryValue(keyPath, valueName) {
|
||||
try {
|
||||
// /ve queries the default (unnamed) value; /v queries a named value.
|
||||
const args =
|
||||
valueName === "" || valueName == null
|
||||
? ["query", keyPath, "/ve"]
|
||||
: ["query", keyPath, "/v", valueName];
|
||||
const output = execFileSync("reg", args, EXEC_OPTS);
|
||||
// Output format:
|
||||
// HKEY_LOCAL_MACHINE\SOFTWARE\GitForWindows
|
||||
// InstallPath REG_SZ C:\Program Files\Git
|
||||
const lines = output.split(/\r?\n/).filter(Boolean);
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\s+.+?\s+REG_\w+\s+(.+)$/);
|
||||
if (match) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// Key or value not found — expected on many systems.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate immediate subkey names under a registry key.
|
||||
* Returns an array of full subkey paths, or an empty array on failure.
|
||||
*
|
||||
* @param {string} keyPath e.g. "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss"
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function regEnumSubkeys(keyPath) {
|
||||
try {
|
||||
const output = execFileSync(
|
||||
"reg",
|
||||
["query", keyPath],
|
||||
EXEC_OPTS,
|
||||
);
|
||||
// `reg query <key>` prints the key itself, then each subkey on its own line
|
||||
// prefixed with the full path. Values appear with leading whitespace.
|
||||
const lines = output.split(/\r?\n/).filter(Boolean);
|
||||
const subkeys = [];
|
||||
const normalizedParent = keyPath.toLowerCase();
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
// Subkeys start with "HK" and are longer than the parent key.
|
||||
if (
|
||||
trimmed.toLowerCase().startsWith("hk") &&
|
||||
trimmed.toLowerCase() !== normalizedParent &&
|
||||
trimmed.toLowerCase().startsWith(normalizedParent + "\\")
|
||||
) {
|
||||
subkeys.push(trimmed);
|
||||
}
|
||||
}
|
||||
return subkeys;
|
||||
} catch (_err) {
|
||||
// Key not found or access denied.
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate an executable on the system PATH using `where.exe`.
|
||||
* Returns the first valid, non-alias path, or `null` if not found.
|
||||
*
|
||||
* @param {string} name Executable name, e.g. "pwsh"
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function findExecutableOnPath(name) {
|
||||
try {
|
||||
const result = execFileSync("where.exe", [name], EXEC_OPTS);
|
||||
const candidates = result
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
// Skip Windows App Execution Aliases (WindowsApps zero-byte stubs).
|
||||
try {
|
||||
const localAppData = (process.env.LOCALAPPDATA || "").toLowerCase();
|
||||
if (
|
||||
localAppData &&
|
||||
candidate.toLowerCase().startsWith(
|
||||
path.join(localAppData, "Microsoft", "WindowsApps").toLowerCase() +
|
||||
path.sep,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore — just use the candidate.
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
} catch (_err) {
|
||||
// Not found on PATH.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a WSL distro name to an icon identifier for SVG lookup.
|
||||
*
|
||||
* @param {string} distroName e.g. "Ubuntu-22.04", "Debian", "kali-linux"
|
||||
* @returns {string}
|
||||
*/
|
||||
function mapWslDistroIcon(distroName) {
|
||||
const lower = (distroName || "").toLowerCase();
|
||||
|
||||
if (lower.includes("ubuntu")) return "ubuntu";
|
||||
if (lower.includes("debian")) return "debian";
|
||||
if (lower.includes("kali")) return "kali";
|
||||
if (lower.includes("alpine")) return "alpine";
|
||||
if (lower.includes("opensuse") || lower.includes("suse")) return "opensuse";
|
||||
if (lower.includes("fedora")) return "fedora";
|
||||
if (lower.includes("arch")) return "arch";
|
||||
if (lower.includes("oracle")) return "oracle";
|
||||
|
||||
return "linux";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Individual shell detectors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detect CMD.
|
||||
* @returns {object|null} DiscoveredShell or null
|
||||
*/
|
||||
function detectCmd() {
|
||||
try {
|
||||
const comSpec = process.env.ComSpec;
|
||||
const cmdPath = comSpec || "cmd.exe";
|
||||
// Verify the path actually exists when ComSpec provides a full path.
|
||||
if (comSpec && !fs.existsSync(comSpec)) {
|
||||
// Fallback to bare name — Windows will resolve it.
|
||||
return {
|
||||
id: "cmd",
|
||||
name: "CMD",
|
||||
command: "cmd.exe",
|
||||
args: [],
|
||||
icon: "cmd",
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: "cmd",
|
||||
name: "CMD",
|
||||
command: cmdPath,
|
||||
args: [],
|
||||
icon: "cmd",
|
||||
};
|
||||
} catch (_err) {
|
||||
// Should never fail, but guard anyway.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Windows PowerShell 5.1.
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function detectPowerShell() {
|
||||
try {
|
||||
// Try where.exe first.
|
||||
const found = findExecutableOnPath("powershell");
|
||||
if (found) {
|
||||
return {
|
||||
id: "powershell",
|
||||
name: "Windows PowerShell",
|
||||
command: found,
|
||||
args: ["-NoLogo"],
|
||||
icon: "powershell",
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: well-known path.
|
||||
const fallback = path.join(
|
||||
process.env.SystemRoot || "C:\\Windows",
|
||||
"System32",
|
||||
"WindowsPowerShell",
|
||||
"v1.0",
|
||||
"powershell.exe",
|
||||
);
|
||||
if (fs.existsSync(fallback)) {
|
||||
return {
|
||||
id: "powershell",
|
||||
name: "Windows PowerShell",
|
||||
command: fallback,
|
||||
args: ["-NoLogo"],
|
||||
icon: "powershell",
|
||||
};
|
||||
}
|
||||
} catch (_err) {
|
||||
// Detection failed — not critical.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect PowerShell Core (pwsh 7+).
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function detectPwsh() {
|
||||
try {
|
||||
// 1. where.exe
|
||||
const found = findExecutableOnPath("pwsh");
|
||||
if (found) {
|
||||
return {
|
||||
id: "pwsh",
|
||||
name: "PowerShell 7",
|
||||
command: found,
|
||||
args: ["-NoLogo"],
|
||||
icon: "pwsh",
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Registry App Paths
|
||||
const regPath = regQueryValue(
|
||||
"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\pwsh.exe",
|
||||
"",
|
||||
);
|
||||
if (regPath && fs.existsSync(regPath)) {
|
||||
return {
|
||||
id: "pwsh",
|
||||
name: "PowerShell 7",
|
||||
command: regPath,
|
||||
args: ["-NoLogo"],
|
||||
icon: "pwsh",
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Common fallback path.
|
||||
const fallback = path.join(
|
||||
process.env.ProgramFiles || "C:\\Program Files",
|
||||
"PowerShell",
|
||||
"7",
|
||||
"pwsh.exe",
|
||||
);
|
||||
if (fs.existsSync(fallback)) {
|
||||
return {
|
||||
id: "pwsh",
|
||||
name: "PowerShell 7",
|
||||
command: fallback,
|
||||
args: ["-NoLogo"],
|
||||
icon: "pwsh",
|
||||
};
|
||||
}
|
||||
} catch (_err) {
|
||||
// Detection failed.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect installed WSL distributions via the registry.
|
||||
* @returns {object[]} Array of DiscoveredShell objects (may be empty).
|
||||
*/
|
||||
function detectWslDistros() {
|
||||
const wslExe = path.join(
|
||||
process.env.SystemRoot || "C:\\Windows",
|
||||
"System32",
|
||||
"wsl.exe",
|
||||
);
|
||||
if (!fs.existsSync(wslExe)) return [];
|
||||
|
||||
const distros = [];
|
||||
|
||||
// Primary: use `wsl.exe -l -q` which lists installed distros one per line.
|
||||
// More reliable than registry parsing across Windows versions.
|
||||
// Note: wsl.exe outputs UTF-16LE on some builds, so we read as buffer and decode.
|
||||
try {
|
||||
const buf = execFileSync(wslExe, ["-l", "-q"], {
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
maxBuffer: 1024 * 64,
|
||||
});
|
||||
// wsl.exe outputs UTF-16LE on most Windows builds (has NUL bytes between chars).
|
||||
// Detect by checking for NUL bytes in the raw buffer; if present → UTF-16LE, else UTF-8.
|
||||
const isUtf16 = buf.length >= 2 && buf.includes(0x00);
|
||||
const output = buf.toString(isUtf16 ? "utf16le" : "utf8");
|
||||
const names = output
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.replace(/\0/g, "").trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const distroName of names) {
|
||||
distros.push({
|
||||
id: `wsl-${distroName.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`,
|
||||
name: `${distroName} (WSL)`,
|
||||
command: wslExe,
|
||||
args: ["-d", distroName],
|
||||
icon: mapWslDistroIcon(distroName),
|
||||
});
|
||||
}
|
||||
if (distros.length > 0) return distros;
|
||||
} catch (_err) {
|
||||
// wsl.exe -l -q failed, fall through to registry method.
|
||||
}
|
||||
|
||||
// Fallback: enumerate registry subkeys under Lxss
|
||||
try {
|
||||
const lxssKey = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss";
|
||||
const subkeys = regEnumSubkeys(lxssKey);
|
||||
|
||||
for (const subkey of subkeys) {
|
||||
try {
|
||||
const distroName = regQueryValue(subkey, "DistributionName");
|
||||
if (!distroName) continue;
|
||||
|
||||
distros.push({
|
||||
id: `wsl-${distroName.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`,
|
||||
name: `${distroName} (WSL)`,
|
||||
command: wslExe,
|
||||
args: ["-d", distroName],
|
||||
icon: mapWslDistroIcon(distroName),
|
||||
});
|
||||
} catch (_err) {
|
||||
// Skip this distro but continue with others.
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// WSL not installed or registry not accessible.
|
||||
}
|
||||
return distros;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Git Bash (from Git for Windows).
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function detectGitBash() {
|
||||
try {
|
||||
// Try registry first.
|
||||
const installPath = regQueryValue(
|
||||
"HKLM\\SOFTWARE\\GitForWindows",
|
||||
"InstallPath",
|
||||
);
|
||||
if (installPath) {
|
||||
const bashExe = path.join(installPath, "bin", "bash.exe");
|
||||
if (fs.existsSync(bashExe)) {
|
||||
return {
|
||||
id: "git-bash",
|
||||
name: "Git Bash",
|
||||
command: bashExe,
|
||||
args: ["--login", "-i"],
|
||||
icon: "git-bash",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: common installation path.
|
||||
const fallbackPaths = [
|
||||
path.join(
|
||||
process.env.ProgramFiles || "C:\\Program Files",
|
||||
"Git",
|
||||
"bin",
|
||||
"bash.exe",
|
||||
),
|
||||
path.join(
|
||||
process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)",
|
||||
"Git",
|
||||
"bin",
|
||||
"bash.exe",
|
||||
),
|
||||
];
|
||||
for (const p of fallbackPaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
return {
|
||||
id: "git-bash",
|
||||
name: "Git Bash",
|
||||
command: p,
|
||||
args: ["--login", "-i"],
|
||||
icon: "git-bash",
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// Git Bash not installed.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Cygwin bash.
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function detectCygwin() {
|
||||
try {
|
||||
// Try 64-bit registry key first, then 32-bit (WOW6432Node).
|
||||
const rootDir =
|
||||
regQueryValue("HKLM\\SOFTWARE\\Cygwin\\setup", "rootdir") ||
|
||||
regQueryValue("HKLM\\SOFTWARE\\WOW6432Node\\Cygwin\\setup", "rootdir");
|
||||
|
||||
if (rootDir) {
|
||||
const bashExe = path.join(rootDir, "bin", "bash.exe");
|
||||
if (fs.existsSync(bashExe)) {
|
||||
return {
|
||||
id: "cygwin",
|
||||
name: "Cygwin",
|
||||
command: bashExe,
|
||||
args: ["--login", "-i"],
|
||||
icon: "cygwin",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: common path.
|
||||
const fallback = "C:\\cygwin64\\bin\\bash.exe";
|
||||
if (fs.existsSync(fallback)) {
|
||||
return {
|
||||
id: "cygwin",
|
||||
name: "Cygwin",
|
||||
command: fallback,
|
||||
args: ["--login", "-i"],
|
||||
icon: "cygwin",
|
||||
};
|
||||
}
|
||||
} catch (_err) {
|
||||
// Cygwin not installed.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main discovery entry point for Windows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Discover all available shells on a Windows system.
|
||||
* Returns an array of DiscoveredShell objects. Exactly one shell will have
|
||||
* `isDefault: true` based on priority: pwsh > powershell > cmd.
|
||||
*
|
||||
* @returns {Array<{id: string, name: string, command: string, args: string[], icon: string, isDefault?: boolean}>}
|
||||
*/
|
||||
function discoverWindowsShells() {
|
||||
const shells = [];
|
||||
|
||||
// Detect each shell type independently — failures are isolated.
|
||||
const cmd = detectCmd();
|
||||
if (cmd) shells.push(cmd);
|
||||
|
||||
const powershell = detectPowerShell();
|
||||
if (powershell) shells.push(powershell);
|
||||
|
||||
const pwsh = detectPwsh();
|
||||
if (pwsh) shells.push(pwsh);
|
||||
|
||||
const wslDistros = detectWslDistros();
|
||||
shells.push(...wslDistros);
|
||||
|
||||
const gitBash = detectGitBash();
|
||||
if (gitBash) shells.push(gitBash);
|
||||
|
||||
const cygwin = detectCygwin();
|
||||
if (cygwin) shells.push(cygwin);
|
||||
|
||||
// Assign default: pwsh > powershell > cmd
|
||||
const defaultShell =
|
||||
shells.find((s) => s.id === "pwsh") ||
|
||||
shells.find((s) => s.id === "powershell") ||
|
||||
shells.find((s) => s.id === "cmd");
|
||||
if (defaultShell) {
|
||||
defaultShell.isDefault = true;
|
||||
}
|
||||
|
||||
return shells;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unix shell detection helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Map a Unix shell binary basename to a human-readable display name.
|
||||
*
|
||||
* @param {string} basename e.g. "zsh", "bash", "nu"
|
||||
* @returns {string}
|
||||
*/
|
||||
function mapUnixShellName(basename) {
|
||||
const map = {
|
||||
zsh: "Zsh",
|
||||
bash: "Bash",
|
||||
fish: "Fish",
|
||||
sh: "sh",
|
||||
ksh: "Ksh",
|
||||
tcsh: "Tcsh",
|
||||
csh: "Csh",
|
||||
dash: "Dash",
|
||||
nu: "Nushell",
|
||||
pwsh: "PowerShell",
|
||||
};
|
||||
return map[basename] || basename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Unix shell binary basename to an icon identifier.
|
||||
*
|
||||
* @param {string} basename e.g. "zsh", "fish", "nu"
|
||||
* @returns {string}
|
||||
*/
|
||||
function mapUnixShellIcon(basename) {
|
||||
const map = {
|
||||
zsh: "zsh",
|
||||
bash: "bash",
|
||||
fish: "fish",
|
||||
sh: "terminal",
|
||||
ksh: "terminal",
|
||||
tcsh: "terminal",
|
||||
csh: "terminal",
|
||||
dash: "terminal",
|
||||
nu: "nushell",
|
||||
pwsh: "pwsh",
|
||||
};
|
||||
return map[basename] || "terminal";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true for shells that should be launched with the `-l` (login) flag.
|
||||
*
|
||||
* @param {string} basename
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isLoginShell(basename) {
|
||||
return ["bash", "zsh", "fish", "ksh", "sh"].includes(basename);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main discovery entry point for Unix
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Discover all available shells on a Unix/macOS system by reading /etc/shells.
|
||||
* The shell referenced by $SHELL is marked as default. If $SHELL is not in
|
||||
* /etc/shells it is prepended to the list.
|
||||
*
|
||||
* @returns {Array<{id: string, name: string, command: string, args: string[], icon: string, isDefault?: boolean}>}
|
||||
*/
|
||||
function discoverUnixShells() {
|
||||
const shells = [];
|
||||
const seen = new Set();
|
||||
|
||||
// Read /etc/shells — each non-comment line is an absolute path.
|
||||
let etcShellPaths = [];
|
||||
try {
|
||||
const content = fs.readFileSync("/etc/shells", "utf8");
|
||||
etcShellPaths = content
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith("#"));
|
||||
} catch (_err) {
|
||||
// /etc/shells not readable — fall through to $SHELL only.
|
||||
}
|
||||
|
||||
// Filter to existing files and deduplicate by real path.
|
||||
const validPaths = [];
|
||||
for (const shellPath of etcShellPaths) {
|
||||
try {
|
||||
if (!fs.existsSync(shellPath)) continue;
|
||||
const real = fs.realpathSync(shellPath);
|
||||
if (seen.has(real)) continue;
|
||||
seen.add(real);
|
||||
validPaths.push(shellPath);
|
||||
} catch (_err) {
|
||||
// Skip unresolvable paths.
|
||||
}
|
||||
}
|
||||
|
||||
// Build DiscoveredShell objects.
|
||||
// Track basename counts to detect duplicates (e.g., /bin/bash vs /usr/local/bin/bash)
|
||||
const baseCount = new Map();
|
||||
for (const shellPath of validPaths) {
|
||||
const base = path.basename(shellPath);
|
||||
baseCount.set(base, (baseCount.get(base) || 0) + 1);
|
||||
}
|
||||
|
||||
for (const shellPath of validPaths) {
|
||||
const base = path.basename(shellPath);
|
||||
const args = isLoginShell(base) ? ["-l"] : [];
|
||||
// Use basename as id when unique, otherwise use path slug to guarantee uniqueness
|
||||
const needsDisambiguation = baseCount.get(base) > 1;
|
||||
const id = needsDisambiguation
|
||||
? shellPath.replace(/^\/+/, "").replace(/[/\\]+/g, "-")
|
||||
: base;
|
||||
const name = needsDisambiguation
|
||||
? `${mapUnixShellName(base)} (${shellPath})`
|
||||
: mapUnixShellName(base);
|
||||
shells.push({
|
||||
id,
|
||||
name,
|
||||
command: shellPath,
|
||||
args,
|
||||
icon: mapUnixShellIcon(base),
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure $SHELL is present — prepend it if missing.
|
||||
const envShell = process.env.SHELL;
|
||||
if (envShell) {
|
||||
try {
|
||||
const envReal = fs.realpathSync(envShell);
|
||||
if (!seen.has(envReal) && fs.existsSync(envShell)) {
|
||||
const base = path.basename(envShell);
|
||||
const args = isLoginShell(base) ? ["-l"] : [];
|
||||
// Check if basename already exists in the list to disambiguate
|
||||
const hasDuplicate = shells.some((s) => path.basename(s.command) === base);
|
||||
const id = hasDuplicate
|
||||
? envShell.replace(/^\/+/, "").replace(/[/\\]+/g, "-")
|
||||
: base;
|
||||
const name = hasDuplicate
|
||||
? `${mapUnixShellName(base)} (${envShell})`
|
||||
: mapUnixShellName(base);
|
||||
shells.unshift({
|
||||
id,
|
||||
name,
|
||||
command: envShell,
|
||||
args,
|
||||
icon: mapUnixShellIcon(base),
|
||||
});
|
||||
}
|
||||
} catch (_err) {
|
||||
// $SHELL path invalid — ignore.
|
||||
}
|
||||
}
|
||||
|
||||
// Mark $SHELL as default (match by command path or basename).
|
||||
if (envShell) {
|
||||
const defaultShell =
|
||||
shells.find((s) => s.command === envShell) ||
|
||||
shells.find((s) => s.id === path.basename(envShell));
|
||||
if (defaultShell) {
|
||||
defaultShell.isDefault = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: mark first shell as default if none matched.
|
||||
if (shells.length > 0 && !shells.some((s) => s.isDefault)) {
|
||||
shells[0].isDefault = true;
|
||||
}
|
||||
|
||||
return shells;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified shell discovery entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Discover all available shells for the current platform.
|
||||
* Results are cached after the first call.
|
||||
*
|
||||
* @returns {Array<{id: string, name: string, command: string, args: string[], icon: string, isDefault?: boolean}>}
|
||||
*/
|
||||
function discoverShells() {
|
||||
if (cachedShells) return cachedShells;
|
||||
|
||||
if (process.platform === "win32") {
|
||||
cachedShells = discoverWindowsShells();
|
||||
} else {
|
||||
cachedShells = discoverUnixShells();
|
||||
}
|
||||
|
||||
return cachedShells;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
module.exports = {
|
||||
discoverShells,
|
||||
discoverWindowsShells,
|
||||
discoverUnixShells,
|
||||
mapUnixShellName,
|
||||
mapUnixShellIcon,
|
||||
isLoginShell,
|
||||
regQueryValue,
|
||||
regEnumSubkeys,
|
||||
findExecutableOnPath,
|
||||
mapWslDistroIcon,
|
||||
};
|
||||
@@ -1912,7 +1912,14 @@ async function listSessionDir(_event, payload) {
|
||||
: dirPath.startsWith("~/")
|
||||
? `"$HOME/${tildePathSuffix}"`
|
||||
: `'${safePath}'`;
|
||||
const cmd = `find ${pathExpr} -mindepth 1 -maxdepth 1 -exec sh -c '
|
||||
// When dirPath is relative (not absolute and not ~/...), exec channels default
|
||||
// to the user's home directory. Resolve the interactive shell's actual cwd first
|
||||
// so that relative paths like "." or "src" are resolved correctly.
|
||||
const needsCwdResolve = !dirPath.startsWith('/') && dirPath !== '~' && !dirPath.startsWith('~/');
|
||||
const cwdResolveCmd = needsCwdResolve
|
||||
? `_sc_p=$(ps --ppid $PPID -o pid=,comm= 2>/dev/null | awk -v self=$$ '$1!=self && $2~/^(ba|z|fi|k|da)?sh$/{pid=$1}END{print pid}'); [ -z "$_sc_p" ] && _sc_p=$(ps -e -o pid=,ppid=,comm= 2>/dev/null | awk -v pp=$PPID -v self=$$ '$1!=self && $2==pp && $3~/^(ba|z|fi|k|da)?sh$/{pid=$1}END{print pid}'); [ -n "$_sc_p" ] && { _sc_d=$(readlink /proc/$_sc_p/cwd 2>/dev/null); [ -n "$_sc_d" ] && cd "$_sc_d" 2>/dev/null; }; `
|
||||
: '';
|
||||
const cmd = `${cwdResolveCmd}find ${pathExpr} -mindepth 1 -maxdepth 1 -exec sh -c '
|
||||
prefix="$1"
|
||||
folders_only="$2"
|
||||
limit="$3"
|
||||
|
||||
@@ -15,6 +15,7 @@ const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
const { detectShellKind } = require("./ai/ptyExec.cjs");
|
||||
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
const { createZmodemSentry } = require("./zmodemHelper.cjs");
|
||||
const { discoverShells } = require("./shellDiscovery.cjs");
|
||||
|
||||
// Shared references
|
||||
let sessions = null;
|
||||
@@ -252,8 +253,20 @@ function startLocalSession(event, payload) {
|
||||
payload?.sessionId ||
|
||||
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const defaultShell = getDefaultLocalShell();
|
||||
const shell = normalizeExecutablePath(payload?.shell) || defaultShell;
|
||||
const shellArgs = getLocalShellArgs(shell);
|
||||
// payload.shell may be a discovered shell ID (e.g., "wsl-ubuntu") — resolve it
|
||||
let resolvedShell = payload?.shell;
|
||||
let resolvedArgs = payload?.shellArgs;
|
||||
if (resolvedShell && !/[/\\]/.test(resolvedShell)) {
|
||||
// Looks like a shell ID, not a path — try to resolve from discovery cache
|
||||
const shells = discoverShells();
|
||||
const match = shells.find((s) => s.id === resolvedShell);
|
||||
if (match) {
|
||||
resolvedShell = match.command;
|
||||
resolvedArgs = resolvedArgs ?? match.args;
|
||||
}
|
||||
}
|
||||
const shell = normalizeExecutablePath(resolvedShell) || defaultShell;
|
||||
const shellArgs = resolvedArgs ?? getLocalShellArgs(shell);
|
||||
const shellKind = detectShellKind(shell);
|
||||
const env = applyLocaleDefaults({
|
||||
...process.env,
|
||||
@@ -1044,6 +1057,7 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:serial:list", listSerialPorts);
|
||||
ipcMain.handle("netcatty:local:defaultShell", getDefaultShell);
|
||||
ipcMain.handle("netcatty:local:validatePath", validatePath);
|
||||
ipcMain.handle("netcatty:shells:discover", () => discoverShells());
|
||||
ipcMain.on("netcatty:write", writeToSession);
|
||||
ipcMain.on("netcatty:resize", resizeSession);
|
||||
ipcMain.on("netcatty:close", closeSession);
|
||||
|
||||
@@ -675,6 +675,11 @@ async function createWindow(electronModule, options) {
|
||||
|
||||
mainWindow = win;
|
||||
|
||||
// Clear reference when the main window is destroyed
|
||||
win.on('closed', () => {
|
||||
if (mainWindow === win) mainWindow = null;
|
||||
});
|
||||
|
||||
// Log renderer crashes for diagnostics (skip normal clean exits)
|
||||
win.webContents.on("render-process-gone", (_event, details) => {
|
||||
if (details?.reason === "clean-exit") return;
|
||||
@@ -716,6 +721,20 @@ async function createWindow(electronModule, options) {
|
||||
win.webContents.on("will-navigate", blockUntrustedNavigation);
|
||||
win.webContents.on("will-redirect", blockUntrustedNavigation);
|
||||
|
||||
// Prevent Chromium from consuming Alt+Arrow as browser back/forward navigation.
|
||||
// Terminal apps need these keys to pass through to the remote shell (e.g., byobu, tmux).
|
||||
// Using setIgnoreMenuShortcuts lets the keydown still reach the page (xterm.js)
|
||||
// while preventing Chromium's built-in shortcuts from triggering.
|
||||
win.webContents.on("before-input-event", (_event, input) => {
|
||||
if (input.alt && !input.control && !input.meta) {
|
||||
if (input.key === "ArrowLeft" || input.key === "ArrowRight") {
|
||||
win.webContents.setIgnoreMenuShortcuts(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
win.webContents.setIgnoreMenuShortcuts(false);
|
||||
});
|
||||
|
||||
// Restore maximized state if it was saved
|
||||
if (savedState?.isMaximized && !savedState?.isFullScreen) {
|
||||
win.once("ready-to-show", () => {
|
||||
@@ -917,6 +936,7 @@ async function openSettingsWindow(electronModule, options, { showOnLoad = true }
|
||||
}
|
||||
|
||||
const win = new BrowserWindow({
|
||||
title: "netcatty Settings",
|
||||
width: settingsWidth,
|
||||
height: settingsHeight,
|
||||
...(settingsX !== undefined && settingsY !== undefined ? { x: settingsX, y: settingsY } : {}),
|
||||
@@ -1042,6 +1062,9 @@ async function openSettingsWindow(electronModule, options, { showOnLoad = true }
|
||||
settingsWindow = null;
|
||||
});
|
||||
|
||||
// Prevent HTML <title> from overriding the window title
|
||||
win.on('page-title-updated', (e) => { e.preventDefault(); });
|
||||
|
||||
// Load the settings page
|
||||
const settingsPath = '/#/settings';
|
||||
|
||||
|
||||
@@ -318,8 +318,8 @@ function registerAppProtocol() {
|
||||
|
||||
function focusMainWindow() {
|
||||
try {
|
||||
const wins = BrowserWindow.getAllWindows();
|
||||
const win = wins && wins.length ? wins[0] : null;
|
||||
const mainWin = getWindowManager().getMainWindow?.();
|
||||
const win = mainWin && !mainWin.isDestroyed?.() ? mainWin : null;
|
||||
if (!win) return false;
|
||||
|
||||
// Check if the webContents has crashed or been destroyed
|
||||
@@ -1074,12 +1074,11 @@ if (!gotLock) {
|
||||
} catch {}
|
||||
|
||||
if (focusMainWindow()) return;
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
void createWindow().catch((err) => {
|
||||
console.error("[Main] Failed to create window on activate:", err);
|
||||
showStartupError(err);
|
||||
});
|
||||
}
|
||||
// Main window doesn't exist — create it even if other windows (e.g. settings) are open
|
||||
void createWindow().catch((err) => {
|
||||
console.error("[Main] Failed to create window on activate:", err);
|
||||
showStartupError(err);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -561,6 +561,7 @@ const api = {
|
||||
getDefaultShell: async () => {
|
||||
return ipcRenderer.invoke("netcatty:local:defaultShell");
|
||||
},
|
||||
discoverShells: () => ipcRenderer.invoke("netcatty:shells:discover"),
|
||||
validatePath: async (path, type) => {
|
||||
return ipcRenderer.invoke("netcatty:local:validatePath", { path, type });
|
||||
},
|
||||
|
||||
13
global.d.ts
vendored
@@ -25,6 +25,16 @@ declare global {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
// Discovered local shell (e.g. CMD, PowerShell, WSL, Git Bash)
|
||||
interface DiscoveredShell {
|
||||
id: string;
|
||||
name: string;
|
||||
command: string;
|
||||
args?: string[];
|
||||
icon: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
// Jump host configuration for SSH tunneling
|
||||
interface NetcattyJumpHost {
|
||||
hostname: string;
|
||||
@@ -176,7 +186,7 @@ declare global {
|
||||
env?: Record<string, string>;
|
||||
sessionLog?: { enabled: boolean; directory: string; format: string };
|
||||
}): Promise<string>;
|
||||
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; cwd?: string; env?: Record<string, string>; sessionLog?: { enabled: boolean; directory: string; format: string } }): Promise<string>;
|
||||
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; shellArgs?: string[]; cwd?: string; env?: Record<string, string>; sessionLog?: { enabled: boolean; directory: string; format: string } }): Promise<string>;
|
||||
startSerialSession?(options: {
|
||||
sessionId?: string;
|
||||
path: string;
|
||||
@@ -197,6 +207,7 @@ declare global {
|
||||
pnpId: string;
|
||||
}>>;
|
||||
getDefaultShell?(): Promise<string>;
|
||||
discoverShells?(): Promise<DiscoveredShell[]>;
|
||||
validatePath?(path: string, type?: 'file' | 'directory' | 'any'): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean }>;
|
||||
generateKeyPair?(options: {
|
||||
type: 'RSA' | 'ECDSA' | 'ED25519';
|
||||
|
||||
34
index.css
@@ -78,6 +78,28 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@keyframes pop-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.82) translateY(6px);
|
||||
}
|
||||
45% {
|
||||
opacity: 1;
|
||||
transform: scale(1.06) translateY(-2px);
|
||||
}
|
||||
72% {
|
||||
transform: scale(0.97) translateY(1px);
|
||||
}
|
||||
88% {
|
||||
transform: scale(1.01);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -144,6 +166,16 @@ body {
|
||||
background: linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--background)) 60%, hsl(var(--background) / 0.9) 100%);
|
||||
}
|
||||
|
||||
/* Slim down xterm 6.0 VS Code scrollbar — wide hit area, thin visual slider */
|
||||
.xterm .xterm-scrollable-element > .scrollbar.vertical {
|
||||
width: 12px !important;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .scrollbar.vertical > .slider {
|
||||
width: 6px !important;
|
||||
border-radius: 3px;
|
||||
left: 3px !important;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
@@ -337,7 +369,7 @@ body {
|
||||
|
||||
/* Dim terminal text in unfocused workspace panes (default) */
|
||||
.workspace-pane:not(:focus-within) .xterm-screen {
|
||||
opacity: 0.65;
|
||||
opacity: 0.82;
|
||||
}
|
||||
/* Border-style focus indicator (opt-in via data attribute) */
|
||||
[data-workspace-focus="border"] .workspace-pane:not(:focus-within) .xterm-screen {
|
||||
|
||||
@@ -108,6 +108,12 @@ export const STORAGE_KEY_WORKSPACE_FOCUS_STYLE = 'netcatty_workspace_focus_style
|
||||
// Immersive Mode
|
||||
export const STORAGE_KEY_IMMERSIVE_MODE = 'netcatty_immersive_mode_v1';
|
||||
|
||||
// Vault: Show Recently Connected hosts section
|
||||
export const STORAGE_KEY_SHOW_RECENT_HOSTS = 'netcatty_show_recent_hosts_v1';
|
||||
|
||||
// Group Configurations (default settings inherited by hosts)
|
||||
export const STORAGE_KEY_GROUP_CONFIGS = 'netcatty_group_configs_v1';
|
||||
|
||||
// Side Panel
|
||||
export const STORAGE_KEY_SIDE_PANEL_WIDTH = 'netcatty_side_panel_width';
|
||||
|
||||
|
||||
@@ -1677,5 +1677,329 @@ export const TERMINAL_THEMES: TerminalTheme[] = [
|
||||
brightCyan: '#83c092',
|
||||
brightWhite: '#5c6d64'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'github-dark',
|
||||
name: 'GitHub Dark',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
background: '#0d1117',
|
||||
foreground: '#e6edf3',
|
||||
cursor: '#2f81f7',
|
||||
selection: '#264f78',
|
||||
black: '#484f58',
|
||||
red: '#ff7b72',
|
||||
green: '#3fb950',
|
||||
yellow: '#d29922',
|
||||
blue: '#58a6ff',
|
||||
magenta: '#bc8cff',
|
||||
cyan: '#39c5cf',
|
||||
white: '#b1bac4',
|
||||
brightBlack: '#6e7681',
|
||||
brightRed: '#ffa198',
|
||||
brightGreen: '#56d364',
|
||||
brightYellow: '#e3b341',
|
||||
brightBlue: '#79c0ff',
|
||||
brightMagenta: '#d2a8ff',
|
||||
brightCyan: '#56d4dd',
|
||||
brightWhite: '#ffffff',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'github-light',
|
||||
name: 'GitHub Light',
|
||||
type: 'light',
|
||||
colors: {
|
||||
background: '#ffffff',
|
||||
foreground: '#1f2328',
|
||||
cursor: '#0969da',
|
||||
selection: '#add6ff',
|
||||
black: '#24292f',
|
||||
red: '#cf222e',
|
||||
green: '#116329',
|
||||
yellow: '#4d2d00',
|
||||
blue: '#0969da',
|
||||
magenta: '#8250df',
|
||||
cyan: '#1b7c83',
|
||||
white: '#6e7781',
|
||||
brightBlack: '#57606a',
|
||||
brightRed: '#a40e26',
|
||||
brightGreen: '#1a7f37',
|
||||
brightYellow: '#633c01',
|
||||
brightBlue: '#218bff',
|
||||
brightMagenta: '#a475f9',
|
||||
brightCyan: '#3192aa',
|
||||
brightWhite: '#8c959f',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'ubuntu',
|
||||
name: 'Ubuntu',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
background: '#300a24',
|
||||
foreground: '#eeeeec',
|
||||
cursor: '#bbbbbb',
|
||||
selection: '#b5d5ff',
|
||||
black: '#2e3436',
|
||||
red: '#cc0000',
|
||||
green: '#4e9a06',
|
||||
yellow: '#c4a000',
|
||||
blue: '#3465a4',
|
||||
magenta: '#75507b',
|
||||
cyan: '#06989a',
|
||||
white: '#d3d7cf',
|
||||
brightBlack: '#555753',
|
||||
brightRed: '#ef2929',
|
||||
brightGreen: '#8ae234',
|
||||
brightYellow: '#fce94f',
|
||||
brightBlue: '#729fcf',
|
||||
brightMagenta: '#ad7fa8',
|
||||
brightCyan: '#34e2e2',
|
||||
brightWhite: '#eeeeec',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'one-dark-pro',
|
||||
name: 'One Dark Pro',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
background: '#282c34',
|
||||
foreground: '#abb2bf',
|
||||
cursor: '#528bff',
|
||||
selection: '#3e4452',
|
||||
black: '#3f4451',
|
||||
red: '#e05561',
|
||||
green: '#8cc265',
|
||||
yellow: '#d18f52',
|
||||
blue: '#4aa5f0',
|
||||
magenta: '#c162de',
|
||||
cyan: '#42b3c2',
|
||||
white: '#d7dae0',
|
||||
brightBlack: '#4f5666',
|
||||
brightRed: '#ff616e',
|
||||
brightGreen: '#a5e075',
|
||||
brightYellow: '#f0a45d',
|
||||
brightBlue: '#4dc4ff',
|
||||
brightMagenta: '#de73ff',
|
||||
brightCyan: '#4cd1e0',
|
||||
brightWhite: '#e6e6e6',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'horizon-dark',
|
||||
name: 'Horizon',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
background: '#1c1e26',
|
||||
foreground: '#d5d8da',
|
||||
cursor: '#6c6f93',
|
||||
selection: '#6c6f93',
|
||||
black: '#16161c',
|
||||
red: '#e95678',
|
||||
green: '#29d398',
|
||||
yellow: '#fab795',
|
||||
blue: '#26bbd9',
|
||||
magenta: '#ee64ac',
|
||||
cyan: '#59e1e3',
|
||||
white: '#d5d8da',
|
||||
brightBlack: '#6c6f93',
|
||||
brightRed: '#ec6a88',
|
||||
brightGreen: '#3fdaa4',
|
||||
brightYellow: '#fbc3a7',
|
||||
brightBlue: '#3fc4de',
|
||||
brightMagenta: '#f075b5',
|
||||
brightCyan: '#6be4e6',
|
||||
brightWhite: '#ffffff',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'palenight',
|
||||
name: 'Palenight',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
background: '#292d3e',
|
||||
foreground: '#bfc7d5',
|
||||
cursor: '#ffcc00',
|
||||
selection: '#7580b8',
|
||||
black: '#292d3e',
|
||||
red: '#ff5572',
|
||||
green: '#a9c77d',
|
||||
yellow: '#ffcb6b',
|
||||
blue: '#82aaff',
|
||||
magenta: '#c792ea',
|
||||
cyan: '#89ddff',
|
||||
white: '#d0d0d0',
|
||||
brightBlack: '#676e95',
|
||||
brightRed: '#ff5572',
|
||||
brightGreen: '#c3e88d',
|
||||
brightYellow: '#ffcb6b',
|
||||
brightBlue: '#82aaff',
|
||||
brightMagenta: '#c792ea',
|
||||
brightCyan: '#89ddff',
|
||||
brightWhite: '#ffffff',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'panda',
|
||||
name: 'Panda',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
background: '#292a2b',
|
||||
foreground: '#e6e6e6',
|
||||
cursor: '#ff4b82',
|
||||
selection: '#454647',
|
||||
black: '#757575',
|
||||
red: '#ff2c6d',
|
||||
green: '#19f9d8',
|
||||
yellow: '#ffb86c',
|
||||
blue: '#45a9f9',
|
||||
magenta: '#ff75b5',
|
||||
cyan: '#b084eb',
|
||||
white: '#cdcdcd',
|
||||
brightBlack: '#757575',
|
||||
brightRed: '#ff2c6d',
|
||||
brightGreen: '#19f9d8',
|
||||
brightYellow: '#ffcc95',
|
||||
brightBlue: '#6fc1ff',
|
||||
brightMagenta: '#ff9ac1',
|
||||
brightCyan: '#bcaafe',
|
||||
brightWhite: '#e6e6e6',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'snazzy',
|
||||
name: 'Snazzy',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
background: '#1e1f29',
|
||||
foreground: '#ebece6',
|
||||
cursor: '#e4e4e4',
|
||||
selection: '#81aec6',
|
||||
black: '#000000',
|
||||
red: '#fc4346',
|
||||
green: '#50fb7c',
|
||||
yellow: '#f0fb8c',
|
||||
blue: '#49baff',
|
||||
magenta: '#fc4cb4',
|
||||
cyan: '#8be9fe',
|
||||
white: '#ededec',
|
||||
brightBlack: '#555555',
|
||||
brightRed: '#fc4346',
|
||||
brightGreen: '#50fb7c',
|
||||
brightYellow: '#f0fb8c',
|
||||
brightBlue: '#49baff',
|
||||
brightMagenta: '#fc4cb4',
|
||||
brightCyan: '#8be9fe',
|
||||
brightWhite: '#ededec',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'synthwave-84',
|
||||
name: "Synthwave '84",
|
||||
type: 'dark',
|
||||
colors: {
|
||||
background: '#262335',
|
||||
foreground: '#f0eff1',
|
||||
cursor: '#72f1b8',
|
||||
selection: '#463465',
|
||||
black: '#241b30',
|
||||
red: '#fe4450',
|
||||
green: '#72f1b8',
|
||||
yellow: '#fede5d',
|
||||
blue: '#03edf9',
|
||||
magenta: '#ff7edb',
|
||||
cyan: '#03edf9',
|
||||
white: '#f0eff1',
|
||||
brightBlack: '#7f7094',
|
||||
brightRed: '#fe4450',
|
||||
brightGreen: '#72f1b8',
|
||||
brightYellow: '#f9f972',
|
||||
brightBlue: '#aa54f9',
|
||||
brightMagenta: '#ff7edb',
|
||||
brightCyan: '#03edf9',
|
||||
brightWhite: '#f2f2e3',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'vesper',
|
||||
name: 'Vesper',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
background: '#101010',
|
||||
foreground: '#ffffff',
|
||||
cursor: '#acb1ab',
|
||||
selection: '#988049',
|
||||
black: '#101010',
|
||||
red: '#f5a191',
|
||||
green: '#90b99f',
|
||||
yellow: '#e6b99d',
|
||||
blue: '#aca1cf',
|
||||
magenta: '#e29eca',
|
||||
cyan: '#ea83a5',
|
||||
white: '#a0a0a0',
|
||||
brightBlack: '#7e7e7e',
|
||||
brightRed: '#ff8080',
|
||||
brightGreen: '#99ffe4',
|
||||
brightYellow: '#ffc799',
|
||||
brightBlue: '#b9aeda',
|
||||
brightMagenta: '#ecaad6',
|
||||
brightCyan: '#f591b2',
|
||||
brightWhite: '#ffffff',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'kanso-dark',
|
||||
name: 'Kanso Dark',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
background: '#090e13',
|
||||
foreground: '#c5c9c7',
|
||||
cursor: '#c5c9c7',
|
||||
selection: '#393b44',
|
||||
black: '#0d0c0c',
|
||||
red: '#c4746e',
|
||||
green: '#8a9a7b',
|
||||
yellow: '#c4b28a',
|
||||
blue: '#8ba4b0',
|
||||
magenta: '#a292a3',
|
||||
cyan: '#8ea4a2',
|
||||
white: '#c8c093',
|
||||
brightBlack: '#a4a7a4',
|
||||
brightRed: '#e46876',
|
||||
brightGreen: '#87a987',
|
||||
brightYellow: '#e6c384',
|
||||
brightBlue: '#7fbbb3',
|
||||
brightMagenta: '#938aa9',
|
||||
brightCyan: '#7aa89f',
|
||||
brightWhite: '#c5c9c7',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'kanso-light',
|
||||
name: 'Kanso Light',
|
||||
type: 'light',
|
||||
colors: {
|
||||
background: '#f2f1ef',
|
||||
foreground: '#22262d',
|
||||
cursor: '#22262d',
|
||||
selection: '#e2e1df',
|
||||
black: '#22262d',
|
||||
red: '#c84053',
|
||||
green: '#6f894e',
|
||||
yellow: '#77713f',
|
||||
blue: '#4d699b',
|
||||
magenta: '#b35b79',
|
||||
cyan: '#597b75',
|
||||
white: '#545464',
|
||||
brightBlack: '#6d6f6e',
|
||||
brightRed: '#d7474b',
|
||||
brightGreen: '#6e915f',
|
||||
brightYellow: '#836f4a',
|
||||
brightBlue: '#6693bf',
|
||||
brightMagenta: '#624c83',
|
||||
brightCyan: '#5e857a',
|
||||
brightWhite: '#43436c',
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
@@ -46,8 +46,8 @@ export const XTERM_PERFORMANCE_CONFIG = {
|
||||
// Enable WebGL by default for GPU acceleration
|
||||
enabled: true,
|
||||
|
||||
// User can choose Canvas renderer on any platform
|
||||
preferCanvas: false,
|
||||
// User can choose DOM renderer on any platform (canvas removed in xterm 6.0)
|
||||
preferDOM: false,
|
||||
|
||||
// Handle WebGL context loss gracefully
|
||||
enableContextLoss: true,
|
||||
@@ -107,7 +107,7 @@ export const XTERM_PERFORMANCE_CONFIG = {
|
||||
|
||||
export type XTermPlatform = "darwin" | "win32" | "linux";
|
||||
|
||||
type RendererType = "canvas" | "dom";
|
||||
type RendererType = "dom";
|
||||
type LogLevel = "off" | "error" | "warn" | "info" | "debug";
|
||||
|
||||
export type ResolvedXTermPerformance = {
|
||||
@@ -127,7 +127,7 @@ export type ResolvedXTermPerformance = {
|
||||
rendererType?: RendererType;
|
||||
};
|
||||
useWebGLAddon: boolean;
|
||||
preferCanvasRenderer: boolean;
|
||||
preferDOMRenderer: boolean;
|
||||
};
|
||||
|
||||
const isLowMemoryDevice = (deviceMemoryGb?: number) =>
|
||||
@@ -141,11 +141,11 @@ export function getXTermConfig(platform: XTermPlatform = "darwin") {
|
||||
return resolveXTermPerformanceConfig({ platform }).options;
|
||||
}
|
||||
|
||||
export type RendererPreference = "auto" | "webgl" | "canvas";
|
||||
export type RendererPreference = "auto" | "webgl" | "dom";
|
||||
|
||||
/**
|
||||
* Resolve a platform and hardware aware performance profile.
|
||||
* When rendererType is 'auto', uses Canvas on low-memory devices to avoid WebGL overhead.
|
||||
* When rendererType is 'auto', uses DOM on low-memory devices to avoid WebGL overhead.
|
||||
*/
|
||||
export function resolveXTermPerformanceConfig({
|
||||
platform = "darwin",
|
||||
@@ -160,15 +160,15 @@ export function resolveXTermPerformanceConfig({
|
||||
|
||||
const lowMem = isLowMemoryDevice(deviceMemoryGb);
|
||||
|
||||
// Determine if we should use Canvas renderer
|
||||
let resolvedPreferCanvas: boolean;
|
||||
if (rendererType === "canvas") {
|
||||
resolvedPreferCanvas = true;
|
||||
// Determine if we should use DOM renderer (canvas removed in xterm 6.0)
|
||||
let resolvedPreferDOM: boolean;
|
||||
if (rendererType === "dom") {
|
||||
resolvedPreferDOM = true;
|
||||
} else if (rendererType === "webgl") {
|
||||
resolvedPreferCanvas = false;
|
||||
resolvedPreferDOM = false;
|
||||
} else {
|
||||
// Auto mode: use Canvas on low-memory devices
|
||||
resolvedPreferCanvas = baseConfig.webgl.preferCanvas || lowMem;
|
||||
// Auto mode: use DOM on low-memory devices
|
||||
resolvedPreferDOM = baseConfig.webgl.preferDOM || lowMem;
|
||||
}
|
||||
|
||||
const scrollbackProfile = lowMem
|
||||
@@ -177,7 +177,7 @@ export function resolveXTermPerformanceConfig({
|
||||
? "macOS"
|
||||
: "default";
|
||||
|
||||
const resolvedRendererType = resolvedPreferCanvas ? ("canvas" as const) : undefined;
|
||||
const resolvedRendererType = resolvedPreferDOM ? ("dom" as const) : undefined;
|
||||
|
||||
const baseOptions = {
|
||||
scrollback: baseConfig.scrollback[scrollbackProfile],
|
||||
@@ -200,7 +200,7 @@ export function resolveXTermPerformanceConfig({
|
||||
|
||||
return {
|
||||
options,
|
||||
useWebGLAddon: baseConfig.webgl.enabled && !resolvedPreferCanvas,
|
||||
preferCanvasRenderer: resolvedPreferCanvas,
|
||||
useWebGLAddon: baseConfig.webgl.enabled && !resolvedPreferDOM,
|
||||
preferDOMRenderer: resolvedPreferDOM,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* function degrades to a no-op — values pass through unmodified.
|
||||
*/
|
||||
|
||||
import type { Host, Identity, SSHKey } from "../../domain/models";
|
||||
import type { GroupConfig, Host, Identity, SSHKey } from "../../domain/models";
|
||||
import type { ProviderConnection, S3Config, WebDAVConfig } from "../../domain/sync";
|
||||
import { netcattyBridge } from "../services/netcattyBridge";
|
||||
|
||||
@@ -91,6 +91,38 @@ export async function decryptIdentitySecrets(identity: Identity): Promise<Identi
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GroupConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function encryptGroupConfigSecrets(config: GroupConfig): Promise<GroupConfig> {
|
||||
const out = { ...config };
|
||||
out.password = await encryptField(out.password);
|
||||
out.telnetPassword = await encryptField(out.telnetPassword);
|
||||
if (out.proxyConfig?.password) {
|
||||
out.proxyConfig = { ...out.proxyConfig, password: await encryptField(out.proxyConfig.password) };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function decryptGroupConfigSecrets(config: GroupConfig): Promise<GroupConfig> {
|
||||
const out = { ...config };
|
||||
out.password = await decryptField(out.password);
|
||||
out.telnetPassword = await decryptField(out.telnetPassword);
|
||||
if (out.proxyConfig?.password) {
|
||||
out.proxyConfig = { ...out.proxyConfig, password: await decryptField(out.proxyConfig.password) };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function encryptGroupConfigs(configs: GroupConfig[]): Promise<GroupConfig[]> {
|
||||
return Promise.all(configs.map(encryptGroupConfigSecrets));
|
||||
}
|
||||
|
||||
export function decryptGroupConfigs(configs: GroupConfig[]): Promise<GroupConfig[]> {
|
||||
return Promise.all(configs.map(decryptGroupConfigSecrets));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider Connection (Cloud Sync)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -104,11 +104,12 @@ export async function getMonospaceFonts(): Promise<TerminalFont[]> {
|
||||
// Filter monospace fonts using robust word boundary matching
|
||||
const monoFonts = fonts.filter(f => isMonospaceFont(f.family));
|
||||
|
||||
// Deduplicate by family name (API may return multiple entries per family)
|
||||
// Deduplicate by family name, case-insensitive (API may return multiple entries per family)
|
||||
const uniqueFamilies = new Set<string>();
|
||||
const dedupedFonts = monoFonts.filter(f => {
|
||||
if (uniqueFamilies.has(f.family)) return false;
|
||||
uniqueFamilies.add(f.family);
|
||||
const key = f.family.toLowerCase();
|
||||
if (uniqueFamilies.has(key)) return false;
|
||||
uniqueFamilies.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ export type LocalOs = 'linux' | 'macos' | 'windows';
|
||||
const POWERSHELL_SHELLS = new Set(['powershell', 'powershell.exe', 'pwsh', 'pwsh.exe']);
|
||||
const CMD_SHELLS = new Set(['cmd', 'cmd.exe']);
|
||||
const FISH_SHELLS = new Set(['fish']);
|
||||
const POSIX_SHELLS = new Set(['sh', 'bash', 'zsh', 'ksh', 'dash', 'ash']);
|
||||
const POSIX_SHELLS = new Set(['sh', 'bash', 'zsh', 'ksh', 'dash', 'ash', 'bash.exe']);
|
||||
// WSL launcher — runs a Linux shell inside WSL, classify as posix
|
||||
const WSL_SHELLS = new Set(['wsl', 'wsl.exe']);
|
||||
|
||||
const getExecutableBaseName = (filePath: string | undefined): string => {
|
||||
const normalized = String(filePath || '').trim();
|
||||
@@ -29,6 +31,7 @@ export const classifyLocalShellType = (
|
||||
if (CMD_SHELLS.has(shellName)) return 'cmd';
|
||||
if (FISH_SHELLS.has(shellName)) return 'fish';
|
||||
if (POSIX_SHELLS.has(shellName)) return 'posix';
|
||||
if (WSL_SHELLS.has(shellName)) return 'posix';
|
||||
if (!shellName) {
|
||||
return detectLocalOs(platformLike) === 'windows' ? 'powershell' : 'posix';
|
||||
}
|
||||
|
||||
77
lib/useDiscoveredShells.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { netcattyBridge } from "../infrastructure/services/netcattyBridge";
|
||||
|
||||
let shellCache: DiscoveredShell[] | null = null;
|
||||
let shellPromise: Promise<DiscoveredShell[]> | null = null;
|
||||
|
||||
export function useDiscoveredShells(): DiscoveredShell[] {
|
||||
const [shells, setShells] = useState<DiscoveredShell[]>(shellCache ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
if (shellCache) {
|
||||
setShells(shellCache);
|
||||
return;
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.discoverShells) return;
|
||||
|
||||
if (!shellPromise) {
|
||||
shellPromise = bridge.discoverShells();
|
||||
}
|
||||
|
||||
shellPromise.then((result) => {
|
||||
shellCache = result;
|
||||
setShells(result);
|
||||
}).catch((err) => {
|
||||
console.warn("Failed to discover shells:", err);
|
||||
// Clear the failed promise so the next mount can retry
|
||||
shellPromise = null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return shells;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a localShell setting value to shell command and args.
|
||||
* The value can be a discovered shell id (e.g., "wsl-ubuntu", "pwsh")
|
||||
* or a custom path/command (e.g., "/usr/local/bin/fish" or "fish").
|
||||
* Returns { command, args } or null when discovery hasn't loaded yet
|
||||
* and the value might be a shell ID that can't be resolved yet.
|
||||
*/
|
||||
export function resolveShellSetting(
|
||||
localShell: string,
|
||||
discoveredShells: DiscoveredShell[]
|
||||
): { command: string; args?: string[] } | null {
|
||||
if (!localShell) return null;
|
||||
|
||||
// Try to match as a discovered shell id
|
||||
const shell = discoveredShells.find(s => s.id === localShell);
|
||||
if (shell) {
|
||||
return { command: shell.command, args: shell.args };
|
||||
}
|
||||
|
||||
// No ID match — treat as a custom shell path/command and pass through.
|
||||
// This handles both custom executables (e.g., "/usr/local/bin/fish", "pwsh-preview")
|
||||
// and stale/synced IDs that no longer exist on this machine (graceful fallback
|
||||
// to whatever the OS resolves the name to, or a spawn error the user can see).
|
||||
return { command: localShell };
|
||||
}
|
||||
|
||||
const DISTRO_ICONS = new Set([
|
||||
"ubuntu", "debian", "kali", "alpine", "opensuse",
|
||||
"fedora", "arch", "oracle", "linux",
|
||||
]);
|
||||
|
||||
export function getShellIconPath(iconId: string): string {
|
||||
if (DISTRO_ICONS.has(iconId)) {
|
||||
return `/distro/${iconId}.svg`;
|
||||
}
|
||||
return `/shells/${iconId}.svg`;
|
||||
}
|
||||
|
||||
/** Distro icons are monochrome black and need `dark:invert` in dark mode */
|
||||
export function isMonochromeShellIcon(iconId: string): boolean {
|
||||
return DISTRO_ICONS.has(iconId);
|
||||
}
|
||||
87
package-lock.json
generated
@@ -32,13 +32,13 @@
|
||||
"@streamdown/cjk": "^1.0.2",
|
||||
"@streamdown/code": "^1.1.0",
|
||||
"@withfig/autocomplete": "^2.692.3",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-serialize": "^0.13.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-serialize": "^0.14.0",
|
||||
"@xterm/addon-unicode-graphemes": "^0.4.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@zed-industries/claude-agent-acp": "0.22.2",
|
||||
"@zed-industries/codex-acp": "0.10.0",
|
||||
"ai": "^6.0.116",
|
||||
@@ -6685,62 +6685,49 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-search": {
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz",
|
||||
"integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz",
|
||||
"integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-serialize": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.13.0.tgz",
|
||||
"integrity": "sha512-kGs8o6LWAmN1l2NpMp01/YkpxbmO4UrfWybeGu79Khw5K9+Krp7XhXbBTOTc3GJRRhd6EmILjpR8k5+odY39YQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz",
|
||||
"integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-unicode11": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
|
||||
"integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==",
|
||||
"node_modules/@xterm/addon-unicode-graphemes": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode-graphemes/-/addon-unicode-graphemes-0.4.0.tgz",
|
||||
"integrity": "sha512-9+/CqwbKcnlkJU4d3wIgO+wjsL8f6vyz+UwUWLu6nADQz8Gr8ONqGCJfdDjIdI+yYZLABQqQy47FzEM6AWELjw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-web-links": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
|
||||
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-webgl": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz",
|
||||
"integrity": "sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
|
||||
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/@yarnpkg/lockfile": {
|
||||
"version": "1.1.0",
|
||||
|
||||
14
package.json
@@ -50,13 +50,13 @@
|
||||
"@streamdown/cjk": "^1.0.2",
|
||||
"@streamdown/code": "^1.1.0",
|
||||
"@withfig/autocomplete": "^2.692.3",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-serialize": "^0.13.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-serialize": "^0.14.0",
|
||||
"@xterm/addon-unicode-graphemes": "^0.4.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@zed-industries/claude-agent-acp": "0.22.2",
|
||||
"@zed-industries/codex-acp": "0.10.0",
|
||||
"ai": "^6.0.116",
|
||||
|
||||
6
public/shells/bash.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- Dark charcoal rounded square -->
|
||||
<rect width="32" height="32" rx="6" fill="#2D2D2D"/>
|
||||
<!-- Green $_ prompt, bold and centered -->
|
||||
<text x="7" y="21" font-family="monospace" font-size="16" font-weight="bold" fill="#4EC9B0">$_</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 313 B |
8
public/shells/cmd.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- Dark background -->
|
||||
<rect width="32" height="32" rx="6" fill="#1E1E1E"/>
|
||||
<!-- Classic green C:\> prompt -->
|
||||
<text x="3" y="15" font-family="monospace" font-size="8" font-weight="bold" fill="#00FF00">C:\></text>
|
||||
<!-- Blinking cursor line -->
|
||||
<rect x="3" y="20" width="8" height="2" fill="#00FF00" opacity="0.8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 400 B |
8
public/shells/cygwin.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- Dark rounded square -->
|
||||
<rect width="32" height="32" rx="6" fill="#1A1A2E"/>
|
||||
<!-- Gold border accent -->
|
||||
<rect x="1" y="1" width="30" height="30" rx="5" fill="none" stroke="#FFD700" stroke-width="1.5"/>
|
||||
<!-- CY text in gold -->
|
||||
<text x="16" y="21" font-family="monospace" font-size="13" font-weight="bold" fill="#FFD700" text-anchor="middle">CY</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 437 B |
15
public/shells/fish.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- Fish shell: simple fish shape in Fish shell green -->
|
||||
<rect width="32" height="32" rx="6" fill="#1A2E1A"/>
|
||||
<!-- Fish body -->
|
||||
<ellipse cx="15" cy="16" rx="9" ry="6" fill="#4DB380"/>
|
||||
<!-- Fish tail -->
|
||||
<path d="M24 16 L29 11 L29 21 Z" fill="#3A9966"/>
|
||||
<!-- Fish eye -->
|
||||
<circle cx="10" cy="14" r="1.5" fill="#FFFFFF"/>
|
||||
<circle cx="10" cy="14" r="0.8" fill="#1A2E1A"/>
|
||||
<!-- Fish fin -->
|
||||
<path d="M13 11 Q16 8 19 11" fill="none" stroke="#98C379" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<!-- Subtle highlight -->
|
||||
<ellipse cx="14" cy="14" rx="4" ry="2" fill="#98C379" opacity="0.3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 682 B |
13
public/shells/git-bash.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- Git logo inspired icon -->
|
||||
<rect width="32" height="32" rx="5" fill="#F05032"/>
|
||||
<!-- Git diamond shape -->
|
||||
<path d="M16 4 L28 16 L16 28 L4 16 Z" fill="none" stroke="#FFFFFF" stroke-width="2.5"/>
|
||||
<!-- Git branch lines inside -->
|
||||
<circle cx="16" cy="11" r="2.5" fill="#FFFFFF"/>
|
||||
<circle cx="11" cy="16" r="2.5" fill="#FFFFFF"/>
|
||||
<circle cx="21" cy="16" r="2.5" fill="#FFFFFF"/>
|
||||
<line x1="16" y1="13.5" x2="16" y2="22" stroke="#FFFFFF" stroke-width="2"/>
|
||||
<line x1="13.3" y1="17.5" x2="16" y2="22" stroke="#FFFFFF" stroke-width="2"/>
|
||||
<circle cx="16" cy="22" r="2" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 671 B |
9
public/shells/nushell.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- Dark background -->
|
||||
<rect width="32" height="32" rx="6" fill="#0D1117"/>
|
||||
<!-- Teal "nu" text -->
|
||||
<text x="5" y="17" font-family="monospace" font-size="12" font-weight="bold" fill="#4EAA97">nu</text>
|
||||
<!-- > prompt with cursor -->
|
||||
<text x="5" y="27" font-family="monospace" font-size="10" font-weight="bold" fill="#56D4C0">></text>
|
||||
<text x="13" y="27" font-family="monospace" font-size="10" fill="#3A9985">_</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 503 B |
7
public/shells/powershell.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- PowerShell blue rounded square -->
|
||||
<rect width="32" height="32" rx="6" fill="#012456"/>
|
||||
<!-- PS> prompt -->
|
||||
<text x="4" y="15" font-family="monospace" font-size="10" font-weight="bold" fill="#FFFFFF">PS</text>
|
||||
<text x="4" y="27" font-family="monospace" font-size="12" font-weight="bold" fill="#2CA5E0">>_</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 398 B |
8
public/shells/pwsh.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- Darker background for PowerShell Core -->
|
||||
<rect width="32" height="32" rx="6" fill="#0D1117"/>
|
||||
<!-- PS7 label -->
|
||||
<text x="4" y="16" font-family="monospace" font-size="9" font-weight="bold" fill="#5BC4F5">PS7</text>
|
||||
<!-- >_ prompt -->
|
||||
<text x="4" y="27" font-family="monospace" font-size="11" font-weight="bold" fill="#2CA5E0">>_</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 425 B |
6
public/shells/terminal.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- Neutral gray rounded square -->
|
||||
<rect width="32" height="32" rx="6" fill="#3C3C3C"/>
|
||||
<!-- White >_ prompt -->
|
||||
<text x="6" y="21" font-family="monospace" font-size="16" font-weight="bold" fill="#FFFFFF">>_</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 296 B |
8
public/shells/zsh.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- Dark navy rounded square -->
|
||||
<rect width="32" height="32" rx="6" fill="#1B1F3B"/>
|
||||
<!-- Teal % prompt -->
|
||||
<text x="8" y="18" font-family="monospace" font-size="14" font-weight="bold" fill="#00D4AA">%</text>
|
||||
<!-- zsh label underneath -->
|
||||
<text x="16" y="28" font-family="sans-serif" font-size="7" font-weight="bold" fill="#00D4AA" text-anchor="middle" opacity="0.7">zsh</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 460 B |