Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41fea1028d | ||
|
|
5a90a4331b | ||
|
|
881f3b1a34 | ||
|
|
8be5865b76 | ||
|
|
685d1cb41a | ||
|
|
14fe1e3ecb | ||
|
|
636f4d7037 |
4
App.tsx
4
App.tsx
@@ -229,6 +229,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
closeSession,
|
||||
closeWorkspace,
|
||||
updateSessionStatus,
|
||||
updateSessionFontSize,
|
||||
clearSessionFontSizeOverride,
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
@@ -985,7 +987,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
logViews={logViews}
|
||||
t={t}
|
||||
/>
|
||||
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
|
||||
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Host, HostProtocol } from '../../types';
|
||||
import type { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import { getEffectiveHostDistro } from '../../domain/host';
|
||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||
|
||||
type AppContextGetter = () => Record<string, any>;
|
||||
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
|
||||
@@ -444,13 +445,19 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
const allTabs = settings.showSftpTab
|
||||
? ['vault', 'sftp', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))]
|
||||
: ['vault', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))];
|
||||
const numberShortcutTabs = buildNumberShortcutTabTargets({
|
||||
showSftpTab: settings.showSftpTab ?? true,
|
||||
shellOnlyTabNumberShortcuts: settings.shellOnlyTabNumberShortcuts ?? false,
|
||||
orderedTabs,
|
||||
editorTabIds: editorTabs.map((t) => toEditorTabId(t.id)),
|
||||
});
|
||||
switch (action) {
|
||||
case 'switchToTab': {
|
||||
// Get the number key pressed (1-9)
|
||||
const num = parseInt(e.key, 10);
|
||||
if (num >= 1 && num <= 9) {
|
||||
if (num <= allTabs.length) {
|
||||
setActiveTabId(allTabs[num - 1]);
|
||||
if (num <= numberShortcutTabs.length) {
|
||||
setActiveTabId(numberShortcutTabs[num - 1]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -45,7 +45,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename,
|
||||
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
|
||||
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId,
|
||||
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, updateSessionFontSize, clearSessionFontSizeOverride,
|
||||
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
|
||||
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId,
|
||||
@@ -254,6 +254,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onUpdateTerminalThemeId={setTerminalThemeId}
|
||||
onUpdateTerminalFontFamilyId={setTerminalFontFamilyId}
|
||||
onUpdateTerminalFontSize={setTerminalFontSize}
|
||||
onUpdateSessionFontSize={updateSessionFontSize}
|
||||
onClearSessionFontSizeOverride={clearSessionFontSizeOverride}
|
||||
onUpdateTerminalFontWeight={(w) => updateTerminalSetting('fontWeight', w)}
|
||||
onCloseSession={closeSession}
|
||||
onUpdateSessionStatus={handleSessionStatusChange}
|
||||
|
||||
40
application/app/tabShortcutTargets.test.ts
Normal file
40
application/app/tabShortcutTargets.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets.ts';
|
||||
|
||||
test('number shortcut tabs include vault and sftp by default', () => {
|
||||
assert.deepEqual(
|
||||
buildNumberShortcutTabTargets({
|
||||
showSftpTab: true,
|
||||
shellOnlyTabNumberShortcuts: false,
|
||||
orderedTabs: ['session-1', 'workspace-1'],
|
||||
editorTabIds: ['editor:file-1'],
|
||||
}),
|
||||
['vault', 'sftp', 'session-1', 'workspace-1', 'editor:file-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('number shortcut tabs skip vault and sftp when shell-only mode is enabled', () => {
|
||||
assert.deepEqual(
|
||||
buildNumberShortcutTabTargets({
|
||||
showSftpTab: true,
|
||||
shellOnlyTabNumberShortcuts: true,
|
||||
orderedTabs: ['session-1', 'workspace-1'],
|
||||
editorTabIds: ['editor:file-1'],
|
||||
}),
|
||||
['session-1', 'workspace-1', 'editor:file-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('hidden sftp tab is omitted from default number shortcut targets', () => {
|
||||
assert.deepEqual(
|
||||
buildNumberShortcutTabTargets({
|
||||
showSftpTab: false,
|
||||
shellOnlyTabNumberShortcuts: false,
|
||||
orderedTabs: ['session-1'],
|
||||
editorTabIds: [],
|
||||
}),
|
||||
['vault', 'session-1'],
|
||||
);
|
||||
});
|
||||
14
application/app/tabShortcutTargets.ts
Normal file
14
application/app/tabShortcutTargets.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/** Tab ids targeted by Cmd/Ctrl+[1...9] number shortcuts. */
|
||||
export function buildNumberShortcutTabTargets(params: {
|
||||
showSftpTab: boolean;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
orderedTabs: readonly string[];
|
||||
editorTabIds: readonly string[];
|
||||
}): string[] {
|
||||
const workTabs = [...params.orderedTabs, ...params.editorTabIds];
|
||||
if (params.shellOnlyTabNumberShortcuts) {
|
||||
return workTabs;
|
||||
}
|
||||
const pinnedTabs = params.showSftpTab ? ['vault', 'sftp'] : ['vault'];
|
||||
return [...pinnedTabs, ...workTabs];
|
||||
}
|
||||
@@ -468,6 +468,8 @@ export const enCoreMessages: Messages = {
|
||||
'settings.shortcuts.scheme.disabled': 'Disabled',
|
||||
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
|
||||
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Number keys skip pinned tabs',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'When enabled, Cmd/Ctrl+[1...9] switches only work tabs (terminals, workspaces, editors), not the pinned Vault or SFTP tabs.',
|
||||
'settings.shortcuts.section.custom': 'Custom Shortcuts',
|
||||
'settings.shortcuts.resetAll': 'Reset All',
|
||||
'settings.shortcuts.recording': 'Press keys...',
|
||||
|
||||
@@ -468,6 +468,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.shortcuts.scheme.disabled': 'Отключено',
|
||||
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
|
||||
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Цифры без закреплённых вкладок',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'Если включено, Cmd/Ctrl+[1...9] переключает только рабочие вкладки (терминалы, рабочие области, редакторы), а не закреплённые Vault и SFTP.',
|
||||
'settings.shortcuts.section.custom': 'Пользовательские сочетания',
|
||||
'settings.shortcuts.resetAll': 'Сбросить все',
|
||||
'settings.shortcuts.recording': 'Нажмите клавиши...',
|
||||
|
||||
@@ -311,6 +311,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.shortcuts.scheme.disabled': '禁用',
|
||||
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
|
||||
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '数字键跳过固定标签',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '开启后,Cmd/Ctrl+[1...9] 仅在终端、工作区、编辑器等可关闭标签页之间切换,不包括固定的 Vault 和 SFTP 标签页。',
|
||||
'settings.shortcuts.section.custom': '自定义快捷键',
|
||||
'settings.shortcuts.resetAll': '全部重置',
|
||||
'settings.shortcuts.recording': '请按键...',
|
||||
|
||||
@@ -64,6 +64,7 @@ export const DEFAULT_SHOW_RECENT_HOSTS = true;
|
||||
export const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
|
||||
export const DEFAULT_SHOW_SFTP_TAB = true;
|
||||
export const DEFAULT_SHOW_HOST_TREE_SIDEBAR = true;
|
||||
export const DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = false;
|
||||
|
||||
// Editor defaults
|
||||
export const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
@@ -77,6 +78,7 @@ interface UseSettingsStorageSyncParams {
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
editorWordWrap: boolean;
|
||||
sessionLogsEnabled: boolean;
|
||||
sessionLogsDir: string;
|
||||
@@ -112,6 +114,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
|
||||
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsDir: Dispatch<SetStateAction<string>>;
|
||||
@@ -133,7 +136,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
@@ -142,7 +145,7 @@ export function useSettingsStorageSync({
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
@@ -156,7 +159,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
});
|
||||
@@ -166,7 +169,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
};
|
||||
@@ -380,6 +383,12 @@ export function useSettingsStorageSync({
|
||||
setShowHostTreeSidebarState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.shellOnlyTabNumberShortcuts) {
|
||||
setShellOnlyTabNumberShortcutsState(newValue);
|
||||
}
|
||||
}
|
||||
// Sync global hotkey enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
@@ -448,6 +457,7 @@ export function useSettingsStorageSync({
|
||||
setShowHostTreeSidebarState,
|
||||
setShowRecentHostsState,
|
||||
setShowSftpTabState,
|
||||
setShellOnlyTabNumberShortcutsState,
|
||||
setTerminalFontFamilyId,
|
||||
setTerminalFontSize,
|
||||
setTerminalThemeDarkId,
|
||||
|
||||
@@ -43,6 +43,8 @@ function createTerminalSessionClone(
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
fontSize: session.fontSize,
|
||||
fontSizeOverride: session.fontSizeOverride,
|
||||
reuseConnectionFromSessionId: canReuseTerminalConnection(session) ? session.id : undefined,
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ SplitDirection,
|
||||
SplitHint,
|
||||
updateWorkspaceSplitSizes,
|
||||
} from '../../domain/workspace';
|
||||
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
|
||||
import { buildOrderedWorkTabIds } from '../app/workTabSurface';
|
||||
import { activeTabStore } from './activeTabStore';
|
||||
import {
|
||||
@@ -72,6 +73,18 @@ export const useSessionState = () => {
|
||||
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));
|
||||
}, []);
|
||||
|
||||
const updateSessionFontSize = useCallback((sessionId: string, fontSize: number) => {
|
||||
setSessions(prev => prev.map(s => (
|
||||
s.id === sessionId ? { ...s, fontSize, fontSizeOverride: true } : s
|
||||
)));
|
||||
}, []);
|
||||
|
||||
const clearSessionFontSizeOverride = useCallback((sessionId: string) => {
|
||||
setSessions(prev => prev.map(s => (
|
||||
s.id === sessionId ? clearSessionFontSizeOverrideFields(s) : s
|
||||
)));
|
||||
}, []);
|
||||
|
||||
const closeWorkspace = useCallback((workspaceId: string) => {
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
|
||||
@@ -943,6 +956,8 @@ export const useSessionState = () => {
|
||||
closeSession,
|
||||
closeWorkspace,
|
||||
updateSessionStatus,
|
||||
updateSessionFontSize,
|
||||
clearSessionFontSizeOverride,
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromTargets,
|
||||
createWorkspaceFromSessions,
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import {
|
||||
@@ -87,6 +88,7 @@ import {
|
||||
DEFAULT_SHOW_RECENT_HOSTS,
|
||||
DEFAULT_SHOW_SFTP_TAB,
|
||||
DEFAULT_SHOW_HOST_TREE_SIDEBAR,
|
||||
DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
|
||||
DEFAULT_TERMINAL_THEME,
|
||||
DEFAULT_THEME,
|
||||
@@ -238,6 +240,10 @@ export const useSettingsState = () => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
return stored ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR;
|
||||
});
|
||||
const [shellOnlyTabNumberShortcuts, setShellOnlyTabNumberShortcutsState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
return stored ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS;
|
||||
});
|
||||
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
|
||||
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
|
||||
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
|
||||
@@ -536,6 +542,8 @@ export const useSettingsState = () => {
|
||||
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
|
||||
const storedShowHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
setShowHostTreeSidebarState(storedShowHostTreeSidebar ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR);
|
||||
const storedShellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
setShellOnlyTabNumberShortcutsState(storedShellOnlyTabNumberShortcuts ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
|
||||
// Workspace focus style
|
||||
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
@@ -653,7 +661,7 @@ export const useSettingsState = () => {
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
@@ -662,7 +670,7 @@ export const useSettingsState = () => {
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
@@ -776,6 +784,13 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShellOnlyTabNumberShortcuts = useCallback((enabled: boolean) => {
|
||||
setShellOnlyTabNumberShortcutsState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
// Apply and persist custom CSS
|
||||
useEffect(() => {
|
||||
applyCustomCssToDocument(customCSS);
|
||||
@@ -1014,6 +1029,8 @@ export const useSettingsState = () => {
|
||||
setShowSftpTab,
|
||||
showHostTreeSidebar,
|
||||
setShowHostTreeSidebar,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
setShellOnlyTabNumberShortcuts,
|
||||
sftpTransferConcurrency,
|
||||
setSftpTransferConcurrency,
|
||||
// Editor Settings
|
||||
@@ -1058,7 +1075,7 @@ export const useSettingsState = () => {
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
||||
@@ -228,6 +229,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
||||
@@ -405,6 +407,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
|
||||
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
|
||||
const shellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
if (shellOnlyTabNumberShortcuts != null) settings.shellOnlyTabNumberShortcuts = shellOnlyTabNumberShortcuts;
|
||||
const showHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
if (showHostTreeSidebar != null) settings.showHostTreeSidebar = showHostTreeSidebar;
|
||||
const workspaceFocusStyle = localStorageAdapter.readString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
@@ -537,6 +541,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
if (settings.showSftpTab != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
|
||||
}
|
||||
if (settings.shellOnlyTabNumberShortcuts != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, settings.shellOnlyTabNumberShortcuts);
|
||||
}
|
||||
if (settings.showHostTreeSidebar != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, settings.showHostTreeSidebar);
|
||||
}
|
||||
|
||||
@@ -397,6 +397,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
<SettingsShortcutsTab
|
||||
hotkeyScheme={settings.hotkeyScheme}
|
||||
setHotkeyScheme={settings.setHotkeyScheme}
|
||||
shellOnlyTabNumberShortcuts={settings.shellOnlyTabNumberShortcuts}
|
||||
setShellOnlyTabNumberShortcuts={settings.setShellOnlyTabNumberShortcuts}
|
||||
keyBindings={settings.keyBindings}
|
||||
updateKeyBinding={settings.updateKeyBinding}
|
||||
resetKeyBinding={settings.resetKeyBinding}
|
||||
|
||||
@@ -1118,7 +1118,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.cursor} 78%, ${effectiveTheme.colors.background} 22%))`,
|
||||
}), [effectiveTheme.colors.background, effectiveTheme.colors.cursor, effectiveTheme.colors.foreground]);
|
||||
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
|
||||
const effectiveComposeBarOpen = inWorkspace ? !!isWorkspaceComposeBarOpen : isComposeBarOpen;
|
||||
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
|
||||
|
||||
return <TerminalView ctx={{ ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isSupportedOs, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
|
||||
import type { DropEntry } from '../lib/sftpFileUtils';
|
||||
import { Host, KnownHost, TerminalSession, Workspace } from '../types';
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from '../domain/groupConfig';
|
||||
import { applySessionFontSizeToHost } from '../domain/terminalAppearance';
|
||||
import { resolveHostAutofillPassword } from '../domain/sshAuth';
|
||||
import { materializeHostProxyProfile } from '../domain/proxyProfiles';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
@@ -100,6 +101,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onUpdateTerminalFontFamilyId,
|
||||
onUpdateTerminalFontSize,
|
||||
onUpdateTerminalFontWeight,
|
||||
onUpdateSessionFontSize,
|
||||
onClearSessionFontSizeOverride,
|
||||
onCloseSession,
|
||||
onUpdateSessionStatus,
|
||||
onUpdateHostDistro,
|
||||
@@ -479,26 +482,28 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const moshEnabled = session.moshEnabled ?? existingHost.moshEnabled;
|
||||
const etEnabled = session.etEnabled ?? existingHost.etEnabled;
|
||||
|
||||
let hostForSession: Host;
|
||||
if (
|
||||
protocol === existingHost.protocol &&
|
||||
port === existingHost.port &&
|
||||
moshEnabled === existingHost.moshEnabled
|
||||
&& etEnabled === existingHost.etEnabled
|
||||
) {
|
||||
map.set(session.id, existingHost);
|
||||
hostForSession = existingHost;
|
||||
} else {
|
||||
map.set(session.id, {
|
||||
hostForSession = {
|
||||
...existingHost,
|
||||
protocol,
|
||||
port,
|
||||
moshEnabled,
|
||||
etEnabled,
|
||||
});
|
||||
};
|
||||
}
|
||||
map.set(session.id, applySessionFontSizeToHost(hostForSession, session));
|
||||
} else {
|
||||
// Create stable fallback host object
|
||||
const fallbackProtocol = session.protocol ?? 'local' as const;
|
||||
map.set(session.id, {
|
||||
const fallbackHost: Host = {
|
||||
id: session.hostId,
|
||||
label: session.hostLabel || 'Local Terminal',
|
||||
hostname: session.hostname || 'localhost',
|
||||
@@ -523,7 +528,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
});
|
||||
};
|
||||
map.set(session.id, applySessionFontSizeToHost(fallbackHost, session));
|
||||
}
|
||||
}
|
||||
return map;
|
||||
@@ -619,6 +625,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}, [hostMap, sessions, keys, identities]);
|
||||
|
||||
const handleTerminalFontSizeChange = useCallback((sessionId: string, nextFontSize: number) => {
|
||||
const session = sessionsRef.current.find((candidate) => candidate.id === sessionId);
|
||||
// Workspace panes keep per-session font size so zooming one split does not
|
||||
// change global defaults or sibling panes (even when they share a host).
|
||||
if (session?.workspaceId) {
|
||||
onUpdateSessionFontSize?.(sessionId, nextFontSize);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionHost = sessionHostsMapRef.current.get(sessionId);
|
||||
if (!sessionHost) return;
|
||||
|
||||
@@ -630,7 +644,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}
|
||||
|
||||
onUpdateHost({ ...rawHost, fontSize: nextFontSize, fontSizeOverride: true });
|
||||
}, [onUpdateHost, onUpdateTerminalFontSize]);
|
||||
}, [onUpdateHost, onUpdateSessionFontSize, onUpdateTerminalFontSize]);
|
||||
|
||||
const validAIScopeTargetIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
@@ -1083,6 +1097,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onUpdateTerminalFontFamilyId,
|
||||
onUpdateTerminalFontSize,
|
||||
onUpdateTerminalFontWeight,
|
||||
onUpdateSessionFontSize,
|
||||
onClearSessionFontSizeOverride,
|
||||
onUpdateTerminalThemeId,
|
||||
pendingTerminalSelectionForAI,
|
||||
refocusActiveTerminalSession,
|
||||
|
||||
@@ -5,11 +5,13 @@ import { keyEventToString } from "../../../domain/models";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { Button } from "../../ui/button";
|
||||
import { SectionHeader, Select, SettingsTabContent, SettingRow } from "../settings-ui";
|
||||
import { SectionHeader, Select, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
|
||||
|
||||
export default function SettingsShortcutsTab(props: {
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
setHotkeyScheme: (scheme: HotkeyScheme) => void;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
setShellOnlyTabNumberShortcuts: (enabled: boolean) => void;
|
||||
keyBindings: KeyBinding[];
|
||||
updateKeyBinding?: (bindingId: string, scheme: "mac" | "pc", newKey: string) => void;
|
||||
resetKeyBinding?: (bindingId: string, scheme?: "mac" | "pc") => void;
|
||||
@@ -19,6 +21,8 @@ export default function SettingsShortcutsTab(props: {
|
||||
const {
|
||||
hotkeyScheme,
|
||||
setHotkeyScheme,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
setShellOnlyTabNumberShortcuts,
|
||||
keyBindings,
|
||||
updateKeyBinding,
|
||||
resetKeyBinding,
|
||||
@@ -136,6 +140,15 @@ export default function SettingsShortcutsTab(props: {
|
||||
className="w-32"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.shortcuts.shellOnlyTabNumberShortcuts.label")}
|
||||
description={t("settings.shortcuts.shellOnlyTabNumberShortcuts.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={shellOnlyTabNumberShortcuts}
|
||||
onChange={setShellOnlyTabNumberShortcuts}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
{hotkeyScheme !== "disabled" && (
|
||||
|
||||
@@ -53,7 +53,7 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]",
|
||||
"relative h-full w-full flex min-h-0 overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]",
|
||||
isComposeBarOpen && !inWorkspace && "flex-col"
|
||||
)}
|
||||
style={terminalPreviewVars}
|
||||
@@ -200,7 +200,7 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-full flex-1 min-w-0 relative overflow-hidden pt-8"
|
||||
className="flex-1 min-h-0 min-w-0 relative overflow-hidden pt-8"
|
||||
style={{ backgroundColor: 'var(--terminal-ui-bg)' }}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -254,3 +254,38 @@ test("startEt connects directly when no jump host is configured", async () => {
|
||||
assert.ok(captured);
|
||||
assert.equal(captured.jumpHosts, undefined);
|
||||
});
|
||||
|
||||
test("startEt forwards known hosts and algorithm options for stats companion parity", async () => {
|
||||
let captured: Record<string, unknown> | null = null;
|
||||
const knownHosts = [{
|
||||
id: "kh-1",
|
||||
hostname: "target.example.test",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
fingerprint: "SHA256:trusted",
|
||||
publicKey: "",
|
||||
discoveredAt: 1,
|
||||
}];
|
||||
const algorithms = { cipher: ["aes128-cbc"] };
|
||||
const backend = makeBackend((options) => { captured = options; });
|
||||
const ctx = {
|
||||
...makeCtx(
|
||||
{
|
||||
legacyAlgorithms: true,
|
||||
skipEcdsaHostKey: true,
|
||||
algorithms,
|
||||
},
|
||||
[],
|
||||
backend,
|
||||
),
|
||||
knownHosts,
|
||||
};
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startEt(term as never);
|
||||
|
||||
assert.ok(captured);
|
||||
assert.equal(captured.knownHosts, knownHosts);
|
||||
assert.equal(captured.legacyAlgorithms, true);
|
||||
assert.equal(captured.skipEcdsaHostKey, true);
|
||||
assert.equal(captured.algorithmOverrides, algorithms);
|
||||
});
|
||||
|
||||
@@ -997,6 +997,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
port: ctx.host.port || 22,
|
||||
etPort: ctx.host.etPort,
|
||||
legacyAlgorithms: ctx.host.legacyAlgorithms,
|
||||
skipEcdsaHostKey: ctx.host.skipEcdsaHostKey,
|
||||
algorithmOverrides: ctx.host.algorithms,
|
||||
knownHosts: ctx.knownHosts,
|
||||
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
agentForwarding: ctx.host.agentForwarding,
|
||||
cols: term.cols,
|
||||
|
||||
@@ -48,7 +48,7 @@ export function resolveSelectionOverlayPosition(term: any, container: HTMLElemen
|
||||
}
|
||||
|
||||
export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, onSnippetShortkeyRef, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef } = ctx;
|
||||
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, onSnippetShortkeyRef, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef } = ctx;
|
||||
|
||||
// Remember the last layout we successfully refit while visible so revisiting
|
||||
// the same workspace tab does not replay expensive force-fit/WebGL recovery.
|
||||
@@ -436,69 +436,81 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
}, [effectiveTheme]);
|
||||
|
||||
|
||||
// Keep font-size sync separate from terminalSettings so unrelated setting
|
||||
// updates (or focus/layout re-renders) do not reset a wheel/Ctrl zoom that
|
||||
// has not yet propagated into React props.
|
||||
useEffect(() => {
|
||||
if (termRef.current) {
|
||||
termRef.current.options.fontSize = effectiveFontSize;
|
||||
termRef.current.options.fontFamily = resolvedFontFamily;
|
||||
if (!termRef.current) return;
|
||||
termRef.current.options.fontSize = effectiveFontSize;
|
||||
xtermRuntimeRef.current?.clearTextureAtlas();
|
||||
if (isVisibleRef.current) {
|
||||
setTimeout(() => safeFit({ force: true, requireVisible: true }), 50);
|
||||
} else {
|
||||
lastFittedSizeRef.current = null;
|
||||
}
|
||||
}, [effectiveFontSize]);
|
||||
|
||||
if (terminalSettings) {
|
||||
applyUserCursorPreference(termRef.current, terminalSettings);
|
||||
termRef.current.options.scrollback = terminalSettings.scrollback === 0 ? 999999 : terminalSettings.scrollback;
|
||||
termRef.current.options.fontWeight = effectiveFontWeight as
|
||||
| 100
|
||||
| 200
|
||||
| 300
|
||||
| 400
|
||||
| 500
|
||||
| 600
|
||||
| 700
|
||||
| 800
|
||||
| 900;
|
||||
const resolvedFontWeightBold = resolveFontWeightBold({
|
||||
fontFamilyCss: termRef.current?.options.fontFamily || "",
|
||||
normalWeight: effectiveFontWeight,
|
||||
desiredBoldWeight: terminalSettings.fontWeightBold,
|
||||
fontSize: effectiveFontSize,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!termRef.current) return;
|
||||
termRef.current.options.fontFamily = resolvedFontFamily;
|
||||
|
||||
termRef.current.options.fontWeightBold = resolvedFontWeightBold as
|
||||
| 100
|
||||
| 200
|
||||
| 300
|
||||
| 400
|
||||
| 500
|
||||
| 600
|
||||
| 700
|
||||
| 800
|
||||
| 900;
|
||||
termRef.current.options.lineHeight = 1 + terminalSettings.linePadding / 10;
|
||||
termRef.current.options.drawBoldTextInBrightColors =
|
||||
terminalSettings.drawBoldInBrightColors;
|
||||
termRef.current.options.minimumContrastRatio =
|
||||
terminalSettings.minimumContrastRatio;
|
||||
termRef.current.options.smoothScrollDuration =
|
||||
terminalSettings.smoothScrolling
|
||||
? XTERM_PERFORMANCE_CONFIG.rendering.smoothScrollDuration
|
||||
: 0;
|
||||
termRef.current.options.scrollOnUserInput =
|
||||
shouldEnableNativeUserInputAutoScroll(terminalSettings);
|
||||
const altKeyOpts = terminalAltKeyOptions(terminalSettings.altAsMeta);
|
||||
termRef.current.options.macOptionIsMeta = altKeyOpts.macOptionIsMeta;
|
||||
termRef.current.options.altClickMovesCursor = altKeyOpts.altClickMovesCursor;
|
||||
termRef.current.options.wordSeparator = terminalSettings.wordSeparators;
|
||||
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
|
||||
}
|
||||
if (terminalSettings) {
|
||||
applyUserCursorPreference(termRef.current, terminalSettings);
|
||||
termRef.current.options.scrollback = terminalSettings.scrollback === 0 ? 999999 : terminalSettings.scrollback;
|
||||
termRef.current.options.fontWeight = effectiveFontWeight as
|
||||
| 100
|
||||
| 200
|
||||
| 300
|
||||
| 400
|
||||
| 500
|
||||
| 600
|
||||
| 700
|
||||
| 800
|
||||
| 900;
|
||||
const resolvedFontWeightBold = resolveFontWeightBold({
|
||||
fontFamilyCss: termRef.current?.options.fontFamily || "",
|
||||
normalWeight: effectiveFontWeight,
|
||||
desiredBoldWeight: terminalSettings.fontWeightBold,
|
||||
fontSize: effectiveFontSize,
|
||||
});
|
||||
|
||||
// Changing the font can leave the WebGL renderer drawing stale glyphs from
|
||||
// the old metrics (xterm.js #3280), surfacing as garbled text (issue #1049).
|
||||
// Clear the texture atlas so glyphs re-rasterize with the new font.
|
||||
xtermRuntimeRef.current?.clearTextureAtlas();
|
||||
termRef.current.options.fontWeightBold = resolvedFontWeightBold as
|
||||
| 100
|
||||
| 200
|
||||
| 300
|
||||
| 400
|
||||
| 500
|
||||
| 600
|
||||
| 700
|
||||
| 800
|
||||
| 900;
|
||||
termRef.current.options.lineHeight = 1 + terminalSettings.linePadding / 10;
|
||||
termRef.current.options.drawBoldTextInBrightColors =
|
||||
terminalSettings.drawBoldInBrightColors;
|
||||
termRef.current.options.minimumContrastRatio =
|
||||
terminalSettings.minimumContrastRatio;
|
||||
termRef.current.options.smoothScrollDuration =
|
||||
terminalSettings.smoothScrolling
|
||||
? XTERM_PERFORMANCE_CONFIG.rendering.smoothScrollDuration
|
||||
: 0;
|
||||
termRef.current.options.scrollOnUserInput =
|
||||
shouldEnableNativeUserInputAutoScroll(terminalSettings);
|
||||
const altKeyOpts = terminalAltKeyOptions(terminalSettings.altAsMeta);
|
||||
termRef.current.options.macOptionIsMeta = altKeyOpts.macOptionIsMeta;
|
||||
termRef.current.options.altClickMovesCursor = altKeyOpts.altClickMovesCursor;
|
||||
termRef.current.options.wordSeparator = terminalSettings.wordSeparators;
|
||||
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
|
||||
}
|
||||
|
||||
if (isVisibleRef.current) {
|
||||
setTimeout(() => safeFit({ force: true, requireVisible: true }), 50);
|
||||
} else {
|
||||
lastFittedSizeRef.current = null;
|
||||
}
|
||||
// Changing the font can leave the WebGL renderer drawing stale glyphs from
|
||||
// the old metrics (xterm.js #3280), surfacing as garbled text (issue #1049).
|
||||
// Clear the texture atlas so glyphs re-rasterize with the new font.
|
||||
xtermRuntimeRef.current?.clearTextureAtlas();
|
||||
|
||||
if (isVisibleRef.current) {
|
||||
setTimeout(() => safeFit({ force: true, requireVisible: true }), 50);
|
||||
} else {
|
||||
lastFittedSizeRef.current = null;
|
||||
}
|
||||
}, [effectiveFontSize, effectiveFontWeight, resolvedFontFamily, terminalSettings]);
|
||||
|
||||
@@ -640,6 +652,16 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
await fontFaceSet.ready;
|
||||
if (cancelled) return;
|
||||
|
||||
// Ensure bundled Nerd Font icon fallbacks are loaded at the terminal's
|
||||
// cell size. Shell prompts can arrive before these faces finish loading
|
||||
// on cold start (Linux), leaving Powerline glyphs cached as tofu (#1363).
|
||||
try {
|
||||
await fontFaceSet.load(`${effectiveFontSize}px "Symbols Nerd Font Mono"`);
|
||||
} catch (err) {
|
||||
logger.warn("Nerd Font preload failed", err);
|
||||
}
|
||||
if (cancelled) return;
|
||||
|
||||
const term = termRef.current as {
|
||||
cols: number;
|
||||
rows: number;
|
||||
@@ -652,6 +674,13 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
logger.warn("Font remeasure failed", err);
|
||||
}
|
||||
|
||||
// remeasureFont does not invalidate cells rasterized before fonts were ready.
|
||||
xtermRuntimeRef.current?.clearTextureAtlas();
|
||||
const visibleTerm = termRef.current;
|
||||
if (visibleTerm) {
|
||||
forceSyncRenderAfterResize(visibleTerm);
|
||||
}
|
||||
|
||||
try {
|
||||
fitAddon?.fit();
|
||||
} catch (err) {
|
||||
@@ -776,6 +805,27 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
return () => clearTimeout(timer);
|
||||
}, [isSearchOpen]);
|
||||
|
||||
// When compose 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(() => {
|
||||
safeFit({ force: true, requireVisible: true });
|
||||
if (wasAtBottom) {
|
||||
term.scrollToBottom();
|
||||
} else {
|
||||
term.scrollToLine(prevViewportY);
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isComposeBarOpen]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const shouldAutoFocus = isVisible && termRef.current && (!inWorkspace || isFocusMode);
|
||||
|
||||
@@ -479,6 +479,8 @@ export interface TerminalLayerProps {
|
||||
onUpdateTerminalFontFamilyId?: (fontFamilyId: string) => void;
|
||||
onUpdateTerminalFontSize?: (fontSize: number) => void;
|
||||
onUpdateTerminalFontWeight?: (fontWeight: number) => void;
|
||||
onUpdateSessionFontSize?: (sessionId: string, fontSize: number) => void;
|
||||
onClearSessionFontSizeOverride?: (sessionId: string) => void;
|
||||
onCloseSession: (sessionId: string, e?: React.MouseEvent) => void;
|
||||
onUpdateSessionStatus: (sessionId: string, status: TerminalSession['status']) => void;
|
||||
onUpdateHostDistro: (hostId: string, distro: string) => void;
|
||||
|
||||
@@ -142,6 +142,8 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
onUpdateHost: s.onUpdateHost,
|
||||
onUpdateTerminalFontFamilyId: s.onUpdateTerminalFontFamilyId,
|
||||
onUpdateTerminalFontSize: s.onUpdateTerminalFontSize,
|
||||
onUpdateSessionFontSize: s.onUpdateSessionFontSize,
|
||||
onClearSessionFontSizeOverride: s.onClearSessionFontSizeOverride,
|
||||
onUpdateTerminalFontWeight: s.onUpdateTerminalFontWeight,
|
||||
onUpdateTerminalThemeId: s.onUpdateTerminalThemeId,
|
||||
sessionHostsMap,
|
||||
@@ -187,6 +189,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
handleToggleScriptsSidePanel: s.handleToggleScriptsSidePanel,
|
||||
handleToggleSidePanel: s.handleToggleSidePanel,
|
||||
hasNotifiableTerminalOutput: s.hasNotifiableTerminalOutput,
|
||||
isComposeBarOpen: s.isComposeBarOpen,
|
||||
isFocusMode,
|
||||
isTerminalLayerVisible,
|
||||
lastSidePanelTabRef: s.lastSidePanelTabRef,
|
||||
@@ -216,6 +219,8 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
setTimeout,
|
||||
setupMcpApprovalBridge: s.setupMcpApprovalBridge,
|
||||
setWorkspaceArea,
|
||||
sidePanelPosition: s.sidePanelPosition,
|
||||
sidePanelWidth: s.sidePanelWidth,
|
||||
sftpActiveHost,
|
||||
sftpHostForTab,
|
||||
shouldMarkSessionActivity: s.shouldMarkSessionActivity,
|
||||
|
||||
@@ -11,53 +11,22 @@ type TerminalLayerViewContext = Record<string, any>;
|
||||
|
||||
function TerminalLayerViewInner({ ctx }: { ctx: TerminalLayerViewContext }) {
|
||||
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
|
||||
const {
|
||||
activeWorkspace,
|
||||
composeBarThemeColors,
|
||||
focusedSessionId,
|
||||
handleComposeSend,
|
||||
handleSnippetFromPanel,
|
||||
isBroadcastEnabled,
|
||||
isComposeBarOpen,
|
||||
isTerminalLayerVisible,
|
||||
refocusTerminalSession,
|
||||
setIsComposeBarOpen,
|
||||
snippets,
|
||||
TerminalComposeBar,
|
||||
workspaceOuterRef,
|
||||
} = ctx;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={workspaceOuterRef}
|
||||
className="absolute inset-0 bg-background flex flex-col"
|
||||
ref={ctx.workspaceOuterRef}
|
||||
className="absolute inset-0 bg-background flex min-h-0"
|
||||
data-section="terminal-workspace"
|
||||
style={{
|
||||
visibility: isTerminalLayerVisible ? 'visible' : 'hidden',
|
||||
pointerEvents: isTerminalLayerVisible ? 'auto' : 'none',
|
||||
zIndex: isTerminalLayerVisible ? 10 : 0,
|
||||
visibility: ctx.isTerminalLayerVisible ? 'visible' : 'hidden',
|
||||
pointerEvents: ctx.isTerminalLayerVisible ? 'auto' : 'none',
|
||||
zIndex: ctx.isTerminalLayerVisible ? 10 : 0,
|
||||
left: hostTreeLayoutWidth,
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 flex min-h-0 relative">
|
||||
<TerminalLayerSidePanelSection ctx={ctx} />
|
||||
<TerminalLayerFocusSidebarSection ctx={ctx} />
|
||||
<TerminalLayerWorkspaceSection ctx={ctx} />
|
||||
</div>
|
||||
|
||||
{activeWorkspace && isComposeBarOpen && (
|
||||
<TerminalComposeBar
|
||||
onSend={handleComposeSend}
|
||||
onSnippetClick={(snippet) => void handleSnippetFromPanel(snippet)}
|
||||
snippets={snippets}
|
||||
onClose={() => {
|
||||
setIsComposeBarOpen(false);
|
||||
refocusTerminalSession(focusedSessionId);
|
||||
}}
|
||||
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
|
||||
themeColors={composeBarThemeColors}
|
||||
/>
|
||||
)}
|
||||
<TerminalLayerSidePanelSection ctx={ctx} />
|
||||
<TerminalLayerFocusSidebarSection ctx={ctx} />
|
||||
<TerminalLayerWorkspaceSection ctx={ctx} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,14 +69,22 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
handleAddSelectionToAI,
|
||||
activeResizers,
|
||||
activeWorkspace,
|
||||
composeBarThemeColors,
|
||||
findSplitNode,
|
||||
focusedSessionId,
|
||||
handleComposeSend,
|
||||
handleSnippetFromPanel,
|
||||
refocusTerminalSession,
|
||||
setIsComposeBarOpen,
|
||||
setResizing,
|
||||
TerminalComposeBar,
|
||||
Array,
|
||||
cn,
|
||||
} = ctx;
|
||||
|
||||
return (
|
||||
<div ref={workspaceInnerRef} className="overflow-hidden relative flex-1">
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div ref={workspaceInnerRef} className="flex-1 min-h-0 overflow-hidden relative">
|
||||
{draggingSessionId && !isFocusMode && (
|
||||
<div
|
||||
ref={workspaceOverlayRef}
|
||||
@@ -219,6 +227,21 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activeWorkspace && isComposeBarOpen && (
|
||||
<TerminalComposeBar
|
||||
onSend={handleComposeSend}
|
||||
onSnippetClick={(snippet) => void handleSnippetFromPanel(snippet)}
|
||||
snippets={snippets}
|
||||
onClose={() => {
|
||||
setIsComposeBarOpen(false);
|
||||
refocusTerminalSession(focusedSessionId);
|
||||
}}
|
||||
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
|
||||
themeColors={composeBarThemeColors}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -255,7 +255,14 @@ const WORKSPACE_CTX_KEYS = [
|
||||
'handleAddSelectionToAI',
|
||||
'activeResizers',
|
||||
'activeWorkspace',
|
||||
'composeBarThemeColors',
|
||||
'findSplitNode',
|
||||
'focusedSessionId',
|
||||
'handleComposeSend',
|
||||
'handleSnippetFromPanel',
|
||||
'refocusTerminalSession',
|
||||
'setIsComposeBarOpen',
|
||||
'TerminalComposeBar',
|
||||
'setResizing',
|
||||
'Array',
|
||||
'cn',
|
||||
|
||||
@@ -1,12 +1,47 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
import { terminalLayoutSuppressStore } from '../../application/state/terminalLayoutSuppressStore';
|
||||
import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics';
|
||||
import { getTerminalSidePanelShellWidth } from './TerminalLayerSidePanelSection';
|
||||
|
||||
type TerminalLayerEffectsContext = Record<string, any>;
|
||||
|
||||
export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
const { activeSidePanelTab, activeTabId, activeTabIdRef, activeTopTabsThemeId, activeWorkspace, activityTrackedSessions, appliedPreviewSessionRef, applyTerminalPreviewVars, applyTopTabsPreviewVars, cancelAnimationFrame, ChunkedEscapeFilter, clearTerminalPreviewVars, clearTimeout, clearTopTabsPreviewVars, document, dropHint, filterTabsMap, focusedSessionId, followAppTerminalTheme, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, prevFocusedSessionIdRef, previewTargetSessionId, refocusActiveTerminalSession, requestAnimationFrame, ResizeObserver, sessionActivityStore, sessions, Set, setAiMountedTabIds, setDropHint, setScriptsMountedTabIds, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setThemeMountedTabIds, setThemePreview, setTimeout, setupMcpApprovalBridge, setWorkspaceArea, sftpActiveHost, sftpHostForTab, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, terminalRendererCwdBySessionRef, themeCommitTimerRef, themePreview, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, visibleFocusedThemeId, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces } = ctx;
|
||||
const { activeSidePanelTab, activeTabId, activeTabIdRef, activeTopTabsThemeId, activeWorkspace, activityTrackedSessions, appliedPreviewSessionRef, applyTerminalPreviewVars, applyTopTabsPreviewVars, cancelAnimationFrame, ChunkedEscapeFilter, clearTerminalPreviewVars, clearTimeout, clearTopTabsPreviewVars, document, dropHint, filterTabsMap, focusedSessionId, followAppTerminalTheme, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isComposeBarOpen, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, prevFocusedSessionIdRef, previewTargetSessionId, refocusActiveTerminalSession, requestAnimationFrame, ResizeObserver, sessionActivityStore, sessions, Set, setAiMountedTabIds, setDropHint, setScriptsMountedTabIds, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setThemeMountedTabIds, setThemePreview, setTimeout, setupMcpApprovalBridge, setWorkspaceArea, sidePanelPosition, sidePanelWidth, sftpActiveHost, sftpHostForTab, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, terminalRendererCwdBySessionRef, themeCommitTimerRef, themePreview, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, visibleFocusedThemeId, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces } = ctx;
|
||||
|
||||
const activeWorkspaceId = activeWorkspace?.id;
|
||||
const activeWorkspaceViewMode = activeWorkspace?.viewMode;
|
||||
|
||||
const isSidePanelOpenForCurrentTab = activeTabId ? sidePanelOpenTabs.has(activeTabId) : false;
|
||||
const sidePanelShellWidth = getTerminalSidePanelShellWidth({
|
||||
activeSidePanelTab,
|
||||
forceHideAiShell: AI_PANEL_FORCE_HIDE_SHELL,
|
||||
isSidePanelOpenForCurrentTab,
|
||||
resizePreviewWidth: null,
|
||||
sidePanelWidth,
|
||||
});
|
||||
|
||||
const remeasureWorkspaceArea = useCallback(() => {
|
||||
const el = workspaceInnerRef.current;
|
||||
if (!el) return;
|
||||
const width = el.clientWidth;
|
||||
const height = el.clientHeight;
|
||||
if (width <= 0 || height <= 0) return;
|
||||
setWorkspaceArea((prev) => (
|
||||
prev.width === width && prev.height === height
|
||||
? prev
|
||||
: { width, height }
|
||||
));
|
||||
}, [setWorkspaceArea, workspaceInnerRef]);
|
||||
|
||||
const scheduleWorkspaceAreaRemeasure = useCallback(() => {
|
||||
remeasureWorkspaceArea();
|
||||
requestAnimationFrame(() => {
|
||||
remeasureWorkspaceArea();
|
||||
requestAnimationFrame(remeasureWorkspaceArea);
|
||||
});
|
||||
}, [remeasureWorkspaceArea, requestAnimationFrame]);
|
||||
|
||||
useEffect(() => {
|
||||
const liveSessionIds = new Set(sessions.map((session) => session.id));
|
||||
@@ -94,16 +129,10 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
if (!workspaceInnerRef.current) return;
|
||||
const el = workspaceInnerRef.current;
|
||||
const updateSize = () => {
|
||||
const width = el.clientWidth;
|
||||
const height = el.clientHeight;
|
||||
// Ignore zero-size reads while the layer is hidden so split rects are
|
||||
// not recomputed from a 1×1 fallback until the real layout is available.
|
||||
if (width <= 0 || height <= 0) return;
|
||||
setWorkspaceArea((prev) => (
|
||||
prev.width === width && prev.height === height
|
||||
? prev
|
||||
: { width, height }
|
||||
));
|
||||
if (!isTerminalLayerVisible) return;
|
||||
remeasureWorkspaceArea();
|
||||
};
|
||||
updateSize();
|
||||
const observer = new ResizeObserver(() => updateSize());
|
||||
@@ -111,31 +140,30 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
// Re-measure when a drag ends so pane rects match the committed layout.
|
||||
const unsubscribeSuppress = terminalLayoutSuppressStore.subscribe(() => {
|
||||
if (!terminalLayoutSuppressStore.getActive()) {
|
||||
updateSize();
|
||||
scheduleWorkspaceAreaRemeasure();
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
unsubscribeSuppress();
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [isTerminalLayerVisible]);
|
||||
}, [isTerminalLayerVisible, remeasureWorkspaceArea, scheduleWorkspaceAreaRemeasure, workspaceInnerRef]);
|
||||
|
||||
// Discrete layout changes (side panel toggle, compose bar, workspace tab/view mode)
|
||||
// can miss a ResizeObserver tick; host-tree width is handled by the observer
|
||||
// because it updates continuously during drag.
|
||||
useEffect(() => {
|
||||
if (!isTerminalLayerVisible || !workspaceInnerRef.current) return;
|
||||
const el = workspaceInnerRef.current;
|
||||
const updateSize = () => {
|
||||
const width = el.clientWidth;
|
||||
const height = el.clientHeight;
|
||||
if (width <= 0 || height <= 0) return;
|
||||
setWorkspaceArea((prev) => (
|
||||
prev.width === width && prev.height === height
|
||||
? prev
|
||||
: { width, height }
|
||||
));
|
||||
};
|
||||
updateSize();
|
||||
requestAnimationFrame(updateSize);
|
||||
}, [activeWorkspace, isTerminalLayerVisible]);
|
||||
if (!isTerminalLayerVisible) return;
|
||||
scheduleWorkspaceAreaRemeasure();
|
||||
}, [
|
||||
activeWorkspaceId,
|
||||
activeWorkspaceViewMode,
|
||||
isComposeBarOpen,
|
||||
isTerminalLayerVisible,
|
||||
scheduleWorkspaceAreaRemeasure,
|
||||
sidePanelPosition,
|
||||
sidePanelShellWidth,
|
||||
]);
|
||||
|
||||
// Keep sftpHostForTab in sync with focus changes in workspace mode
|
||||
// so that the toggle check uses the currently displayed host.
|
||||
|
||||
@@ -41,6 +41,8 @@ interface UseTerminalThemePanelStateOptions {
|
||||
onUpdateTerminalFontSize?: (fontSize: number) => void;
|
||||
onUpdateTerminalFontWeight?: (fontWeight: number) => void;
|
||||
onUpdateTerminalThemeId?: (themeId: string) => void;
|
||||
onUpdateSessionFontSize?: (sessionId: string, fontSize: number) => void;
|
||||
onClearSessionFontSizeOverride?: (sessionId: string) => void;
|
||||
sessionHostsMap: Map<string, Host>;
|
||||
terminalFontFamilyId: string;
|
||||
terminalSettings?: { fontWeight?: number };
|
||||
@@ -63,6 +65,8 @@ export function useTerminalThemePanelState({
|
||||
onUpdateTerminalFontSize,
|
||||
onUpdateTerminalFontWeight,
|
||||
onUpdateTerminalThemeId,
|
||||
onUpdateSessionFontSize,
|
||||
onClearSessionFontSizeOverride,
|
||||
sessionHostsMap,
|
||||
terminalFontFamilyId,
|
||||
terminalSettings,
|
||||
@@ -252,6 +256,10 @@ export function useTerminalThemePanelState({
|
||||
const handleFontSizeChangeForFocusedSession = useCallback((newFontSize: number) => {
|
||||
if (!focusedHost || newFontSize === focusedFontSize) return;
|
||||
startTransition(() => {
|
||||
if (activeWorkspace && focusedSessionId) {
|
||||
onUpdateSessionFontSize?.(focusedSessionId, newFontSize);
|
||||
return;
|
||||
}
|
||||
if (isFocusedHostEphemeral) {
|
||||
onUpdateTerminalFontSize?.(newFontSize);
|
||||
return;
|
||||
@@ -260,12 +268,17 @@ export function useTerminalThemePanelState({
|
||||
onUpdateHost({ ...rawFocusedHost, fontSize: newFontSize, fontSizeOverride: true });
|
||||
}
|
||||
});
|
||||
}, [focusedHost, focusedFontSize, isFocusedHostEphemeral, onUpdateTerminalFontSize, onUpdateHost, rawFocusedHost]);
|
||||
}, [activeWorkspace, focusedHost, focusedFontSize, focusedSessionId, isFocusedHostEphemeral, onUpdateSessionFontSize, onUpdateTerminalFontSize, onUpdateHost, rawFocusedHost]);
|
||||
|
||||
const handleFontSizeResetForFocusedSession = useCallback(() => {
|
||||
if (!focusedHost || isFocusedHostEphemeral || !rawFocusedHost) return;
|
||||
if (!focusedHost) return;
|
||||
if (activeWorkspace && focusedSessionId) {
|
||||
onClearSessionFontSizeOverride?.(focusedSessionId);
|
||||
return;
|
||||
}
|
||||
if (isFocusedHostEphemeral || !rawFocusedHost) return;
|
||||
onUpdateHost(clearHostFontSizeOverride(rawFocusedHost));
|
||||
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost, rawFocusedHost]);
|
||||
}, [activeWorkspace, focusedHost, focusedSessionId, isFocusedHostEphemeral, onClearSessionFontSizeOverride, onUpdateHost, rawFocusedHost]);
|
||||
|
||||
const handleFontWeightChangeForFocusedSession = useCallback((newFontWeight: number) => {
|
||||
if (!focusedHost || newFontWeight === focusedFontWeight) return;
|
||||
|
||||
@@ -347,4 +347,7 @@ export interface TerminalSession {
|
||||
// falls back to a fresh connection — so this also applies on reconnect: a
|
||||
// reconnect reuses the source again if still connected, else dials fresh.
|
||||
reuseConnectionFromSessionId?: string;
|
||||
// Per-pane font size override (workspace splits only; not persisted to vault hosts).
|
||||
fontSize?: number;
|
||||
fontSizeOverride?: boolean;
|
||||
}
|
||||
|
||||
@@ -249,6 +249,8 @@ export interface SyncPayload {
|
||||
showOnlyUngroupedHostsInRoot?: boolean;
|
||||
// Top tabs: show standalone SFTP view tab
|
||||
showSftpTab?: boolean;
|
||||
// Shortcuts: Cmd/Ctrl+[1...9] skip pinned Vault/SFTP tabs
|
||||
shellOnlyTabNumberShortcuts?: boolean;
|
||||
// Terminal/editor tabs: show left host list sidebar
|
||||
showHostTreeSidebar?: boolean;
|
||||
// Workspace focus indicator style
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
applySessionFontSizeToHost,
|
||||
mergeTerminalHostUpdate,
|
||||
resolveFollowedTerminalThemeId,
|
||||
TERMINAL_THEME_AUTO,
|
||||
@@ -128,6 +129,34 @@ test("terminal updates still persist SFTP bookmarks", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("applySessionFontSizeToHost overlays workspace pane font size", () => {
|
||||
const host: Host = {
|
||||
id: "host-1",
|
||||
label: "Server",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
port: 22,
|
||||
group: "",
|
||||
tags: [],
|
||||
};
|
||||
const session = {
|
||||
id: "session-1",
|
||||
hostId: "host-1",
|
||||
hostLabel: "Server",
|
||||
username: "root",
|
||||
hostname: "example.com",
|
||||
status: "connected" as const,
|
||||
workspaceId: "workspace-1",
|
||||
fontSize: 18,
|
||||
fontSizeOverride: true,
|
||||
};
|
||||
|
||||
const merged = applySessionFontSizeToHost(host, session);
|
||||
|
||||
assert.equal(merged.fontSize, 18);
|
||||
assert.equal(merged.fontSizeOverride, true);
|
||||
});
|
||||
|
||||
test("terminal appearance reset clears only appearance fields", () => {
|
||||
const hostWithAppearance: Host = {
|
||||
...savedHost,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Host, TerminalTheme } from './models';
|
||||
import { Host, TerminalSession, TerminalTheme } from './models';
|
||||
|
||||
const hasLegacyStringValue = (value: string | undefined): boolean =>
|
||||
typeof value === 'string' && value.trim().length > 0;
|
||||
@@ -216,6 +216,23 @@ 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 hasSessionFontSizeOverride = (
|
||||
session?: Pick<TerminalSession, 'fontSizeOverride' | 'fontSize'> | null,
|
||||
): boolean => hasHostFontSizeOverride(session);
|
||||
|
||||
export const applySessionFontSizeToHost = (host: Host, session?: TerminalSession): Host => {
|
||||
if (!session || !hasSessionFontSizeOverride(session) || session.fontSize == null) {
|
||||
return host;
|
||||
}
|
||||
return { ...host, fontSize: session.fontSize, fontSizeOverride: true };
|
||||
};
|
||||
|
||||
export const clearSessionFontSizeOverride = (session: TerminalSession): TerminalSession => ({
|
||||
...session,
|
||||
fontSize: undefined,
|
||||
fontSizeOverride: false,
|
||||
});
|
||||
|
||||
export const hasHostFontWeightOverride = (host?: Pick<Host, 'fontWeightOverride' | 'fontWeight'> | null): boolean =>
|
||||
hasEffectiveOverride(host?.fontWeightOverride, hasLegacyNumberValue(host?.fontWeight));
|
||||
|
||||
|
||||
@@ -990,7 +990,7 @@ const { isHostKeyTrustedBySystem } = createSystemKnownHostsApi({
|
||||
});
|
||||
|
||||
const { createMoshStatsConnectionApi } = require("./sshBridge/moshStatsConnection.cjs");
|
||||
const { ensureMoshStatsConnection } = createMoshStatsConnectionApi({
|
||||
const { ensureMoshStatsConnection, ensureEtStatsConnection } = createMoshStatsConnectionApi({
|
||||
get sessions() { return sessions; },
|
||||
SSHClient, sshUtils, NetcattyAgent, buildAlgorithms, getSshAgentSocket,
|
||||
readFileNoFollow, expandIdentityFilePath, isAutoFillablePasswordChallenge,
|
||||
@@ -1003,7 +1003,7 @@ const sessionOpsApi = createSessionOpsApi({
|
||||
get electronModule() { return electronModule; },
|
||||
fs, path, os, exec, randomUUID, iconv, Buffer, process, console, setTimeout, clearTimeout,
|
||||
getSessionDecoder, resetSessionDecoders, sessionEncodings, resolveLangFromCharset, safeSend,
|
||||
quoteShellArg, log, ensureMoshStatsConnection,
|
||||
quoteShellArg, log, ensureMoshStatsConnection, ensureEtStatsConnection,
|
||||
execOnEtSession: (...args) => require("./terminalBridge.cjs").execOnEtSession(...args),
|
||||
getServerStats: undefined,
|
||||
});
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
|
||||
const { createSessionOpsApi } = require("./sshBridge/sessionOps.cjs");
|
||||
|
||||
function makeApi(session, execOnEtSession) {
|
||||
function fakeStream(stdout) {
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
setImmediate(() => {
|
||||
if (stdout) stream.emit("data", Buffer.from(stdout));
|
||||
stream.emit("close", 0);
|
||||
});
|
||||
return stream;
|
||||
}
|
||||
|
||||
function fakeConn(stdout) {
|
||||
return {
|
||||
exec(_command, cb) {
|
||||
cb(null, fakeStream(stdout));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const LINUX_STATS =
|
||||
"CPURAW:1000 900|CORES:4|PERCORERAW:|MEMINFO:8000 4000 100 900 0 0|PROCS:|DISKS:|NET:";
|
||||
|
||||
function makeApi(session, execOnEtSession, extra = {}) {
|
||||
const sessions = new Map([["et-1", session]]);
|
||||
return createSessionOpsApi({
|
||||
sessions,
|
||||
@@ -14,6 +36,7 @@ function makeApi(session, execOnEtSession) {
|
||||
iconv: { encodingExists: () => true },
|
||||
sessionEncodings: new Map(),
|
||||
resetSessionDecoders: () => {},
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,14 +57,99 @@ test("getSessionDistroInfo probes ET sessions through execOnEtSession", async ()
|
||||
assert.match(command, /os-release/);
|
||||
});
|
||||
|
||||
test("getServerStats explicitly skips ET sessions instead of treating them as missing", async () => {
|
||||
test("getServerStats opens an ET stats companion connection for direct ET sessions", async () => {
|
||||
const session = {
|
||||
type: "et",
|
||||
sshUserHost: "alice@example.test",
|
||||
sshOptions: [],
|
||||
sshEnv: {},
|
||||
etStatsAuth: { hostname: "example.test", username: "alice" },
|
||||
};
|
||||
let ensureCalls = 0;
|
||||
let execFallbackCalls = 0;
|
||||
const api = makeApi(
|
||||
{ type: "et", sshUserHost: "alice@example.test", sshOptions: [], sshEnv: {} },
|
||||
async () => ({ success: true, stdout: "", stderr: "" }),
|
||||
session,
|
||||
async () => {
|
||||
execFallbackCalls += 1;
|
||||
return { success: true, stdout: LINUX_STATS, stderr: "" };
|
||||
},
|
||||
{
|
||||
ensureEtStatsConnection: async (s, id) => {
|
||||
ensureCalls += 1;
|
||||
assert.equal(s, session);
|
||||
assert.equal(id, "et-1");
|
||||
s.etStatsConn = fakeConn(LINUX_STATS);
|
||||
return s.etStatsConn;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const result = await api.getServerStats(null, { sessionId: "et-1" });
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error, /not supported for EternalTerminal/i);
|
||||
assert.equal(ensureCalls, 1);
|
||||
assert.equal(execFallbackCalls, 0);
|
||||
assert.equal(session.conn, undefined);
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.stats.memTotal, 8000);
|
||||
assert.equal(result.stats.cpuCores, 4);
|
||||
});
|
||||
|
||||
test("getServerStats falls back to execOnEtSession for jumped ET sessions", async () => {
|
||||
let command = "";
|
||||
let ensureCalls = 0;
|
||||
const api = makeApi(
|
||||
{
|
||||
type: "et",
|
||||
sshUserHost: "alice@example.test",
|
||||
sshOptions: [],
|
||||
sshEnv: {},
|
||||
etStatsAuth: { hostname: "example.test", hasJumpHost: true },
|
||||
},
|
||||
async (_session, cmd) => {
|
||||
command = cmd;
|
||||
return { success: true, stdout: LINUX_STATS, stderr: "" };
|
||||
},
|
||||
{
|
||||
ensureEtStatsConnection: async () => {
|
||||
ensureCalls += 1;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const result = await api.getServerStats(null, { sessionId: "et-1" });
|
||||
|
||||
assert.equal(ensureCalls, 0);
|
||||
assert.match(command, /CPURAW|UNSUPPORTED_OS/);
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.stats.memTotal, 8000);
|
||||
});
|
||||
|
||||
test("getServerStats falls back to execOnEtSession when the direct ET companion is unavailable", async () => {
|
||||
let execFallbackCalls = 0;
|
||||
const api = makeApi(
|
||||
{
|
||||
type: "et",
|
||||
sshUserHost: "alice@example.test",
|
||||
sshOptions: [],
|
||||
sshEnv: {},
|
||||
etStatsAuth: { hostname: "example.test" },
|
||||
etStatsConnFailed: true,
|
||||
},
|
||||
async () => {
|
||||
execFallbackCalls += 1;
|
||||
return { success: true, stdout: LINUX_STATS, stderr: "" };
|
||||
},
|
||||
{
|
||||
ensureEtStatsConnection: async () => {
|
||||
throw new Error("should not retry failed companion");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const result = await api.getServerStats(null, { sessionId: "et-1" });
|
||||
|
||||
assert.equal(execFallbackCalls, 1);
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.stats.memTotal, 8000);
|
||||
});
|
||||
|
||||
@@ -116,7 +116,7 @@ function createMoshStatsConnectionApi(ctx) {
|
||||
// keys. `trust.trusted` additionally gates the password method in the
|
||||
// authHandler (defense in depth); `trust.rejected` lets the caller treat an
|
||||
// untrusted host as a permanent failure so it stops reconnecting every poll.
|
||||
function createTrustEnforcingHostVerifier({ hostname, port, knownHosts, trust }) {
|
||||
function createTrustEnforcingHostVerifier({ hostname, port, knownHosts, trust, label }) {
|
||||
return (rawKey, callback) => {
|
||||
try {
|
||||
const keyInfo = hostKeyVerifier.describeHostKey(rawKey);
|
||||
@@ -140,7 +140,7 @@ function createMoshStatsConnectionApi(ctx) {
|
||||
}) === true;
|
||||
}
|
||||
} catch (err) {
|
||||
log("[Mosh] stats companion host-key check failed:", err?.message || String(err));
|
||||
log(`[${label}] stats companion host-key check failed:`, err?.message || String(err));
|
||||
trust.trusted = false;
|
||||
}
|
||||
if (!trust.trusted) trust.rejected = true;
|
||||
@@ -178,7 +178,7 @@ function createMoshStatsConnectionApi(ctx) {
|
||||
};
|
||||
}
|
||||
|
||||
async function buildStatsConnectOpts(auth) {
|
||||
async function buildStatsConnectOpts(auth, label = "Mosh") {
|
||||
const connectOpts = {
|
||||
host: auth.hostname,
|
||||
port: auth.port || 22,
|
||||
@@ -268,6 +268,7 @@ function createMoshStatsConnectionApi(ctx) {
|
||||
port: connectOpts.port,
|
||||
knownHosts: auth.knownHosts,
|
||||
trust,
|
||||
label,
|
||||
});
|
||||
|
||||
// When a plaintext password is in play, also gate it behind the trust
|
||||
@@ -310,23 +311,43 @@ function createMoshStatsConnectionApi(ctx) {
|
||||
* @param {Electron.WebContents} [webContents] - sender of the stats IPC,
|
||||
* used only for certificate-agent construction.
|
||||
*/
|
||||
function ensureMoshStatsConnection(session, sessionId, webContents) {
|
||||
function ensureStatsConnection(session, sessionId, webContents, opts) {
|
||||
if (!session) return Promise.resolve(null);
|
||||
// A previously established companion is reused.
|
||||
if (session.moshStatsConn) return Promise.resolve(session.moshStatsConn);
|
||||
if (session[opts.connProp]) return Promise.resolve(session[opts.connProp]);
|
||||
// A prior attempt permanently failed — don't keep retrying every poll.
|
||||
if (session.moshStatsConnFailed) return Promise.resolve(null);
|
||||
if (session[opts.failedProp]) return Promise.resolve(null);
|
||||
// Reuse an in-flight attempt so two near-simultaneous polls don't open
|
||||
// two connections.
|
||||
if (session.moshStatsConnPromise) return session.moshStatsConnPromise;
|
||||
if (session[opts.promiseProp]) return session[opts.promiseProp];
|
||||
|
||||
const promise = establishMoshStatsConnection(session, sessionId, webContents).finally(() => {
|
||||
session.moshStatsConnPromise = null;
|
||||
const promise = establishStatsConnection(session, sessionId, webContents, opts).finally(() => {
|
||||
session[opts.promiseProp] = null;
|
||||
});
|
||||
session.moshStatsConnPromise = promise;
|
||||
session[opts.promiseProp] = promise;
|
||||
return promise;
|
||||
}
|
||||
|
||||
function ensureMoshStatsConnection(session, sessionId, webContents) {
|
||||
return ensureStatsConnection(session, sessionId, webContents, {
|
||||
label: "Mosh",
|
||||
authProp: "moshStatsAuth",
|
||||
connProp: "moshStatsConn",
|
||||
failedProp: "moshStatsConnFailed",
|
||||
promiseProp: "moshStatsConnPromise",
|
||||
});
|
||||
}
|
||||
|
||||
function ensureEtStatsConnection(session, sessionId, webContents) {
|
||||
return ensureStatsConnection(session, sessionId, webContents, {
|
||||
label: "ET",
|
||||
authProp: "etStatsAuth",
|
||||
connProp: "etStatsConn",
|
||||
failedProp: "etStatsConnFailed",
|
||||
promiseProp: "etStatsConnPromise",
|
||||
});
|
||||
}
|
||||
|
||||
// True once the session has gone away — either explicitly closed or
|
||||
// dropped from the shared map (e.g. the mosh-client PTY exited while we
|
||||
// were still connecting the companion).
|
||||
@@ -334,8 +355,8 @@ function createMoshStatsConnectionApi(ctx) {
|
||||
return session.closed || sessions.get(sessionId) !== session;
|
||||
}
|
||||
|
||||
async function establishMoshStatsConnection(session, sessionId, webContents) {
|
||||
const auth = session.moshStatsAuth;
|
||||
async function establishStatsConnection(session, sessionId, webContents, opts) {
|
||||
const auth = session[opts.authProp];
|
||||
if (!auth || !auth.hostname) {
|
||||
// moshStatsAuth is only assigned once the handshake completes and the
|
||||
// session swaps to mosh-client. The renderer can mark a session
|
||||
@@ -349,11 +370,11 @@ function createMoshStatsConnectionApi(ctx) {
|
||||
const { connectOpts, hasAnyAuth, trust } = await buildStatsConnectOpts({
|
||||
...auth,
|
||||
webContents,
|
||||
});
|
||||
}, opts.label);
|
||||
if (!hasAnyAuth) {
|
||||
// Nothing we can authenticate with non-interactively (e.g. the user
|
||||
// typed a password into the Mosh handshake PTY that we never stored).
|
||||
session.moshStatsConnFailed = true;
|
||||
session[opts.failedProp] = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -399,7 +420,7 @@ function createMoshStatsConnectionApi(ctx) {
|
||||
// retry.
|
||||
const fail = (err, permanent) => {
|
||||
try { conn.end(); } catch { /* ignore */ }
|
||||
if (permanent) session.moshStatsConnFailed = true;
|
||||
if (permanent) session[opts.failedProp] = true;
|
||||
finish(null);
|
||||
};
|
||||
|
||||
@@ -410,17 +431,18 @@ function createMoshStatsConnectionApi(ctx) {
|
||||
finish(null);
|
||||
return;
|
||||
}
|
||||
// Stored only on moshStatsConn — never on session.conn (see the
|
||||
// ensureMoshStatsConnection docstring for why).
|
||||
session.moshStatsConn = conn;
|
||||
// Stored only on the protocol-specific companion property
|
||||
// (opts.connProp, e.g. moshStatsConn / etStatsConn) — never on
|
||||
// session.conn (see ensureMoshStatsConnection docstring for why).
|
||||
session[opts.connProp] = conn;
|
||||
finish(conn);
|
||||
});
|
||||
|
||||
conn.on("error", (err) => {
|
||||
log("[Mosh] stats companion connection error:", err?.message || String(err));
|
||||
log(`[${opts.label}] stats companion connection error:`, err?.message || String(err));
|
||||
// If this fired after we already adopted the connection, drop the
|
||||
// stale handle so the next poll can rebuild a fresh one.
|
||||
if (session.moshStatsConn === conn) session.moshStatsConn = null;
|
||||
if (session[opts.connProp] === conn) session[opts.connProp] = null;
|
||||
// Auth rejection won't change with the same stored credentials, and a
|
||||
// host-key rejection (untrusted host) won't either until the user
|
||||
// vets the host via a real session — treat both as permanent so we
|
||||
@@ -429,10 +451,11 @@ function createMoshStatsConnectionApi(ctx) {
|
||||
});
|
||||
|
||||
conn.on("close", () => {
|
||||
if (session.moshStatsConn === conn) session.moshStatsConn = null;
|
||||
if (session[opts.connProp] === conn) session[opts.connProp] = null;
|
||||
// If the socket closed mid-handshake without ever emitting "ready"
|
||||
// or "error", settle the attempt here so the awaiting getServerStats
|
||||
// call (and session.moshStatsConnPromise) don't hang forever. This
|
||||
// call (and the in-flight promise on opts.promiseProp) don't hang
|
||||
// forever. This
|
||||
// is treated as transient — the next poll may retry.
|
||||
finish(null);
|
||||
});
|
||||
@@ -440,7 +463,7 @@ function createMoshStatsConnectionApi(ctx) {
|
||||
try {
|
||||
conn.connect(connectOpts);
|
||||
} catch (err) {
|
||||
log("[Mosh] stats companion connect threw:", err?.message || String(err));
|
||||
log(`[${opts.label}] stats companion connect threw:`, err?.message || String(err));
|
||||
// A synchronous throw from connect() (e.g. malformed options) won't
|
||||
// succeed on retry either.
|
||||
fail(err, true);
|
||||
@@ -448,7 +471,7 @@ function createMoshStatsConnectionApi(ctx) {
|
||||
});
|
||||
}
|
||||
|
||||
return { ensureMoshStatsConnection };
|
||||
return { ensureMoshStatsConnection, ensureEtStatsConnection };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -147,6 +147,47 @@ test("connects with a stored password and adopts the connection on ready", async
|
||||
assert.equal(session.conn, undefined);
|
||||
});
|
||||
|
||||
test("ET stats companion uses ET-specific session fields", async () => {
|
||||
const { api, sessions } = makeApi();
|
||||
const session = { etStatsAuth: { hostname: "example.com", port: 2222, username: "alice", password: "secret" } };
|
||||
sessions.set("sid", session);
|
||||
|
||||
const pending = api.ensureEtStatsConnection(session, "sid");
|
||||
await tick();
|
||||
assert.equal(FakeSSHClient.instances.length, 1);
|
||||
const client = FakeSSHClient.instances[0];
|
||||
assert.equal(client.connectOpts.host, "example.com");
|
||||
assert.equal(client.connectOpts.port, 2222);
|
||||
assert.equal(client.connectOpts.username, "alice");
|
||||
assert.equal(client.connectOpts.password, "secret");
|
||||
|
||||
client.emitReady();
|
||||
const result = await pending;
|
||||
|
||||
assert.equal(result, client);
|
||||
assert.equal(session.etStatsConn, client);
|
||||
assert.equal(session.moshStatsConn, undefined);
|
||||
assert.equal(session.conn, undefined);
|
||||
});
|
||||
|
||||
test("ET stats companion caches permanent failures on ET-specific fields", async () => {
|
||||
const { api, sessions } = makeApi();
|
||||
const session = { etStatsAuth: { hostname: "h", username: "u", password: "p" } };
|
||||
sessions.set("sid", session);
|
||||
|
||||
const p1 = api.ensureEtStatsConnection(session, "sid");
|
||||
await tick();
|
||||
const authErr = new Error("All configured authentication methods failed");
|
||||
authErr.level = "client-authentication";
|
||||
FakeSSHClient.instances[0].emitError(authErr);
|
||||
assert.equal(await p1, null);
|
||||
assert.equal(session.etStatsConnFailed, true);
|
||||
|
||||
const r2 = await api.ensureEtStatsConnection(session, "sid");
|
||||
assert.equal(r2, null);
|
||||
assert.equal(FakeSSHClient.instances.length, 1);
|
||||
});
|
||||
|
||||
test("uses a parseable private key and passphrase, not password fallback only", async () => {
|
||||
const { api, sessions } = makeApi();
|
||||
const session = {
|
||||
|
||||
@@ -33,7 +33,10 @@ function createSessionOpsApi(ctx) {
|
||||
if (typeof execOnEtSession !== "function") {
|
||||
return { success: false, error: "ET command executor unavailable" };
|
||||
}
|
||||
return execOnEtSession(session, "cat /etc/os-release 2>/dev/null || uname -a", 5000);
|
||||
return execOnEtSession(session, "cat /etc/os-release 2>/dev/null || uname -a", 5000, {
|
||||
requireTrustedHost: true,
|
||||
knownHosts: session.etStatsAuth?.knownHosts,
|
||||
});
|
||||
}
|
||||
if (!session || !session.conn) {
|
||||
return { success: false, error: 'Session not found or not connected' };
|
||||
@@ -497,23 +500,82 @@ function createSessionOpsApi(ctx) {
|
||||
return { success: false, error: 'Session not found or not connected' };
|
||||
}
|
||||
|
||||
if (session.type === "et") {
|
||||
return { success: false, error: "Server stats are not supported for EternalTerminal sessions" };
|
||||
const isEtSession = session.type === "et";
|
||||
const etUsesExecFallback = isEtSession && session.etStatsAuth?.hasJumpHost;
|
||||
|
||||
function createBufferedExecStream(stdout = "", stderr = "") {
|
||||
const stream = {
|
||||
stderr: {
|
||||
on(eventName, handler) {
|
||||
if (eventName === "data" && stderr) {
|
||||
setTimeout(() => handler(Buffer.from(stderr)), 0);
|
||||
}
|
||||
return this;
|
||||
},
|
||||
},
|
||||
on(eventName, handler) {
|
||||
if (eventName === "data" && stdout) {
|
||||
setTimeout(() => handler(Buffer.from(stdout)), 0);
|
||||
}
|
||||
if (eventName === "close") {
|
||||
setTimeout(() => handler(0), 0);
|
||||
}
|
||||
return this;
|
||||
},
|
||||
close() {},
|
||||
destroy() {},
|
||||
};
|
||||
return stream;
|
||||
}
|
||||
|
||||
// Mosh sessions run over UDP via a local mosh-client PTY and have no
|
||||
function createEtStatsExecConn(etSession) {
|
||||
return {
|
||||
exec(command, cb) {
|
||||
execOnEtSession(etSession, command, 4500, {
|
||||
requireTrustedHost: true,
|
||||
knownHosts: etSession.etStatsAuth?.knownHosts,
|
||||
})
|
||||
.then((result) => {
|
||||
if (!result?.success) {
|
||||
cb(new Error(result?.error || result?.stderr || "ET stats command failed"));
|
||||
return;
|
||||
}
|
||||
cb(null, createBufferedExecStream(result.stdout || "", result.stderr || ""));
|
||||
})
|
||||
.catch((err) => {
|
||||
cb(err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Mosh and direct ET sessions run through external PTYs and have no
|
||||
// ssh2 connection of their own. Lazily open a best-effort companion SSH
|
||||
// connection (reusing the handshake credentials) so the host-info bar
|
||||
// works for Mosh too (issue #1198). The companion lives on
|
||||
// session.moshStatsConn — deliberately NOT session.conn — so it stays
|
||||
// invisible to other bridges (getSessionPwd / SFTP / MCP exec) that key
|
||||
// off session.conn as the interactive connection. This is a no-op for
|
||||
// real SSH sessions, which already carry session.conn.
|
||||
if (!session.conn && !session.moshStatsConn && typeof ensureMoshStatsConnection === 'function') {
|
||||
await ensureMoshStatsConnection(session, sessionId, event?.sender);
|
||||
// connection (reusing credentials) so the host-info bar can use the same
|
||||
// stats parser as normal SSH. The companion lives on protocol-specific
|
||||
// fields — never session.conn — so other bridges do not mistake it for
|
||||
// the interactive connection. ET sessions with a jump host use the
|
||||
// already-prepared OpenSSH environment via execOnEtSession instead.
|
||||
if (!session.conn && !session.moshStatsConn && !session.etStatsConn) {
|
||||
if (session.type === 'mosh' && typeof ensureMoshStatsConnection === 'function') {
|
||||
await ensureMoshStatsConnection(session, sessionId, event?.sender);
|
||||
} else if (
|
||||
isEtSession &&
|
||||
!etUsesExecFallback &&
|
||||
!session.etStatsConnFailed &&
|
||||
typeof ensureEtStatsConnection === 'function'
|
||||
) {
|
||||
await ensureEtStatsConnection(session, sessionId, event?.sender);
|
||||
}
|
||||
}
|
||||
|
||||
const conn = session.conn || session.moshStatsConn;
|
||||
const canExecEtStats =
|
||||
isEtSession &&
|
||||
typeof execOnEtSession === "function" &&
|
||||
!!session.sshUserHost &&
|
||||
!session.externalAuthArtifactsCleaned;
|
||||
const conn = session.conn || session.moshStatsConn || session.etStatsConn ||
|
||||
(canExecEtStats ? createEtStatsExecConn(session) : null);
|
||||
if (!conn) {
|
||||
// A Mosh session can be marked "connected" (and start polling) from
|
||||
// the SSH bootstrap's visible output before swapToMoshClient stores
|
||||
|
||||
@@ -292,6 +292,7 @@ function createSystemKnownHostsApi(ctx) {
|
||||
|
||||
return {
|
||||
isHostKeyTrustedBySystem,
|
||||
readSystemKnownHostsContent,
|
||||
// Exposed for unit testing.
|
||||
parseKnownHostsLine,
|
||||
hostFieldMatches,
|
||||
|
||||
@@ -835,9 +835,10 @@ function closeSession(event, payload) {
|
||||
} else if (session.proc) {
|
||||
session.proc.kill();
|
||||
// Mosh sessions may also carry a companion ssh2 connection opened
|
||||
// lazily for host-info stats (issue #1198), stored on moshStatsConn so
|
||||
// it stays separate from session.conn. Close it here to avoid leaking it.
|
||||
session.moshStatsConn?.end();
|
||||
// lazily for host-info stats (issue #1198). ET can use the same pattern.
|
||||
// Close companions here to avoid leaking them.
|
||||
try { session.moshStatsConn?.end(); } catch { /* ignore */ }
|
||||
try { session.etStatsConn?.end(); } catch { /* ignore */ }
|
||||
} else if (session.socket) {
|
||||
session.socket.destroy();
|
||||
} else if (session.serialPort) {
|
||||
@@ -1014,8 +1015,9 @@ function cleanupAllSessions() {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
// Tear down a Mosh stats companion ssh2 connection if one was opened
|
||||
// (issue #1198) — it lives on moshStatsConn, separate from session.conn.
|
||||
// (issue #1198), and the equivalent ET companion when present.
|
||||
try { session.moshStatsConn?.end(); } catch (e) { /* ignore */ }
|
||||
try { session.etStatsConn?.end(); } catch (e) { /* ignore */ }
|
||||
} else if (session.socket) {
|
||||
session.socket.destroy();
|
||||
} else if (session.serialPort) {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
/* eslint-disable no-undef */
|
||||
const crypto = require("node:crypto");
|
||||
const { createSystemKnownHostsApi } = require("../sshBridge/systemKnownHosts.cjs");
|
||||
|
||||
//
|
||||
// EternalTerminal session backend, factored into the createXxxSessionApi
|
||||
// pattern used by moshSession.cjs / telnetSession.cjs. Dependencies arrive
|
||||
@@ -533,21 +536,121 @@ main();
|
||||
return env;
|
||||
}
|
||||
|
||||
function formatVaultKnownHostLine(knownHost) {
|
||||
if (!knownHost?.hostname || !knownHost?.keyType) return null;
|
||||
const port = Number.isFinite(knownHost.port) ? Number(knownHost.port) : 22;
|
||||
const hostField = port !== 22 ? `[${knownHost.hostname}]:${port}` : knownHost.hostname;
|
||||
const pubKey = String(knownHost.publicKey || "").trim();
|
||||
const parts = pubKey.split(/\s+/);
|
||||
let keyType = knownHost.keyType;
|
||||
let keyBlob = "";
|
||||
if (parts.length >= 2 && /^ssh-|^ecdsa-|^sk-/.test(parts[0])) {
|
||||
keyType = parts[0];
|
||||
keyBlob = parts[1];
|
||||
} else if (parts.length === 1 && parts[0].length > 0 && !/^SHA256:/i.test(parts[0])) {
|
||||
keyBlob = parts[0];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
if (!keyBlob) return null;
|
||||
return `${hostField} ${keyType} ${keyBlob}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a known_hosts file for background ET exec (stats / distro probes).
|
||||
* Merges the user's system known_hosts with any Netcatty-vault entries that
|
||||
* carry a full public key blob, then pins StrictHostKeyChecking=yes on exec
|
||||
* so accept-new cannot auto-trust a host in a non-interactive flow.
|
||||
*/
|
||||
function ensureStrictExecKnownHostsFile(session, knownHosts) {
|
||||
if (session.etStrictExecKnownHostsPath) {
|
||||
return session.etStrictExecKnownHostsPath;
|
||||
}
|
||||
|
||||
const { readSystemKnownHostsContent } = createSystemKnownHostsApi({
|
||||
fs, path, os, crypto, log: console,
|
||||
});
|
||||
const chunks = [];
|
||||
|
||||
try {
|
||||
const systemContent = readSystemKnownHostsContent();
|
||||
if (systemContent) chunks.push(systemContent);
|
||||
} catch {
|
||||
// ignore read failures — strict checking fails closed below
|
||||
}
|
||||
|
||||
const configuredKnownHosts = (session.sshOptions || []).find(
|
||||
(opt) => opt.startsWith("UserKnownHostsFile="),
|
||||
);
|
||||
if (configuredKnownHosts) {
|
||||
const configuredPath = configuredKnownHosts.slice("UserKnownHostsFile=".length);
|
||||
try {
|
||||
const configuredContent = fs.readFileSync(configuredPath, "utf8");
|
||||
if (configuredContent) chunks.push(configuredContent);
|
||||
} catch {
|
||||
// ignore missing configured file
|
||||
}
|
||||
}
|
||||
|
||||
const vaultLines = [];
|
||||
if (Array.isArray(knownHosts)) {
|
||||
for (const knownHost of knownHosts) {
|
||||
const line = formatVaultKnownHostLine(knownHost);
|
||||
if (line) vaultLines.push(line);
|
||||
}
|
||||
}
|
||||
if (vaultLines.length > 0) {
|
||||
chunks.push(vaultLines.join("\n"));
|
||||
}
|
||||
|
||||
const artifact = Array.isArray(session.externalAuthArtifacts)
|
||||
? session.externalAuthArtifacts[0]
|
||||
: null;
|
||||
const sshDir = artifact ? path.dirname(artifact) : tempDirBridge.getTempDir();
|
||||
const strictKhPath = path.join(sshDir, "netcatty-et-strict-known_hosts");
|
||||
writeSecureFile(strictKhPath, chunks.filter(Boolean).join("\n") + (chunks.length ? "\n" : ""), 0o600);
|
||||
session.etStrictExecKnownHostsPath = strictKhPath;
|
||||
if (Array.isArray(session.externalAuthArtifacts)) {
|
||||
session.externalAuthArtifacts.push(strictKhPath);
|
||||
}
|
||||
return strictKhPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a remote command on an ET session by spawning a system ssh
|
||||
* process. Reuses the SSH environment (keys, config, askpass) already
|
||||
* prepared by prepareEtSshEnvironment() for the ET connection.
|
||||
*
|
||||
* @param {object} [execOpts]
|
||||
* @param {boolean} [execOpts.requireTrustedHost] When true, refuse unknown
|
||||
* host keys (StrictHostKeyChecking=yes) using system + vault known_hosts
|
||||
* instead of accept-new. Used for background stats/distro probes.
|
||||
* @param {Array} [execOpts.knownHosts] Netcatty vault known hosts to merge
|
||||
* into the strict known_hosts file (defaults to session.etStatsAuth).
|
||||
*/
|
||||
function execOnEtSession(session, command, timeoutMs = 5000) {
|
||||
function execOnEtSession(session, command, timeoutMs = 5000, execOpts = {}) {
|
||||
if (!session?.sshUserHost || session.externalAuthArtifactsCleaned) {
|
||||
return Promise.resolve({ success: false, error: "ET SSH environment not available" });
|
||||
}
|
||||
|
||||
const requireTrustedHost = execOpts.requireTrustedHost === true;
|
||||
const knownHosts = execOpts.knownHosts ?? session.etStatsAuth?.knownHosts;
|
||||
|
||||
const sshCmd = process.platform === "win32" ? findExecutable("ssh") : "ssh";
|
||||
const args = ["-o", "BatchMode=no", "-o", "StrictHostKeyChecking=accept-new"];
|
||||
const args = ["-o", "BatchMode=no"];
|
||||
if (!requireTrustedHost) {
|
||||
args.push("-o", "StrictHostKeyChecking=accept-new");
|
||||
}
|
||||
for (const opt of session.sshOptions) {
|
||||
if (requireTrustedHost && opt.startsWith("StrictHostKeyChecking=")) continue;
|
||||
if (requireTrustedHost && opt.startsWith("UserKnownHostsFile=")) continue;
|
||||
args.push("-o", opt);
|
||||
}
|
||||
if (requireTrustedHost) {
|
||||
const strictKhPath = ensureStrictExecKnownHostsFile(session, knownHosts);
|
||||
args.push("-o", `UserKnownHostsFile=${normalizeSshConfigPath(strictKhPath)}`);
|
||||
args.push("-o", "StrictHostKeyChecking=yes");
|
||||
}
|
||||
args.push(session.sshUserHost, command);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
@@ -666,6 +769,22 @@ main();
|
||||
sshEnv: sshEnvironment?.env || {},
|
||||
sshOptions: sshEnvironment?.sshOptions || [],
|
||||
sshUserHost: sshEnvironment?.userHost || "",
|
||||
etStatsAuth: {
|
||||
hostname: options.hostname,
|
||||
port: options.port || 22,
|
||||
username: options.username,
|
||||
password: options.password,
|
||||
privateKey: options.privateKey,
|
||||
passphrase: options.passphrase,
|
||||
certificate: options.certificate,
|
||||
keyId: options.keyId,
|
||||
identityFilePaths: options.identityFilePaths,
|
||||
legacyAlgorithms: options.legacyAlgorithms,
|
||||
skipEcdsaHostKey: options.skipEcdsaHostKey,
|
||||
algorithmOverrides: options.algorithmOverrides,
|
||||
knownHosts: options.knownHosts,
|
||||
hasJumpHost: Array.isArray(options.jumpHosts) && options.jumpHosts.length > 0,
|
||||
},
|
||||
flushPendingData: null,
|
||||
lastIdlePrompt: "",
|
||||
lastIdlePromptAt: 0,
|
||||
@@ -725,6 +844,7 @@ main();
|
||||
|
||||
proc.onExit((evt) => {
|
||||
flushEt();
|
||||
try { session.etStatsConn?.end(); } catch { /* ignore */ }
|
||||
cleanupSessionExternalAuthArtifacts(session);
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
|
||||
@@ -9,7 +9,7 @@ const { createEtSessionApi } = require("./etSession.cjs");
|
||||
|
||||
// Build an et session API wired to a hermetic temp HOME so prepareEtSshEnvironment
|
||||
// is deterministic regardless of the developer's real ~/.ssh contents.
|
||||
function makeApi(t) {
|
||||
function makeApi(t, overrides = {}) {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-et-prep-"));
|
||||
const fakeHome = path.join(base, "home");
|
||||
fs.mkdirSync(fakeHome, { recursive: true });
|
||||
@@ -36,6 +36,7 @@ function makeApi(t) {
|
||||
// no-op execFileSync so writeSecureFile's Windows icacls hardening doesn't spawn
|
||||
execFileSync: () => {},
|
||||
execFile: () => {},
|
||||
...overrides,
|
||||
StringDecoder: require("node:string_decoder").StringDecoder,
|
||||
randomUUID: require("node:crypto").randomUUID,
|
||||
pty: {},
|
||||
@@ -324,6 +325,41 @@ test("prepareEtSshEnvironment uses a persistent user known_hosts file", (t) => {
|
||||
assert.equal(fs.existsSync(path.join(base, "et-ssh-home-sess1", ".ssh", "known_hosts")), false);
|
||||
});
|
||||
|
||||
test("execOnEtSession requireTrustedHost uses strict host-key checking", async (t) => {
|
||||
let capturedArgs = null;
|
||||
const { api } = makeApi(t, {
|
||||
execFile: (_cmd, args, _opts, cb) => {
|
||||
capturedArgs = args;
|
||||
process.nextTick(() => cb(null, "", ""));
|
||||
},
|
||||
});
|
||||
const env = api.prepareEtSshEnvironment("sess1", { hostname: "host.example", username: "alice" });
|
||||
const session = {
|
||||
sshUserHost: env.userHost,
|
||||
sshOptions: env.sshOptions,
|
||||
sshEnv: env.env,
|
||||
externalAuthArtifacts: env.artifacts,
|
||||
externalAuthArtifactsCleaned: false,
|
||||
etStatsAuth: {
|
||||
knownHosts: [{
|
||||
hostname: "host.example",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
publicKey: "vaultblob",
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
await api.execOnEtSession(session, "echo ok", 1000, { requireTrustedHost: true });
|
||||
|
||||
const joined = capturedArgs.join(" ");
|
||||
assert.match(joined, /StrictHostKeyChecking=yes/);
|
||||
assert.doesNotMatch(joined, /StrictHostKeyChecking=accept-new/);
|
||||
assert.ok(session.etStrictExecKnownHostsPath);
|
||||
const strictContent = fs.readFileSync(session.etStrictExecKnownHostsPath, "utf8");
|
||||
assert.match(strictContent, /host\.example ssh-ed25519 vaultblob/);
|
||||
});
|
||||
|
||||
test("cleanupStaleEtTempDirs only removes Netcatty ET temp directories by prefix", (t) => {
|
||||
const { api, base } = makeApi(t);
|
||||
const staleEtDir = path.join(base, "et-ssh-home-old-session");
|
||||
|
||||
@@ -160,6 +160,9 @@ export const STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = 'netcatty_show_only
|
||||
export const STORAGE_KEY_SHOW_SFTP_TAB = 'netcatty_show_sftp_tab_v1';
|
||||
export const STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR = 'netcatty_show_host_tree_sidebar_v1';
|
||||
|
||||
// Shortcuts: Cmd/Ctrl+[1...9] skip pinned Vault/SFTP tabs
|
||||
export const STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = 'netcatty_shell_only_tab_number_shortcuts_v1';
|
||||
|
||||
// Group Configurations (default settings inherited by hosts)
|
||||
export const STORAGE_KEY_GROUP_CONFIGS = 'netcatty_group_configs_v1';
|
||||
|
||||
|
||||
3
types/global/netcatty-bridge-session.d.ts
vendored
3
types/global/netcatty-bridge-session.d.ts
vendored
@@ -58,6 +58,9 @@ declare global {
|
||||
port?: number;
|
||||
etPort?: number;
|
||||
legacyAlgorithms?: boolean;
|
||||
skipEcdsaHostKey?: boolean;
|
||||
algorithmOverrides?: import("../../domain/models").HostAlgorithmOverrides;
|
||||
knownHosts?: import("../../domain/models").KnownHost[];
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
agentForwarding?: boolean;
|
||||
cols?: number;
|
||||
|
||||
Reference in New Issue
Block a user