mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-11 00:30:57 +00:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7ad0207c6 | ||
|
|
90e0de1e6b | ||
|
|
27a54d9780 | ||
|
|
85bddba538 | ||
|
|
66e87a95f1 | ||
|
|
7cce9c9dbb | ||
|
|
6bf0d2ff15 | ||
|
|
56b185a80b | ||
|
|
954b60acf2 | ||
|
|
73284582f5 | ||
|
|
33853398d4 | ||
|
|
c62d375d2a | ||
|
|
80263c4647 | ||
|
|
20debb5c5a | ||
|
|
d70386f74c | ||
|
|
bfa1a9c635 | ||
|
|
9497cd65fc | ||
|
|
95a0186d90 | ||
|
|
626e0ce5bd | ||
|
|
71d378c9b8 | ||
|
|
892777fbf2 | ||
|
|
09a9b5ece8 | ||
|
|
0261931f95 | ||
|
|
62e9c9ed12 | ||
|
|
1b4e826e15 | ||
|
|
f469513579 |
No files matched your search
@@ -776,7 +776,9 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
|
||||
{
|
||||
const shortcutTabs = buildNumberShortcutTabTargets({
|
||||
showSftpTab: settings.showSftpTab ?? true,
|
||||
// Match the TopTabs badge map: when the sidebar hosts SFTP, the top tab
|
||||
// is hidden and work tabs shift up in the Cmd/Ctrl+[1...9] numbering.
|
||||
showSftpTab: (settings.showSftpTab ?? true) && !settings.sftpInSidebar,
|
||||
shellOnlyTabNumberShortcuts: settings.shellOnlyTabNumberShortcuts ?? false,
|
||||
orderedTabs,
|
||||
editorTabIds: editorTabs.map((t) => toEditorTabId(t.id)),
|
||||
@@ -923,7 +925,9 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
setActiveTabId('vault');
|
||||
break;
|
||||
case 'openSftp':
|
||||
if (settings.showSftpTab) {
|
||||
// Effective availability: the SFTP tab is reachable either as a top
|
||||
// tab (showSftpTab) or inside the vault sidebar (sftpInSidebar).
|
||||
if ((settings.showSftpTab ?? true) || settings.sftpInSidebar) {
|
||||
setActiveTabId('sftp');
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { Suspense, lazy, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useActiveTabId, useIsSftpActive, useIsVaultActive } from '../state/activeTabStore';
|
||||
import { useSettingsChromeStore } from '../state/settingsChromeStore';
|
||||
import { computeSftpViewRailOffset, useVaultSidebarLayoutWidth } from '../state/vaultSidebarLayoutStore';
|
||||
import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
|
||||
import { isTerminalContentTabSurface } from './workTabSurface';
|
||||
import { cn } from '../../lib/utils';
|
||||
@@ -14,13 +16,20 @@ export const VaultViewContainer: React.FC<{
|
||||
children: React.ReactNode;
|
||||
appThemeStyle?: React.CSSProperties;
|
||||
}> = ({ children, appThemeStyle }) => {
|
||||
const isActive = useIsVaultActive();
|
||||
const isVaultActive = useIsVaultActive();
|
||||
const { sftpInSidebar } = useSettingsChromeStore();
|
||||
const isSftpActive = useIsSftpActive();
|
||||
const vaultRailWidth = useVaultSidebarLayoutWidth();
|
||||
// With the "SFTP in sidebar" setting, the vault sidebar rail stays visible
|
||||
// next to the root SFTP view while the SFTP tab is active.
|
||||
const sftpRailMode = !!sftpInSidebar && isSftpActive;
|
||||
const isActive = isVaultActive || sftpRailMode;
|
||||
const wasActiveRef = useRef(isActive);
|
||||
const [suppressActiveTransition, setSuppressActiveTransition] = useState(false);
|
||||
const isActivating = isActive && !wasActiveRef.current;
|
||||
const shouldSuppressTransition = isActivating || suppressActiveTransition;
|
||||
const containerStyle: React.CSSProperties = isActive
|
||||
? {}
|
||||
? (sftpRailMode ? { left: 0, top: 0, bottom: 0, width: vaultRailWidth } : {})
|
||||
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1 };
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -146,6 +155,8 @@ export function shouldRenderTerminalLayerMount(
|
||||
|
||||
export const SftpViewMount: React.FC<SftpViewProps> = (props) => {
|
||||
const isActive = useIsSftpActive();
|
||||
const { sftpInSidebar } = useSettingsChromeStore();
|
||||
const vaultRailWidth = useVaultSidebarLayoutWidth();
|
||||
const [shouldMount, setShouldMount] = useState(isActive);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -154,12 +165,25 @@ export const SftpViewMount: React.FC<SftpViewProps> = (props) => {
|
||||
|
||||
if (!shouldMount) return null;
|
||||
|
||||
const railOffset = computeSftpViewRailOffset({
|
||||
sftpInSidebar: !!sftpInSidebar,
|
||||
isSftpActive: isActive,
|
||||
vaultRailWidth,
|
||||
});
|
||||
|
||||
return (
|
||||
<LazyLoadBoundary name="SFTP" resetKey={isActive ? "active" : "idle"}>
|
||||
<Suspense fallback={<SftpViewFallback visible={isActive} />}>
|
||||
<LazySftpView {...props} />
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
// The inactive terminal layer remains painted at z=0. Place SFTP above
|
||||
// it, while the vault rail at z=20 keeps its resize handle interactive.
|
||||
style={{ left: railOffset, zIndex: railOffset > 0 ? 10 : undefined }}
|
||||
>
|
||||
<LazyLoadBoundary name="SFTP" resetKey={isActive ? "active" : "idle"}>
|
||||
<Suspense fallback={<SftpViewFallback visible={isActive} />}>
|
||||
<LazySftpView {...props} />
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@ export function AppSideEffects() {
|
||||
keyBindings,
|
||||
isHotkeyRecording,
|
||||
showSftpTab,
|
||||
sftpInSidebar,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
workspaceFocusStyle,
|
||||
} = settings;
|
||||
@@ -929,6 +930,8 @@ export function AppSideEffects() {
|
||||
workspacesRefForHotkeys.current = workspaces;
|
||||
const showSftpTabRef = useRef(showSftpTab);
|
||||
showSftpTabRef.current = showSftpTab;
|
||||
const sftpInSidebarRef = useRef(sftpInSidebar);
|
||||
sftpInSidebarRef.current = sftpInSidebar;
|
||||
const shellOnlyTabNumberShortcutsRef = useRef(shellOnlyTabNumberShortcuts);
|
||||
shellOnlyTabNumberShortcutsRef.current = shellOnlyTabNumberShortcuts;
|
||||
const isQuickSwitcherOpenRef = useRef(isQuickSwitcherOpen);
|
||||
@@ -1072,6 +1075,7 @@ export function AppSideEffects() {
|
||||
setNavigateToSection,
|
||||
settings: {
|
||||
showSftpTab: showSftpTabRef.current,
|
||||
sftpInSidebar: sftpInSidebarRef.current,
|
||||
shellOnlyTabNumberShortcuts: shellOnlyTabNumberShortcutsRef.current,
|
||||
},
|
||||
sftpPaneMagnificationRef,
|
||||
|
||||
@@ -291,6 +291,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
resolvedTheme,
|
||||
windowOpacity,
|
||||
showSftpTab,
|
||||
sftpInSidebar,
|
||||
showHostTreeSidebar,
|
||||
showRecentHosts,
|
||||
hostClickBehavior,
|
||||
@@ -530,7 +531,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onReorderTabs={reorderWorkTabs}
|
||||
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
||||
onAppendHostToWorkspace={handleAppendHostToWorkspace}
|
||||
showSftpTab={showSftpTab}
|
||||
showSftpTab={showSftpTab && !sftpInSidebar}
|
||||
showHostTreeSidebar={showHostTreeSidebar}
|
||||
switchTabKeyBinding={keyBindings.find((binding) => binding.action === 'switchToTab') ?? null}
|
||||
dynamicTabTitleMode={dynamicTabTitleMode}
|
||||
@@ -891,7 +892,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
results={quickResults}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
showSftpTab={showSftpTab}
|
||||
showSftpTab={showSftpTab || sftpInSidebar}
|
||||
onQueryChange={setQuickSearch}
|
||||
onSelect={handleHostConnectWithProtocolCheck}
|
||||
onEditHost={(host) => {
|
||||
|
||||
@@ -171,7 +171,10 @@ export function ChromeHost() {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
showSftpTab: settingsChrome.showSftpTab,
|
||||
// The SFTP-tab guard only redirects when the SFTP surface is truly
|
||||
// unreachable; sidebar placement (sftpInSidebar) keeps it reachable
|
||||
// even when the top tab flag is off.
|
||||
showSftpTab: settingsChrome.showSftpTab || settingsChrome.sftpInSidebar,
|
||||
setActiveTabId,
|
||||
applyAppTheme,
|
||||
hostById,
|
||||
@@ -194,6 +197,7 @@ export function ChromeHost() {
|
||||
sessionById,
|
||||
setActiveTabId,
|
||||
settingsChrome.showSftpTab,
|
||||
settingsChrome.sftpInSidebar,
|
||||
t,
|
||||
terminalSettings.followAppTerminalTheme,
|
||||
themeById,
|
||||
|
||||
@@ -4,6 +4,7 @@ export const enAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent Settings',
|
||||
'ai.chat.preparing': 'Preparing…',
|
||||
'ai.chat.modelCatalogWarning': 'Could not refresh the live model list. Showing the last available or built-in models.',
|
||||
'ai.chat.compactingContext': 'Compacting earlier context…',
|
||||
'ai.chat.compactingStep': 'Trimming context for next step…',
|
||||
'ai.chat.compactionRetry': 'Request was too large. Compacting context and retrying…',
|
||||
@@ -600,8 +601,12 @@ export const enAiMessages: Messages = {
|
||||
'terminal.layer.aiChat': 'AI Chat',
|
||||
'terminal.layer.movePanelLeft': 'Move panel to left',
|
||||
'terminal.layer.movePanelRight': 'Move panel to right',
|
||||
'terminal.layer.movePanelBottom': 'Move panel to bottom',
|
||||
'terminal.layer.closePanel': 'Close panel',
|
||||
'terminal.layer.closePane': 'Close split',
|
||||
'terminal.layer.saveLayoutAsDefault': 'Save layout as default',
|
||||
'terminal.layer.layoutSavedAsDefault': 'Layout saved as default',
|
||||
'terminal.layer.layoutSaveFailed': 'Could not save layout',
|
||||
'terminal.layer.resizeSplit': 'Resize split',
|
||||
'terminal.layer.splitHorizontal': 'Split top and bottom',
|
||||
'terminal.layer.splitVertical': 'Split left and right',
|
||||
|
||||
@@ -364,6 +364,8 @@ export const enCoreMessages: Messages = {
|
||||
'settings.appearance.tabBarPosition.bottom': 'Bottom',
|
||||
'settings.vault.showSftpTab': 'Show SFTP tab',
|
||||
'settings.vault.showSftpTabDesc': 'Display the standalone SFTP view in the top tab bar. When hidden, use the in-session SFTP side panel instead.',
|
||||
'settings.vault.sftpInSidebar': 'Keep SFTP in the sidebar',
|
||||
'settings.vault.sftpInSidebarDesc': 'Hide the top SFTP tab and show SFTP in the vault sidebar instead, so the sidebar stays visible while SFTP is open.',
|
||||
'settings.vault.showHostTreeSidebar': 'Show host list sidebar',
|
||||
'settings.vault.showHostTreeSidebarDesc': 'Display the host list sidebar and its top-bar toggle on terminal and editor tabs.',
|
||||
'settings.vault.autoImportSystemKnownHosts': 'Auto-import system known_hosts',
|
||||
@@ -1027,6 +1029,7 @@ Highlight the focused split pane:
|
||||
'vault.nav.snippets': 'Scripts',
|
||||
'vault.nav.notes': 'Notes',
|
||||
'vault.nav.knownHosts': 'Known Hosts',
|
||||
'vault.nav.sftp': 'SFTP',
|
||||
'vault.nav.logs': 'Logs',
|
||||
|
||||
'proxyProfiles.action.add': 'Add Proxy',
|
||||
|
||||
@@ -188,6 +188,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.auth.retryMessage': 'Authentication failed. Please check your credentials and try again.',
|
||||
'terminal.auth.retryLog': 'Authentication failed. Please try again.',
|
||||
'terminal.auth.noKeysHint': 'No keys available. Add keys in Keychain.',
|
||||
'terminal.auth.selectIdentity': 'Use saved identity',
|
||||
'terminal.auth.continueSave': 'Continue & Save',
|
||||
'terminal.auth.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.',
|
||||
|
||||
@@ -4,6 +4,7 @@ export const esAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Configuración del agente',
|
||||
'ai.chat.preparing': 'Preparando…',
|
||||
'ai.chat.modelCatalogWarning': 'No se pudo actualizar la lista de modelos. Se muestran los últimos modelos disponibles o los integrados.',
|
||||
'ai.chat.compactingContext': 'Compactando el contexto anterior…',
|
||||
'ai.chat.compactingStep': 'Recortando el contexto para el siguiente paso…',
|
||||
'ai.chat.compactionRetry': 'La petición era demasiado grande. Compactando el contexto y reintentando…',
|
||||
@@ -591,6 +592,7 @@ export const esAiMessages: Messages = {
|
||||
'terminal.layer.aiChat': 'Chat de IA',
|
||||
'terminal.layer.movePanelLeft': 'Mover panel a la izquierda',
|
||||
'terminal.layer.movePanelRight': 'Mover panel a la derecha',
|
||||
'terminal.layer.movePanelBottom': 'Mover panel a la parte inferior',
|
||||
'terminal.layer.closePanel': 'Cerrar panel',
|
||||
'terminal.layer.closePane': 'Cerrar división',
|
||||
'terminal.layer.resizeSplit': 'Redimensionar división',
|
||||
|
||||
@@ -279,6 +279,8 @@ export const esCoreMessages: Messages = {
|
||||
'settings.appearance.tabBarPosition.bottom': 'Abajo',
|
||||
'settings.vault.showSftpTab': 'Mostrar pestaña SFTP',
|
||||
'settings.vault.showSftpTabDesc': 'Muestra la vista SFTP independiente en la barra de pestañas superior. Cuando está oculta, usa el panel lateral SFTP dentro de la sesión.',
|
||||
'settings.vault.sftpInSidebar': 'Mantener SFTP en la barra lateral',
|
||||
'settings.vault.sftpInSidebarDesc': 'Oculta la pestaña SFTP superior y muestra SFTP en la barra lateral del depósito, de modo que la barra lateral siga visible mientras SFTP está abierto.',
|
||||
'settings.vault.showHostTreeSidebar': 'Mostrar barra lateral de lista de hosts',
|
||||
'settings.vault.showHostTreeSidebarDesc': 'Muestra la barra lateral de lista de hosts y su alternador en la barra superior en las pestañas de terminal y editor.',
|
||||
'settings.vault.autoImportSystemKnownHosts': 'Importar automáticamente known_hosts del sistema',
|
||||
@@ -985,6 +987,7 @@ Resalta el panel dividido enfocado:
|
||||
'vault.nav.snippets': 'Scripts',
|
||||
'vault.nav.notes': 'Notas',
|
||||
'vault.nav.knownHosts': 'Hosts conocidos',
|
||||
'vault.nav.sftp': 'SFTP',
|
||||
'vault.nav.logs': 'Registros',
|
||||
|
||||
'proxyProfiles.action.add': 'Agregar proxy',
|
||||
|
||||
@@ -188,6 +188,7 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.auth.retryMessage': 'Falló la autenticación. Verifica tus credenciales e inténtalo de nuevo.',
|
||||
'terminal.auth.retryLog': 'Falló la autenticación. Inténtalo de nuevo.',
|
||||
'terminal.auth.noKeysHint': 'No hay claves disponibles. Agrega claves en Keychain.',
|
||||
'terminal.auth.selectIdentity': 'Usar identidad guardada',
|
||||
'terminal.auth.continueSave': 'Continuar y guardar',
|
||||
'terminal.auth.credentialsUnavailable': 'Las credenciales guardadas no se pueden descifrar en este dispositivo. Vuelve a ingresarlas y guárdalas de nuevo.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'Un host de salto tiene credenciales guardadas que no se pueden descifrar en este dispositivo. Abre la configuración del host y vuelve a ingresarlas.',
|
||||
|
||||
@@ -5,6 +5,7 @@ export const ruAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Настройки агента',
|
||||
'ai.chat.preparing': 'Подготовка…',
|
||||
'ai.chat.modelCatalogWarning': 'Не удалось обновить список моделей. Показаны последние доступные или встроенные модели.',
|
||||
'ai.chat.contextCompacted': 'Контекст сжат',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
|
||||
@@ -559,6 +560,7 @@ export const ruAiMessages: Messages = {
|
||||
'terminal.layer.aiChat': 'AI-чат',
|
||||
'terminal.layer.movePanelLeft': 'Переместить панель влево',
|
||||
'terminal.layer.movePanelRight': 'Переместить панель вправо',
|
||||
'terminal.layer.movePanelBottom': 'Переместить панель вниз',
|
||||
'terminal.layer.closePanel': 'Закрыть панель',
|
||||
'terminal.layer.closePane': 'Закрыть область',
|
||||
'terminal.layer.resizeSplit': 'Изменить размер области',
|
||||
|
||||
@@ -348,6 +348,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.appearance.tabBarPosition.bottom': 'Снизу',
|
||||
'settings.vault.showSftpTab': 'Показывать вкладку SFTP',
|
||||
'settings.vault.showSftpTabDesc': 'Показывать отдельный SFTP-вид в верхней панели вкладок. Если скрыто, используйте боковую панель SFTP внутри сессии.',
|
||||
'settings.vault.sftpInSidebar': 'Держать SFTP в боковой панели',
|
||||
'settings.vault.sftpInSidebarDesc': 'Скрывает верхнюю вкладку SFTP и показывает SFTP в боковой панели хранилища, чтобы панель оставалась видимой, пока открыт SFTP.',
|
||||
'settings.vault.showHostTreeSidebar': 'Показывать боковую панель хостов',
|
||||
'settings.vault.showHostTreeSidebarDesc': 'Показывать список хостов и кнопку в верхней панели для вкладок терминала и редактора.',
|
||||
'settings.vault.autoImportSystemKnownHosts': 'Автоимпорт системного known_hosts',
|
||||
@@ -1010,6 +1012,7 @@ export const ruCoreMessages: Messages = {
|
||||
'vault.nav.snippets': 'Сниппеты',
|
||||
'vault.nav.notes': 'Заметки',
|
||||
'vault.nav.knownHosts': 'Известные хосты',
|
||||
'vault.nav.sftp': 'SFTP',
|
||||
'vault.nav.logs': 'Журналы',
|
||||
|
||||
'proxyProfiles.action.add': 'Добавить прокси',
|
||||
|
||||
@@ -209,6 +209,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.auth.retryMessage': 'Ошибка аутентификации. Проверьте учётные данные и повторите попытку.',
|
||||
'terminal.auth.retryLog': 'Ошибка аутентификации. Повторите попытку.',
|
||||
'terminal.auth.noKeysHint': 'Нет доступных ключей. Добавьте ключи в связке ключей.',
|
||||
'terminal.auth.selectIdentity': 'Использовать сохранённый профиль',
|
||||
'terminal.auth.continueSave': 'Продолжить и сохранить',
|
||||
'terminal.auth.credentialsUnavailable': 'Сохранённые учётные данные не могут быть расшифрованы на этом устройстве. Пожалуйста, введите и сохраните их заново.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'У jump-хоста сохранены учётные данные, которые нельзя расшифровать на этом устройстве. Откройте настройки хоста и введите их заново.',
|
||||
|
||||
@@ -4,6 +4,7 @@ export const zhCNAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 设置',
|
||||
'ai.chat.preparing': '准备中…',
|
||||
'ai.chat.modelCatalogWarning': '暂时无法更新实时模型列表,当前显示上次可用或内置的模型。',
|
||||
'ai.chat.compactingContext': '正在压缩较早的上下文…',
|
||||
'ai.chat.compactingStep': '正在为下一步整理上下文…',
|
||||
'ai.chat.compactionRetry': '请求过大,正在压缩上下文并重试…',
|
||||
@@ -600,8 +601,12 @@ export const zhCNAiMessages: Messages = {
|
||||
'terminal.layer.aiChat': 'AI 助手',
|
||||
'terminal.layer.movePanelLeft': '面板移至左侧',
|
||||
'terminal.layer.movePanelRight': '面板移至右侧',
|
||||
'terminal.layer.movePanelBottom': '面板移至底部',
|
||||
'terminal.layer.closePanel': '关闭面板',
|
||||
'terminal.layer.closePane': '关闭分屏',
|
||||
'terminal.layer.saveLayoutAsDefault': '将布局保存为默认',
|
||||
'terminal.layer.layoutSavedAsDefault': '布局已保存为默认',
|
||||
'terminal.layer.layoutSaveFailed': '布局保存失败',
|
||||
'terminal.layer.resizeSplit': '调整分屏大小',
|
||||
'terminal.layer.splitHorizontal': '上下分屏',
|
||||
'terminal.layer.splitVertical': '左右分屏',
|
||||
|
||||
@@ -368,6 +368,8 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.appearance.tabBarPosition.bottom': '底部',
|
||||
'settings.vault.showSftpTab': '显示 SFTP 标签页',
|
||||
'settings.vault.showSftpTabDesc': '在顶部标签栏显示独立的 SFTP 视图。关闭后可改用会话内左侧的 SFTP 侧栏。',
|
||||
'settings.vault.sftpInSidebar': '将 SFTP 放入侧边栏',
|
||||
'settings.vault.sftpInSidebarDesc': '隐藏顶部的 SFTP 标签页,改为在侧边栏显示 SFTP,打开 SFTP 时侧边栏保持可见,可直接切换到其他功能页面。',
|
||||
'settings.vault.showHostTreeSidebar': '显示主机列表侧栏',
|
||||
'settings.vault.showHostTreeSidebarDesc': '在终端和编辑器标签页显示主机列表侧栏及顶部开关。',
|
||||
'settings.vault.autoImportSystemKnownHosts': '自动导入系统 known_hosts',
|
||||
@@ -651,6 +653,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.nav.snippets': '脚本',
|
||||
'vault.nav.notes': '笔记',
|
||||
'vault.nav.knownHosts': '已知主机',
|
||||
'vault.nav.sftp': 'SFTP',
|
||||
'vault.nav.logs': '日志',
|
||||
|
||||
'proxyProfiles.action.add': '添加代理',
|
||||
|
||||
@@ -579,6 +579,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.auth.retryMessage': '身份验证失败。请检查凭据后重试。',
|
||||
'terminal.auth.retryLog': '身份验证失败,请重试。',
|
||||
'terminal.auth.noKeysHint': '暂无密钥,请先在钥匙串中添加。',
|
||||
'terminal.auth.selectIdentity': '使用已保存的身份',
|
||||
'terminal.auth.continueSave': '继续并保存',
|
||||
'terminal.auth.credentialsUnavailable': '当前设备无法解密已保存凭据,请重新输入并再次保存。',
|
||||
'terminal.auth.jumpCredentialsUnavailable': '某个跳板机的已保存凭据无法在当前设备解密,请到主机设置中重新填写。',
|
||||
|
||||
@@ -4,6 +4,7 @@ export const zhTWAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 設定',
|
||||
'ai.chat.preparing': '準備中…',
|
||||
'ai.chat.modelCatalogWarning': '暫時無法更新即時模型清單,目前顯示上次可用或內建的模型。',
|
||||
'ai.chat.compactingContext': '正在壓縮較早的上下文…',
|
||||
'ai.chat.compactingStep': '正在為下一步整理上下文…',
|
||||
'ai.chat.compactionRetry': '請求過大,正在壓縮上下文並重試…',
|
||||
@@ -590,8 +591,12 @@ export const zhTWAiMessages: Messages = {
|
||||
'terminal.layer.aiChat': 'AI 助手',
|
||||
'terminal.layer.movePanelLeft': '面板移至左側',
|
||||
'terminal.layer.movePanelRight': '面板移至右側',
|
||||
'terminal.layer.movePanelBottom': '面板移至底部',
|
||||
'terminal.layer.closePanel': '關閉面板',
|
||||
'terminal.layer.closePane': '關閉分割',
|
||||
'terminal.layer.saveLayoutAsDefault': '將佈局儲存為預設',
|
||||
'terminal.layer.layoutSavedAsDefault': '佈局已儲存為預設',
|
||||
'terminal.layer.layoutSaveFailed': '佈局儲存失敗',
|
||||
'terminal.layer.resizeSplit': '調整分割大小',
|
||||
'terminal.layer.splitHorizontal': '上下分割',
|
||||
'terminal.layer.splitVertical': '左右分割',
|
||||
|
||||
@@ -370,6 +370,8 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.appearance.tabBarPosition.bottom': '底部',
|
||||
'settings.vault.showSftpTab': '顯示 SFTP 分頁',
|
||||
'settings.vault.showSftpTabDesc': '在頂部分頁列顯示獨立的 SFTP 檢視。關閉後可改用工作階段內左側的 SFTP 側欄。',
|
||||
'settings.vault.sftpInSidebar': '將 SFTP 放入側邊欄',
|
||||
'settings.vault.sftpInSidebarDesc': '隱藏頂部的 SFTP 分頁,改為在側邊欄顯示 SFTP,開啟 SFTP 時側邊欄保持可見,可直接切換到其他功能頁面。',
|
||||
'settings.vault.showHostTreeSidebar': '顯示主機列表側欄',
|
||||
'settings.vault.showHostTreeSidebarDesc': '在終端和編輯器分頁顯示主機列表側欄及頂部開關。',
|
||||
'settings.vault.autoImportSystemKnownHosts': '自動匯入系統 known_hosts',
|
||||
@@ -653,6 +655,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.nav.snippets': '程式碼片段',
|
||||
'vault.nav.notes': '筆記',
|
||||
'vault.nav.knownHosts': '已知主機',
|
||||
'vault.nav.sftp': 'SFTP',
|
||||
'vault.nav.logs': '日誌',
|
||||
|
||||
'proxyProfiles.action.add': '新增代理',
|
||||
|
||||
@@ -579,6 +579,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.auth.retryMessage': '驗證失敗。請檢查憑證後再試一次。',
|
||||
'terminal.auth.retryLog': '驗證失敗,請再試一次。',
|
||||
'terminal.auth.noKeysHint': '暫無金鑰,請先在鑰匙串中新增。',
|
||||
'terminal.auth.selectIdentity': '使用已儲存的身分',
|
||||
'terminal.auth.continueSave': '繼續並儲存',
|
||||
'terminal.auth.credentialsUnavailable': '目前裝置無法解密已儲存憑證,請重新輸入並再次儲存。',
|
||||
'terminal.auth.jumpCredentialsUnavailable': '某個跳板機的已儲存憑證無法在目前裝置解密,請到主機設定中重新填寫。',
|
||||
|
||||
@@ -25,6 +25,7 @@ export type SettingsChromeSnapshot = {
|
||||
windowOpacity: number;
|
||||
tabBarPosition: 'top' | 'bottom';
|
||||
showSftpTab: boolean;
|
||||
sftpInSidebar: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
showRecentHosts: boolean;
|
||||
hostClickBehavior: HostClickBehavior;
|
||||
@@ -60,6 +61,7 @@ export const DEFAULT_SETTINGS_CHROME_SNAPSHOT: SettingsChromeSnapshot = Object.f
|
||||
windowOpacity: 1,
|
||||
tabBarPosition: 'top',
|
||||
showSftpTab: true,
|
||||
sftpInSidebar: false,
|
||||
showHostTreeSidebar: true,
|
||||
showRecentHosts: true,
|
||||
hostClickBehavior: 'connect',
|
||||
@@ -88,6 +90,7 @@ export function settingsChromeSnapshotsEqual(
|
||||
&& a.windowOpacity === b.windowOpacity
|
||||
&& a.tabBarPosition === b.tabBarPosition
|
||||
&& a.showSftpTab === b.showSftpTab
|
||||
&& a.sftpInSidebar === b.sftpInSidebar
|
||||
&& a.showHostTreeSidebar === b.showHostTreeSidebar
|
||||
&& a.showRecentHosts === b.showRecentHosts
|
||||
&& a.hostClickBehavior === b.hostClickBehavior
|
||||
|
||||
@@ -80,6 +80,7 @@ export { DEFAULT_HOST_CLICK_BEHAVIOR } from '../../domain/hostClickBehavior';
|
||||
export type { HostClickBehavior } from '../../domain/hostClickBehavior';
|
||||
export const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
|
||||
export const DEFAULT_SHOW_SFTP_TAB = true;
|
||||
export const DEFAULT_SFTP_IN_SIDEBAR = false;
|
||||
export const DEFAULT_SHOW_HOST_TREE_SIDEBAR = true;
|
||||
export const DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = false;
|
||||
export const DEFAULT_SHOW_TAB_NUMBER_BADGES = true;
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SFTP_IN_SIDEBAR,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
@@ -96,6 +97,7 @@ interface UseSettingsStorageSyncParams {
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
tabBarPosition: TabBarPosition;
|
||||
showSftpTab: boolean;
|
||||
sftpInSidebar: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
terminalSidePanelAutoOpen: boolean;
|
||||
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
|
||||
@@ -148,6 +150,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
|
||||
setTabBarPositionState: Dispatch<SetStateAction<TabBarPosition>>;
|
||||
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpInSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
|
||||
@@ -186,7 +189,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, sftpInSidebar, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
|
||||
@@ -195,7 +198,7 @@ export function useSettingsStorageSync({
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setTabBarPositionState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setTabBarPositionState, setShowSftpTabState, setSftpInSidebarState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
@@ -210,7 +213,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, sftpInSidebar, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
});
|
||||
@@ -220,7 +223,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, sftpInSidebar, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
};
|
||||
@@ -450,6 +453,12 @@ export function useSettingsStorageSync({
|
||||
setShowSftpTabState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SFTP_IN_SIDEBAR && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.sftpInSidebar) {
|
||||
setSftpInSidebarState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showHostTreeSidebar) {
|
||||
@@ -601,7 +610,7 @@ export function useSettingsStorageSync({
|
||||
setLocalShellSidePanelAutoOpenState,
|
||||
setLocalShellSidePanelAutoOpenTabState,
|
||||
setShowRecentHostsState,
|
||||
setTabBarPositionState, setShowSftpTabState,
|
||||
setTabBarPositionState, setShowSftpTabState, setSftpInSidebarState,
|
||||
setShellOnlyTabNumberShortcutsState,
|
||||
setShowTabNumberBadgesState,
|
||||
setDisableTerminalFontZoomState,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { resolveDownloadSourceSnapshot } from "./downloadSourceSnapshot.ts";
|
||||
|
||||
const stat = (overrides: Partial<SftpStatResult> = {}): SftpStatResult => ({
|
||||
name: "f",
|
||||
size: 50_000,
|
||||
type: "file",
|
||||
lastModified: 0,
|
||||
sizeKnown: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
for (const listedSize of [25_000, 100_000]) {
|
||||
test(`recreated file uses the current 50k size rather than listed ${listedSize}`, async () => {
|
||||
const snapshot = await resolveDownloadSourceSnapshot(
|
||||
async (_id, path, encoding) => {
|
||||
assert.equal(path, "/tmp/f");
|
||||
assert.equal(encoding, "auto");
|
||||
return stat();
|
||||
},
|
||||
"sftp-1", "/tmp/f", "auto",
|
||||
);
|
||||
assert.deepEqual(snapshot, { size: 50_000, isDirectory: false });
|
||||
assert.notEqual(snapshot.size, listedSize);
|
||||
});
|
||||
}
|
||||
|
||||
test("a stale file entry replaced by a directory uses directory routing", async () => {
|
||||
const snapshot = await resolveDownloadSourceSnapshot(
|
||||
async () => stat({ size: 0, type: "directory" }),
|
||||
"sftp-1", "/tmp/x", "auto",
|
||||
);
|
||||
assert.deepEqual(snapshot, { size: 0, isDirectory: true });
|
||||
});
|
||||
|
||||
test("a stale directory entry replaced by a file uses file routing", async () => {
|
||||
const snapshot = await resolveDownloadSourceSnapshot(
|
||||
async () => stat({ size: 12, type: "file" }),
|
||||
"sftp-1", "/tmp/x", "auto",
|
||||
);
|
||||
assert.deepEqual(snapshot, { size: 12, isDirectory: false });
|
||||
});
|
||||
|
||||
test("missing, failed and unsupported stats reject instead of using a stale listing", async () => {
|
||||
const rejected = [
|
||||
undefined,
|
||||
async () => null as unknown as SftpStatResult,
|
||||
async () => { throw new Error("File not found"); },
|
||||
async () => stat({ type: "symlink" }),
|
||||
async () => stat({ size: Number.NaN }),
|
||||
async () => stat({ size: -1 }),
|
||||
];
|
||||
for (const candidate of rejected) {
|
||||
await assert.rejects(
|
||||
resolveDownloadSourceSnapshot(candidate, "sftp-1", "/tmp/f", "auto"),
|
||||
/Cannot verify the current remote source before download/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("stat-less SCP file leaves sizing to the transfer instead of using listed bytes", async () => {
|
||||
const snapshot = await resolveDownloadSourceSnapshot(
|
||||
async () => stat({ size: 0, sizeKnown: false }),
|
||||
"scp-1", "/tmp/f", "auto",
|
||||
);
|
||||
assert.deepEqual(snapshot, { size: undefined, isDirectory: false });
|
||||
});
|
||||
|
||||
test("SCP links retain their resolved file or directory routing without using link size", async () => {
|
||||
for (const target of ["file", "directory"] as const) {
|
||||
const snapshot = await resolveDownloadSourceSnapshot(
|
||||
async () => stat({ type: "symlink", size: 8 }),
|
||||
"scp-1", "/tmp/link", "auto",
|
||||
async (_id, parent, encoding) => {
|
||||
assert.equal(parent, "/tmp");
|
||||
assert.equal(encoding, "auto");
|
||||
return [{ name: "link", type: "symlink", linkTarget: target, size: "8 bytes", lastModified: "" }];
|
||||
},
|
||||
);
|
||||
assert.deepEqual(snapshot, { size: undefined, isDirectory: target === "directory" });
|
||||
}
|
||||
});
|
||||
|
||||
test("SCP link whose current target cannot be confirmed stops safely", async () => {
|
||||
for (const entries of [
|
||||
[],
|
||||
[{ name: "link", type: "symlink" as const, linkTarget: null, size: "8 bytes", lastModified: "" }],
|
||||
[{ name: "link", type: "file" as const, size: "8 bytes", lastModified: "" }],
|
||||
]) {
|
||||
await assert.rejects(
|
||||
resolveDownloadSourceSnapshot(
|
||||
async () => stat({ type: "symlink" }),
|
||||
"scp-1", "/tmp/link", "auto", async () => entries,
|
||||
),
|
||||
/Cannot verify the current remote source before download/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("directory routing does not require a file byte count", async () => {
|
||||
const snapshot = await resolveDownloadSourceSnapshot(
|
||||
async () => stat({ size: Number.NaN, sizeKnown: false, type: "directory" }),
|
||||
"sftp-1", "/tmp/d", "auto",
|
||||
);
|
||||
assert.deepEqual(snapshot, { size: 0, isDirectory: true });
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { SftpFilenameEncoding } from "../../../domain/models/sftp";
|
||||
import type { RemoteFile } from "../../../domain/models/workspace";
|
||||
import { getFileName, getParentPath } from "./utils";
|
||||
|
||||
export type DownloadSourceSnapshot = {
|
||||
/** Unknown for stat-less SCP files and links; the transfer reads its wire size. */
|
||||
size: number | undefined;
|
||||
isDirectory: boolean;
|
||||
};
|
||||
|
||||
type StatSftp = (
|
||||
sftpId: string,
|
||||
path: string,
|
||||
encoding?: SftpFilenameEncoding,
|
||||
) => Promise<SftpStatResult>;
|
||||
type ListSftp = (
|
||||
sftpId: string,
|
||||
path: string,
|
||||
encoding?: SftpFilenameEncoding,
|
||||
) => Promise<RemoteFile[]>;
|
||||
|
||||
const sourceUnavailable = () => new Error("Cannot verify the current remote source before download");
|
||||
|
||||
/** A stale listing must never supply the size or type of a new download. */
|
||||
export const resolveDownloadSourceSnapshot = async (
|
||||
statSftp: StatSftp | undefined,
|
||||
sftpId: string,
|
||||
sourcePath: string,
|
||||
encoding: SftpFilenameEncoding | undefined,
|
||||
listSftp?: ListSftp,
|
||||
): Promise<DownloadSourceSnapshot> => {
|
||||
if (!statSftp) throw sourceUnavailable();
|
||||
|
||||
let stat: SftpStatResult | null;
|
||||
try {
|
||||
stat = await statSftp(sftpId, sourcePath, encoding);
|
||||
} catch (cause) {
|
||||
throw new Error("Cannot verify the current remote source before download", { cause });
|
||||
}
|
||||
|
||||
if (!stat) {
|
||||
throw sourceUnavailable();
|
||||
}
|
||||
if (stat.type === "directory") return { size: 0, isDirectory: true };
|
||||
if (stat.type === "symlink") {
|
||||
// SFTP STAT follows links; legacy SCP describes the link node. Re-list its
|
||||
// parent to resolve the current target kind rather than trusting the pane.
|
||||
if (!listSftp) throw sourceUnavailable();
|
||||
let entries: RemoteFile[];
|
||||
try {
|
||||
entries = await listSftp(sftpId, getParentPath(sourcePath), encoding);
|
||||
} catch (cause) {
|
||||
throw new Error("Cannot verify the current remote source before download", { cause });
|
||||
}
|
||||
const current = entries.find((entry) => entry.name === getFileName(sourcePath));
|
||||
if (current?.type !== "symlink"
|
||||
|| (current.linkTarget !== "file" && current.linkTarget !== "directory")) {
|
||||
throw sourceUnavailable();
|
||||
}
|
||||
// The link's own size is never a valid file plan; SCP's wire header is.
|
||||
return { size: undefined, isDirectory: current.linkTarget === "directory" };
|
||||
}
|
||||
if (stat.type !== "file") throw sourceUnavailable();
|
||||
if (stat.sizeKnown === false) return { size: undefined, isDirectory: false };
|
||||
if (!Number.isSafeInteger(stat.size) || stat.size < 0) throw sourceUnavailable();
|
||||
return { size: stat.size, isDirectory: false };
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
buildSftpHostCredentials,
|
||||
} from "./useSftpHostCredentials.ts";
|
||||
import type { Host, Identity, KnownHost, SSHKey } from "../../../domain/models.ts";
|
||||
import { resolvePasswordAuthSftpHost } from "../../../domain/authIdentityPicker.ts";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
@@ -16,6 +17,22 @@ const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("SFTP credentials follow the temporary deploy identity on a root profile", () => {
|
||||
const identities = [
|
||||
{ id: "root-id", label: "root", username: "root", authMethod: "password", password: "bad-root", created: 0 },
|
||||
{ id: "deploy-id", label: "deploy", username: "deploy", authMethod: "password", password: "good-deploy", created: 0 },
|
||||
] as Identity[];
|
||||
const savedHost = host({ identityId: "root-id" });
|
||||
const sftpHost = resolvePasswordAuthSftpHost(savedHost, identities, {
|
||||
authMethod: "password", username: "deploy", password: "good-deploy", savedToHost: false,
|
||||
});
|
||||
const credentials = buildSftpHostCredentials({ host: sftpHost, hosts: [savedHost], keys: [], identities });
|
||||
assert.equal(credentials.username, "deploy");
|
||||
assert.equal(credentials.password, "good-deploy");
|
||||
assert.equal(credentials.hostId, savedHost.id);
|
||||
assert.equal(savedHost.identityId, "root-id");
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials forwards system agent settings for target and jump hosts", () => {
|
||||
const jump = host({
|
||||
id: "jump-1",
|
||||
|
||||
@@ -2,10 +2,15 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
clampTerminalSidePanelHeight,
|
||||
clampTerminalSidePanelWidth,
|
||||
getTerminalSidePanelAvailableWidth,
|
||||
getTerminalSidePanelMaxShownTools,
|
||||
getTerminalSidePanelMaxWidth,
|
||||
getTerminalSidePanelAvailableHeight,
|
||||
getTerminalSidePanelMaxHeight,
|
||||
TERMINAL_SIDE_PANEL_MAX_HEIGHT,
|
||||
TERMINAL_SIDE_PANEL_MIN_HEIGHT,
|
||||
TERMINAL_SIDE_PANEL_MAX_WIDTH,
|
||||
TERMINAL_SIDE_PANEL_MIN_WIDTH,
|
||||
} from './terminalSidePanelWidth.ts';
|
||||
@@ -34,3 +39,17 @@ test('terminal side panel keeps usable terminal space in smaller windows', () =>
|
||||
assert.equal(clampTerminalSidePanelWidth(280, 1000, 401), 401);
|
||||
assert.equal(clampTerminalSidePanelWidth(280, 600, 401), TERMINAL_SIDE_PANEL_MIN_WIDTH);
|
||||
});
|
||||
|
||||
test('terminal side panel bottom dock clamps height against surface height', () => {
|
||||
assert.equal(getTerminalSidePanelAvailableHeight(1000, 200), 800);
|
||||
assert.equal(getTerminalSidePanelMaxHeight(1000), 760);
|
||||
assert.equal(clampTerminalSidePanelHeight(900, 1000), 760);
|
||||
assert.equal(clampTerminalSidePanelHeight(100, 1000), TERMINAL_SIDE_PANEL_MIN_HEIGHT);
|
||||
assert.equal(clampTerminalSidePanelHeight(300, 1000, 500), 500);
|
||||
assert.equal(clampTerminalSidePanelHeight(5000, 4000), TERMINAL_SIDE_PANEL_MAX_HEIGHT);
|
||||
// A tall compose bar in the smallest window leaves less than the nominal
|
||||
// panel minimum; the terminal's 240px reserve must still win.
|
||||
assert.equal(getTerminalSidePanelMaxHeight(244), 4);
|
||||
assert.equal(clampTerminalSidePanelHeight(560, 244), 4);
|
||||
assert.equal(clampTerminalSidePanelHeight(560, 220), 0);
|
||||
});
|
||||
@@ -41,3 +41,37 @@ export function getTerminalSidePanelMaxShownTools(panelWidth: number): number {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export const TERMINAL_SIDE_PANEL_MIN_HEIGHT = 200;
|
||||
export const TERMINAL_SIDE_PANEL_MAX_HEIGHT = 1200;
|
||||
export const TERMINAL_SIDE_PANEL_MIN_TERMINAL_HEIGHT = 240;
|
||||
// Shared tab toolbar rendered above the pane tree (`h-9`).
|
||||
export const TERMINAL_SIDE_PANEL_TOOLBAR_HEIGHT = 36;
|
||||
|
||||
export function getTerminalSidePanelAvailableHeight(
|
||||
terminalLayerHeight: number,
|
||||
occupiedSiblingHeight: number,
|
||||
): number {
|
||||
return Math.max(0, terminalLayerHeight - occupiedSiblingHeight);
|
||||
}
|
||||
|
||||
export function getTerminalSidePanelMaxHeight(availableSurfaceHeight: number): number {
|
||||
const availableHeight = availableSurfaceHeight - TERMINAL_SIDE_PANEL_MIN_TERMINAL_HEIGHT;
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(TERMINAL_SIDE_PANEL_MAX_HEIGHT, availableHeight),
|
||||
);
|
||||
}
|
||||
|
||||
export function clampTerminalSidePanelHeight(
|
||||
height: number,
|
||||
availableSurfaceHeight: number,
|
||||
contentMinimumHeight = TERMINAL_SIDE_PANEL_MIN_HEIGHT,
|
||||
): number {
|
||||
const maximumHeight = getTerminalSidePanelMaxHeight(availableSurfaceHeight);
|
||||
const minimumHeight = Math.min(
|
||||
maximumHeight,
|
||||
Math.max(TERMINAL_SIDE_PANEL_MIN_HEIGHT, contentMinimumHeight),
|
||||
);
|
||||
return Math.max(minimumHeight, Math.min(maximumHeight, height));
|
||||
}
|
||||
@@ -79,6 +79,7 @@ import {
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SFTP_IN_SIDEBAR,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
@@ -131,6 +132,7 @@ import {
|
||||
DEFAULT_SHOW_RECENT_HOSTS,
|
||||
DEFAULT_HOST_CLICK_BEHAVIOR,
|
||||
DEFAULT_SHOW_SFTP_TAB,
|
||||
DEFAULT_SFTP_IN_SIDEBAR,
|
||||
DEFAULT_SHOW_HOST_TREE_SIDEBAR,
|
||||
DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
DEFAULT_SHOW_TAB_NUMBER_BADGES,
|
||||
@@ -424,6 +426,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
return stored ?? DEFAULT_SHOW_SFTP_TAB;
|
||||
});
|
||||
const [sftpInSidebar, setSftpInSidebarState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_IN_SIDEBAR);
|
||||
return stored ?? DEFAULT_SFTP_IN_SIDEBAR;
|
||||
});
|
||||
const [showHostTreeSidebar, setShowHostTreeSidebarState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
return stored ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR;
|
||||
@@ -1030,6 +1036,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setTabBarPositionState(normalizeTabBarPosition(readStoredString(STORAGE_KEY_TAB_BAR_POSITION)));
|
||||
const storedShowSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
|
||||
const storedSftpInSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_IN_SIDEBAR);
|
||||
setSftpInSidebarState(storedSftpInSidebar ?? DEFAULT_SFTP_IN_SIDEBAR);
|
||||
const storedShowHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
setShowHostTreeSidebarState(storedShowHostTreeSidebar ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR);
|
||||
const storedTerminalSidePanelAutoOpen = localStorageAdapter.readBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN);
|
||||
@@ -1281,7 +1289,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, sftpInSidebar, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
|
||||
@@ -1291,7 +1299,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setTabBarPositionState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setTabBarPositionState, setShowSftpTabState, setSftpInSidebarState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
@@ -1436,6 +1444,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setSftpInSidebar = useCallback((enabled: boolean) => {
|
||||
setSftpInSidebarState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SFTP_IN_SIDEBAR, enabled);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_IN_SIDEBAR, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShowHostTreeSidebar = useCallback((enabled: boolean) => {
|
||||
setShowHostTreeSidebarState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, enabled);
|
||||
@@ -1917,6 +1932,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
windowOpacity,
|
||||
tabBarPosition,
|
||||
showSftpTab,
|
||||
sftpInSidebar,
|
||||
showHostTreeSidebar,
|
||||
showRecentHosts,
|
||||
hostClickBehavior,
|
||||
@@ -1946,6 +1962,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
showRecentHosts,
|
||||
tabBarPosition,
|
||||
showSftpTab,
|
||||
sftpInSidebar,
|
||||
showTabNumberBadges,
|
||||
terminalSettings.dynamicTabTitleMode,
|
||||
terminalSidePanelAutoOpen,
|
||||
@@ -2123,6 +2140,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
showSftpTab,
|
||||
setTabBarPosition,
|
||||
setShowSftpTab,
|
||||
sftpInSidebar,
|
||||
setSftpInSidebar,
|
||||
showHostTreeSidebar,
|
||||
setShowHostTreeSidebar,
|
||||
terminalSidePanelAutoOpen,
|
||||
@@ -2201,7 +2220,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, sftpInSidebar, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -186,6 +186,7 @@ const migrateKey = (key: Partial<SSHKey>): SSHKey => {
|
||||
id,
|
||||
label,
|
||||
type: key.type || "ED25519",
|
||||
keySize: key.keySize,
|
||||
privateKey: key.privateKey || "",
|
||||
publicKey: key.publicKey,
|
||||
certificate: key.certificate,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
|
||||
import {
|
||||
collectSidePanelPanes,
|
||||
createSidePanelLayout,
|
||||
splitSidePanelPane,
|
||||
type SidePanelLayout,
|
||||
} from '../../domain/sidePanelLayout.ts';
|
||||
import { STORAGE_KEY_WORKSPACE_LAYOUT_PRESET } from '../../infrastructure/config/storageKeys.ts';
|
||||
import { useWorkspaceLayoutPresetState } from './useWorkspaceLayoutPresetState.ts';
|
||||
|
||||
function buildSplitLayout(): SidePanelLayout {
|
||||
let layout = createSidePanelLayout('scripts', 'pane-scripts');
|
||||
layout = splitSidePanelPane(layout, 'pane-scripts', 'sftp', 'horizontal', {
|
||||
paneId: 'pane-sftp',
|
||||
splitId: 'split-root',
|
||||
}, 400);
|
||||
return layout;
|
||||
}
|
||||
|
||||
test('saved default resolves per session with fresh ids and protocol-aware pruning', async () => {
|
||||
const values = new Map<string, string>();
|
||||
const fakeStorage = {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { values.set(key, value); },
|
||||
removeItem: (key: string) => { values.delete(key); },
|
||||
};
|
||||
const globals = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
localStorage?: typeof fakeStorage;
|
||||
};
|
||||
const previousActEnvironment = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: fakeStorage,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
let state: ReturnType<typeof useWorkspaceLayoutPresetState> | null = null;
|
||||
const Probe = () => {
|
||||
state = useWorkspaceLayoutPresetState();
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
create(React.createElement(Probe));
|
||||
});
|
||||
assert.ok(state);
|
||||
|
||||
// Nothing saved yet: no preset to apply.
|
||||
assert.equal(state.resolveDefaultLayoutForSession({ protocol: 'ssh' }), null);
|
||||
|
||||
let saved = false;
|
||||
await act(async () => {
|
||||
saved = state?.saveWorkspaceLayoutAsDefault(buildSplitLayout()) ?? false;
|
||||
});
|
||||
assert.equal(saved, true);
|
||||
assert.ok(values.has(STORAGE_KEY_WORKSPACE_LAYOUT_PRESET));
|
||||
|
||||
// SSH sessions keep their SFTP pane; ids are rekeyed per application.
|
||||
const sshResolved = state.resolveDefaultLayoutForSession({ protocol: 'ssh' });
|
||||
assert.ok(sshResolved);
|
||||
assert.equal(sshResolved.protocol, 'ssh');
|
||||
assert.deepEqual(
|
||||
collectSidePanelPanes(sshResolved.layout.root).map((pane) => pane.tool),
|
||||
['scripts', 'sftp'],
|
||||
);
|
||||
assert.notEqual(sshResolved.layout.root.id, buildSplitLayout().root.id);
|
||||
assert.equal(sshResolved.focusedTool, 'sftp');
|
||||
|
||||
// Protocols that cannot serve SFTP lose that pane.
|
||||
const telnetResolved = state.resolveDefaultLayoutForSession({ protocol: 'telnet' });
|
||||
assert.ok(telnetResolved);
|
||||
assert.deepEqual(
|
||||
collectSidePanelPanes(telnetResolved.layout.root).map((pane) => pane.tool),
|
||||
['scripts'],
|
||||
);
|
||||
assert.equal(telnetResolved.focusedTool, 'scripts');
|
||||
|
||||
// A second resolve rekeys again so parallel workspaces share no node ids.
|
||||
const again = state.resolveDefaultLayoutForSession({ protocol: 'ssh' });
|
||||
assert.ok(again);
|
||||
assert.notEqual(again.layout.root.id, sshResolved.layout.root.id);
|
||||
} finally {
|
||||
if (previousLocalStorage) {
|
||||
Object.defineProperty(globalThis, 'localStorage', previousLocalStorage);
|
||||
} else {
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
}
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
|
||||
test('saving falls back to false when persistence rejects the write', async () => {
|
||||
const fakeStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
const quotaError = new DOMException('full', 'QuotaExceededError');
|
||||
throw quotaError;
|
||||
},
|
||||
removeItem: () => undefined,
|
||||
};
|
||||
const globals = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
localStorage?: typeof fakeStorage;
|
||||
};
|
||||
const previousActEnvironment = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: fakeStorage,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
let state: ReturnType<typeof useWorkspaceLayoutPresetState> | null = null;
|
||||
const Probe = () => {
|
||||
state = useWorkspaceLayoutPresetState();
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
create(React.createElement(Probe));
|
||||
});
|
||||
assert.ok(state);
|
||||
|
||||
let saved = true;
|
||||
await act(async () => {
|
||||
saved = state?.saveWorkspaceLayoutAsDefault(buildSplitLayout()) ?? true;
|
||||
});
|
||||
assert.equal(saved, false);
|
||||
} finally {
|
||||
if (previousLocalStorage) {
|
||||
Object.defineProperty(globalThis, 'localStorage', previousLocalStorage);
|
||||
} else {
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
}
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import {
|
||||
getFocusedSidePanelPane,
|
||||
type SidePanelLayout,
|
||||
type SidePanelTool,
|
||||
} from '../../domain/sidePanelLayout';
|
||||
import {
|
||||
createWorkspaceLayoutPreset,
|
||||
rekeySidePanelLayout,
|
||||
withoutUnavailableSidePanelTools,
|
||||
} from '../../domain/workspaceLayoutPreset';
|
||||
import {
|
||||
readWorkspaceLayoutPreset,
|
||||
writeWorkspaceLayoutPreset,
|
||||
} from './workspaceLayoutPresetStore';
|
||||
|
||||
/** Protocols whose sessions can actually serve an SFTP pane. */
|
||||
function canServeSftp(protocol: string): boolean {
|
||||
return protocol === 'local' || protocol === 'ssh' || protocol === 'mosh';
|
||||
}
|
||||
|
||||
export type ResolvedWorkspaceLayoutPreset = {
|
||||
layout: SidePanelLayout;
|
||||
focusedTool: SidePanelTool;
|
||||
/** Connection protocol used for availability filtering (`session.protocol ?? 'ssh'`). */
|
||||
protocol: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Application boundary for the saved default workspace side panel layout.
|
||||
* Owns the preset persistence lifecycle (read, validate, write) so UI
|
||||
* components consume handlers instead of touching the store directly.
|
||||
*/
|
||||
export function useWorkspaceLayoutPresetState() {
|
||||
/**
|
||||
* Resolve the saved default preset into a fresh layout for a newly
|
||||
* connected session. Panes the session cannot serve (SFTP on non-file
|
||||
* protocols) are pruned, and ids are rekeyed so several workspaces can
|
||||
* run the same preset in parallel. Returns null when there is no preset
|
||||
* or nothing survives availability filtering.
|
||||
*/
|
||||
const resolveDefaultLayoutForSession = useCallback(
|
||||
(session: { protocol?: string | null }): ResolvedWorkspaceLayoutPreset | null => {
|
||||
const preset = readWorkspaceLayoutPreset();
|
||||
if (!preset) return null;
|
||||
|
||||
const proto = session.protocol ?? 'ssh';
|
||||
const usable = withoutUnavailableSidePanelTools(
|
||||
preset.layout,
|
||||
(tool) => tool !== 'sftp' || canServeSftp(proto),
|
||||
);
|
||||
if (!usable) return null;
|
||||
|
||||
const layout = rekeySidePanelLayout(usable, () => crypto.randomUUID());
|
||||
return { layout, focusedTool: getFocusedSidePanelPane(layout).tool, protocol: proto };
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/** Validate and persist a live layout as the default. False when invalid or the write failed. */
|
||||
const saveWorkspaceLayoutAsDefault = useCallback((layout: SidePanelLayout): boolean => {
|
||||
const preset = createWorkspaceLayoutPreset(layout);
|
||||
if (!preset) return false;
|
||||
return writeWorkspaceLayoutPreset(preset);
|
||||
}, []);
|
||||
|
||||
return { resolveDefaultLayoutForSession, saveWorkspaceLayoutAsDefault };
|
||||
}
|
||||
@@ -48,6 +48,51 @@ test("MobaXterm import keeps valid UTF-8 labels when GB18030 would produce Chine
|
||||
assert.equal(result.hosts[0]?.label, "¡prod");
|
||||
});
|
||||
|
||||
test("CSV import decodes legacy GB18030 Chinese group names", async () => {
|
||||
const header = new TextEncoder().encode("Groups,Label,Hostname/IP,Port,Username\n");
|
||||
const encodedGroup = new Uint8Array([
|
||||
0xd6, 0xd0, 0xce, 0xc4, 0xd7, 0xe9, // 中文组 in GB18030
|
||||
]);
|
||||
const row = new TextEncoder().encode(",web,192.168.1.10,22,root\n");
|
||||
const bytes = new Uint8Array(header.length + encodedGroup.length + row.length);
|
||||
bytes.set(header);
|
||||
bytes.set(encodedGroup, header.length);
|
||||
bytes.set(row, header.length + encodedGroup.length);
|
||||
|
||||
const text = await readVaultImportFile(
|
||||
"csv",
|
||||
new File([bytes], "hosts.csv", { type: "text/csv" }),
|
||||
);
|
||||
const result = importVaultHostsFromText("csv", text);
|
||||
|
||||
assert.equal(result.hosts[0]?.group, "中文组");
|
||||
});
|
||||
|
||||
test("CSV import keeps valid UTF-8 Chinese group names", async () => {
|
||||
const text = "Groups,Label,Hostname/IP,Port,Username\n中文组,web,192.168.1.10,22,root\n";
|
||||
const decoded = await readVaultImportFile(
|
||||
"csv",
|
||||
new File([text], "hosts.csv", { type: "text/csv" }),
|
||||
);
|
||||
const result = importVaultHostsFromText("csv", decoded);
|
||||
|
||||
assert.equal(result.hosts[0]?.group, "中文组");
|
||||
});
|
||||
|
||||
test("ssh_config import keeps UTF-8 host names when a comment has invalid bytes", async () => {
|
||||
const file = new File([
|
||||
new TextEncoder().encode("# legacy comment "),
|
||||
new Uint8Array([0xff]),
|
||||
new TextEncoder().encode("\nHost 中文组\n HostName example.com\n"),
|
||||
], "config", { type: "text/plain" });
|
||||
|
||||
const text = await readVaultImportFile("ssh_config", file);
|
||||
const result = importVaultHostsFromText("ssh_config", text);
|
||||
|
||||
assert.equal(result.hosts[0]?.label, "中文组");
|
||||
assert.equal(result.hosts[0]?.hostname, "example.com");
|
||||
});
|
||||
|
||||
test("MobaXterm import can force GB18030 for ambiguous legacy Chinese text", async () => {
|
||||
const prefix = new TextEncoder().encode("[Bookmarks]\nSubRep=\nImgNum=42\n");
|
||||
const suffix = new TextEncoder().encode(`prod=${sessionValue}`);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
computeSftpViewRailOffset,
|
||||
vaultSidebarLayoutStore,
|
||||
} from './vaultSidebarLayoutStore.ts';
|
||||
|
||||
test('computeSftpViewRailOffset offsets only when the sidebar hosts the SFTP surface', () => {
|
||||
assert.equal(
|
||||
computeSftpViewRailOffset({
|
||||
sftpInSidebar: true,
|
||||
isSftpActive: true,
|
||||
vaultRailWidth: 208,
|
||||
}),
|
||||
208,
|
||||
);
|
||||
assert.equal(
|
||||
computeSftpViewRailOffset({
|
||||
sftpInSidebar: true,
|
||||
isSftpActive: false,
|
||||
vaultRailWidth: 208,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
computeSftpViewRailOffset({
|
||||
sftpInSidebar: false,
|
||||
isSftpActive: true,
|
||||
vaultRailWidth: 208,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('computeSftpViewRailOffset clamps negative rail widths', () => {
|
||||
assert.equal(
|
||||
computeSftpViewRailOffset({
|
||||
sftpInSidebar: true,
|
||||
isSftpActive: true,
|
||||
vaultRailWidth: -20,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('vaultSidebarLayoutStore dedupes identical width publishes', () => {
|
||||
const events: number[] = [];
|
||||
const unsubscribe = vaultSidebarLayoutStore.subscribe(() => {
|
||||
events.push(vaultSidebarLayoutStore.getLayoutWidth());
|
||||
});
|
||||
|
||||
vaultSidebarLayoutStore.setLayoutWidth(208.4);
|
||||
vaultSidebarLayoutStore.setLayoutWidth(208.2);
|
||||
vaultSidebarLayoutStore.setLayoutWidth(0);
|
||||
vaultSidebarLayoutStore.setLayoutWidth(-5);
|
||||
|
||||
assert.deepEqual(events, [208, 0]);
|
||||
unsubscribe();
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
/**
|
||||
* Live width of the vault sidebar rail. VaultViewLayout publishes its sidebar
|
||||
* width here so the root SFTP surface can offset itself beside the rail while
|
||||
* the vault sidebar stays visible during the SFTP tab (the "SFTP in sidebar"
|
||||
* setting). Mirrors the terminalHostTreeStore layout-width pattern.
|
||||
*/
|
||||
class VaultSidebarLayoutStore {
|
||||
private layoutWidth = 0;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getLayoutWidth = () => this.layoutWidth;
|
||||
|
||||
setLayoutWidth = (width: number) => {
|
||||
const next = Math.max(0, Math.round(width));
|
||||
if (this.layoutWidth === next) return;
|
||||
this.layoutWidth = next;
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const vaultSidebarLayoutStore = new VaultSidebarLayoutStore();
|
||||
|
||||
export const useVaultSidebarLayoutWidth = () => {
|
||||
return useSyncExternalStore(
|
||||
vaultSidebarLayoutStore.subscribe,
|
||||
vaultSidebarLayoutStore.getLayoutWidth,
|
||||
vaultSidebarLayoutStore.getLayoutWidth,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Horizontal offset applied to the root SFTP view so it starts to the right
|
||||
* of the vault sidebar rail. Zero unless the SFTP-in-sidebar mode is on and
|
||||
* the SFTP tab is the active surface.
|
||||
*/
|
||||
export function computeSftpViewRailOffset(params: {
|
||||
sftpInSidebar: boolean;
|
||||
isSftpActive: boolean;
|
||||
vaultRailWidth: number;
|
||||
}): number {
|
||||
return params.sftpInSidebar && params.isSftpActive
|
||||
? Math.max(0, params.vaultRailWidth)
|
||||
: 0;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { STORAGE_KEY_WORKSPACE_LAYOUT_PRESET } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
sanitizeWorkspaceLayoutPreset,
|
||||
type WorkspaceLayoutPreset,
|
||||
} from '../../domain/workspaceLayoutPreset';
|
||||
|
||||
/**
|
||||
* Saved default workspace side panel layout. Device-local only: it is not
|
||||
* part of the sync payload because pane arrangements are client-side chrome.
|
||||
*/
|
||||
export function readWorkspaceLayoutPreset(): WorkspaceLayoutPreset | null {
|
||||
return sanitizeWorkspaceLayoutPreset(localStorageAdapter.read(STORAGE_KEY_WORKSPACE_LAYOUT_PRESET));
|
||||
}
|
||||
|
||||
export function writeWorkspaceLayoutPreset(preset: WorkspaceLayoutPreset): boolean {
|
||||
return localStorageAdapter.write(STORAGE_KEY_WORKSPACE_LAYOUT_PRESET, preset);
|
||||
}
|
||||
@@ -96,6 +96,7 @@ import {
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SFTP_IN_SIDEBAR,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
@@ -323,6 +324,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SFTP_IN_SIDEBAR,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
@@ -561,6 +563,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (tabBarPosition != null) settings.tabBarPosition = normalizeTabBarPosition(tabBarPosition);
|
||||
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
|
||||
const sftpInSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_IN_SIDEBAR);
|
||||
if (sftpInSidebar != null) settings.sftpInSidebar = sftpInSidebar;
|
||||
const shellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
if (shellOnlyTabNumberShortcuts != null) settings.shellOnlyTabNumberShortcuts = shellOnlyTabNumberShortcuts;
|
||||
const showTabNumberBadges = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES);
|
||||
@@ -836,6 +840,9 @@ async function applySyncableSettings(
|
||||
if (settings.showSftpTab != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
|
||||
}
|
||||
if (settings.sftpInSidebar != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SFTP_IN_SIDEBAR, settings.sftpInSidebar);
|
||||
}
|
||||
if (settings.shellOnlyTabNumberShortcuts != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, settings.shellOnlyTabNumberShortcuts);
|
||||
}
|
||||
|
||||
@@ -311,8 +311,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, AgentModelPreset[]>>({});
|
||||
const [runtimeModelWarnings, setRuntimeModelWarnings] = useState<Record<string, string>>({});
|
||||
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, { cacheKey: string; models: AgentModelPreset[] }>>({});
|
||||
const [runtimeModelWarnings, setRuntimeModelWarnings] = useState<Record<string, { cacheKey: string; message: string }>>({});
|
||||
const [steerWarnings, setSteerWarnings] = useState<Record<string, SteerWarning>>({});
|
||||
const [steeringSessionId, setSteeringSessionId] = useState<string | null>(null);
|
||||
const [userSkillOptions, setUserSkillOptions] = useState<UserSkillOption[]>([]);
|
||||
@@ -874,11 +874,13 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
externalAgentsRef.current = externalAgents;
|
||||
|
||||
const applySdkRuntimeModelCatalog = useCallback((
|
||||
agentId: string,
|
||||
target: SdkRuntimeModelTarget,
|
||||
catalog: SdkRuntimeModelCatalog,
|
||||
options: { adoptCurrentModel?: boolean } = {},
|
||||
) => {
|
||||
const agent = externalAgentsRef.current.find((item) => item.id === agentId);
|
||||
const agent = externalAgentsRef.current.find((item) => item.id === target.agentId);
|
||||
if (buildExternalAgentRuntimeModelTarget(agent)?.cacheKey !== target.cacheKey) return;
|
||||
const agentId = target.agentId;
|
||||
const runtimePresets = mergeFallbackThinkingLevels(
|
||||
normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId),
|
||||
getAgentModelPresets(agent?.command, getExternalAgentSdkBackend(agent)),
|
||||
@@ -892,9 +894,10 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
});
|
||||
} else {
|
||||
setRuntimeAgentModelPresets((prev) => (
|
||||
agentModelPresetsShallowEqual(prev[agentId], runtimePresets)
|
||||
prev[agentId]?.cacheKey === target.cacheKey
|
||||
&& agentModelPresetsShallowEqual(prev[agentId].models, runtimePresets)
|
||||
? prev
|
||||
: { ...prev, [agentId]: runtimePresets }
|
||||
: { ...prev, [agentId]: { cacheKey: target.cacheKey, models: runtimePresets } }
|
||||
));
|
||||
}
|
||||
|
||||
@@ -914,7 +917,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
) {
|
||||
setAgentModel(agentId, catalog.currentModelId);
|
||||
}
|
||||
}, [setAgentModel]);
|
||||
}, [setAgentModel, buildExternalAgentRuntimeModelTarget]);
|
||||
|
||||
const loadSdkRuntimeModelCatalog = useCallback((
|
||||
target: SdkRuntimeModelTarget,
|
||||
@@ -939,10 +942,15 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
throw new Error(result?.error || 'Failed to load SDK agent models');
|
||||
}
|
||||
setRuntimeModelWarnings((current) => {
|
||||
const agent = externalAgentsRef.current.find((item) => item.id === target.agentId);
|
||||
if (buildExternalAgentRuntimeModelTarget(agent)?.cacheKey !== target.cacheKey) return current;
|
||||
const next = { ...current };
|
||||
if (result.warning && target.codexRuntime === 'app-server') {
|
||||
next[target.agentId] = t('ai.codex.appServer.modelCatalogWarning');
|
||||
console.warn('[AIChatSidePanel] Codex App Server model catalog unavailable:', result.warning);
|
||||
if (result.warning) {
|
||||
next[target.agentId] = {
|
||||
cacheKey: target.cacheKey,
|
||||
message: t('ai.chat.modelCatalogWarning'),
|
||||
};
|
||||
console.warn('[AIChatSidePanel] SDK model catalog unavailable:', result.warning);
|
||||
} else {
|
||||
delete next[target.agentId];
|
||||
}
|
||||
@@ -955,18 +963,20 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
},
|
||||
{ force: options.force },
|
||||
).catch((err) => {
|
||||
if (target.codexRuntime === 'app-server') {
|
||||
setRuntimeModelWarnings((current) => ({
|
||||
setRuntimeModelWarnings((current) => {
|
||||
const agent = externalAgentsRef.current.find((item) => item.id === target.agentId);
|
||||
if (buildExternalAgentRuntimeModelTarget(agent)?.cacheKey !== target.cacheKey) return current;
|
||||
return {
|
||||
...current,
|
||||
[target.agentId]: t('ai.codex.appServer.modelCatalogWarning'),
|
||||
}));
|
||||
}
|
||||
[target.agentId]: { cacheKey: target.cacheKey, message: t('ai.chat.modelCatalogWarning') },
|
||||
};
|
||||
});
|
||||
if (options.logErrors !== false) {
|
||||
console.warn('[AIChatSidePanel] Failed to load SDK agent models:', err);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}, [t]);
|
||||
}, [t, buildExternalAgentRuntimeModelTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
@@ -978,7 +988,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
const cached = sdkRuntimeModelCache.read(target.cacheKey);
|
||||
if (cached) {
|
||||
applySdkRuntimeModelCatalog(target.agentId, cached);
|
||||
applySdkRuntimeModelCatalog(target, cached);
|
||||
}
|
||||
|
||||
// Respect renderer TTL / in-flight coalescing for all SDK agents including
|
||||
@@ -990,7 +1000,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const cancelIdle = scheduleWhenAiComposerIdle(() => {
|
||||
void loadSdkRuntimeModelCatalog(target).then((catalog) => {
|
||||
if (cancelled || !catalog) return;
|
||||
applySdkRuntimeModelCatalog(target.agentId, catalog, { adoptCurrentModel: true });
|
||||
applySdkRuntimeModelCatalog(target, catalog, { adoptCurrentModel: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1012,7 +1022,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const hasCodexCustomConfig = Boolean(codexConfigModel) && isCodexManagedAgent;
|
||||
|
||||
const agentModelPresets = useMemo(() => {
|
||||
const runtimePresets = runtimeAgentModelPresets[currentAgentId];
|
||||
const target = buildExternalAgentRuntimeModelTarget(currentAgentConfig);
|
||||
const runtimeEntry = runtimeAgentModelPresets[currentAgentId];
|
||||
const runtimePresets = runtimeEntry && target && runtimeEntry.cacheKey === target.cacheKey
|
||||
? runtimeEntry.models
|
||||
: undefined;
|
||||
if (hasCodexCustomConfig && codexConfigModel) {
|
||||
return [{ id: codexConfigModel, name: codexConfigModel }];
|
||||
}
|
||||
@@ -1028,6 +1042,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
currentAgentConfig,
|
||||
currentAgentId,
|
||||
runtimeAgentModelPresets,
|
||||
buildExternalAgentRuntimeModelTarget,
|
||||
hasCodexCustomConfig,
|
||||
codexConfigModel,
|
||||
discoveredAgents,
|
||||
@@ -1213,7 +1228,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
if (runtimeTarget) {
|
||||
const catalog = await loadSdkRuntimeModelCatalog(runtimeTarget);
|
||||
if (catalog) {
|
||||
applySdkRuntimeModelCatalog(runtimeTarget.agentId, catalog, { adoptCurrentModel: true });
|
||||
applySdkRuntimeModelCatalog(runtimeTarget, catalog, { adoptCurrentModel: true });
|
||||
const runtimePresets = mergeFallbackThinkingLevels(
|
||||
normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId),
|
||||
getAgentModelPresets(
|
||||
@@ -1720,7 +1735,10 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
canSendCurrentAgent={canSendCurrentAgent}
|
||||
providerDisplayName={providerDisplayName}
|
||||
modelDisplayName={modelDisplayName}
|
||||
modelCatalogWarning={runtimeModelWarnings[currentAgentId]}
|
||||
modelCatalogWarning={runtimeModelWarnings[currentAgentId]
|
||||
&& runtimeModelWarnings[currentAgentId].cacheKey === buildExternalAgentRuntimeModelTarget(currentAgentConfig)?.cacheKey
|
||||
? runtimeModelWarnings[currentAgentId].message
|
||||
: undefined}
|
||||
agentModelPresets={agentModelPresets}
|
||||
selectedAgentModel={selectedAgentModel}
|
||||
handleAgentModelSelect={handleAgentModelSelect}
|
||||
|
||||
@@ -89,6 +89,11 @@ test('mergeFallbackThinkingLevels fills missing runtime effort catalogs', () =>
|
||||
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'medium', 'high'], defaultThinkingLevel: 'medium' }],
|
||||
);
|
||||
assert.deepEqual(partial[0]?.thinkingLevels, ['low']);
|
||||
const unsupported = mergeFallbackThinkingLevels(
|
||||
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: [] }],
|
||||
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'high'] }],
|
||||
);
|
||||
assert.deepEqual(unsupported[0]?.thinkingLevels, []);
|
||||
assert.deepEqual(mergeFallbackThinkingLevels([], [{ id: 'gpt-5.5', name: 'GPT-5.5' }]), []);
|
||||
const alreadyFilled = [{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low'] }];
|
||||
assert.equal(
|
||||
@@ -137,7 +142,7 @@ test('shouldLoadSdkRuntimeModels includes SDK agents with model catalogs', () =>
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('codebuddy')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('opencode')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('grok')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('codex')), false);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('codex')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels({ ...agent('codex'), codexRuntime: 'app-server' }), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(undefined), false);
|
||||
});
|
||||
@@ -156,6 +161,28 @@ test('Codex App Server model discovery uses a separate cache identity', () => {
|
||||
codexRuntime: 'app-server',
|
||||
});
|
||||
assert.notEqual(sdk, appServer);
|
||||
assert.notEqual(
|
||||
buildSdkRuntimeModelCacheKey({ id: 'discovered_codex', sdkBackend: 'codex', command: '/bin/codex', env: { HOME: '/shared', CODEX_HOME: '/profiles/a' } }),
|
||||
buildSdkRuntimeModelCacheKey({ id: 'discovered_codex', sdkBackend: 'codex', command: '/bin/codex', env: { HOME: '/shared', CODEX_HOME: '/profiles/b' } }),
|
||||
);
|
||||
});
|
||||
|
||||
test('Claude model cache keys isolate configuration directories under one home', () => {
|
||||
const agent = { id: 'discovered_claude', sdkBackend: 'claude', command: '/bin/claude' };
|
||||
assert.notEqual(
|
||||
buildSdkRuntimeModelCacheKey({ ...agent, env: { HOME: '/shared', CLAUDE_CONFIG_DIR: '/profiles/a' } }),
|
||||
buildSdkRuntimeModelCacheKey({ ...agent, env: { HOME: '/shared', CLAUDE_CONFIG_DIR: '/profiles/b' } }),
|
||||
);
|
||||
});
|
||||
|
||||
test('Claude model cache keys isolate credentials without exposing them', () => {
|
||||
const agent = { id: 'discovered_claude', sdkBackend: 'claude', command: '/bin/claude' };
|
||||
for (const name of ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN']) {
|
||||
const first = buildSdkRuntimeModelCacheKey({ ...agent, env: { [name]: 'secret-one' } });
|
||||
const second = buildSdkRuntimeModelCacheKey({ ...agent, env: { [name]: 'secret-two' } });
|
||||
assert.notEqual(first, second, `${name} must separate cache entries`);
|
||||
assert.doesNotMatch(first, /secret-one/);
|
||||
}
|
||||
});
|
||||
|
||||
test('shouldAdoptSdkCurrentModel keeps SDK defaults when no runtime list is returned', () => {
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
} from '../infrastructure/ai/types';
|
||||
import { getExternalAgentSdkBackend } from '../infrastructure/ai/managedAgents';
|
||||
import { canonicalizeEffortEncodedModelId } from '../infrastructure/ai/composerPicker';
|
||||
import { sha256 } from '@noble/hashes/sha2.js';
|
||||
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||
|
||||
export { canonicalizeEffortEncodedModelId };
|
||||
|
||||
@@ -35,6 +37,11 @@ const MODEL_CACHE_ENV_HINTS = [
|
||||
'HOME',
|
||||
'USERPROFILE',
|
||||
'XDG_CONFIG_HOME',
|
||||
'CODEX_HOME',
|
||||
'CLAUDE_CONFIG_DIR',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'OPENCODE_BIN',
|
||||
'OPENCODE_CONFIG',
|
||||
'OPENCODE_CONFIG_DIR',
|
||||
@@ -103,7 +110,9 @@ export function buildSdkRuntimeModelCacheKey(agent: {
|
||||
const grokRuntime = sdkBackend === 'grok'
|
||||
? (agent.grokRuntime === 'streaming-json' ? 'streaming-json' : 'acp')
|
||||
: '';
|
||||
return [agent.id, sdkBackend, agent.command ?? '', agent.codexRuntime ?? 'sdk', grokRuntime, cursorAuth, ...envHints].join('\u0000');
|
||||
// Keep authentication values out of the in-memory cache key itself.
|
||||
const envHash = bytesToHex(sha256(new TextEncoder().encode(envHints.join('\u0000'))));
|
||||
return [agent.id, sdkBackend, agent.command ?? '', agent.codexRuntime ?? 'sdk', grokRuntime, cursorAuth, envHash].join('\u0000');
|
||||
}
|
||||
|
||||
export function createSdkRuntimeModelCache(options: SdkRuntimeModelCacheOptions = {}) {
|
||||
@@ -190,7 +199,7 @@ export function mergeFallbackThinkingLevels(
|
||||
const byId = new Map(fallbacks.map((preset) => [preset.id, preset]));
|
||||
let changed = false;
|
||||
const next = runtime.map((preset) => {
|
||||
if (preset.thinkingLevels?.length) return preset;
|
||||
if (preset.thinkingLevels !== undefined) return preset;
|
||||
const fallback = byId.get(preset.id);
|
||||
if (!fallback?.thinkingLevels?.length) return preset;
|
||||
changed = true;
|
||||
@@ -253,7 +262,10 @@ export function normalizeStoredAgentModelSelection(
|
||||
|
||||
export function shouldLoadSdkRuntimeModels(agent?: ExternalAgentConfig): boolean {
|
||||
const sdkBackend = getExternalAgentSdkBackend(agent);
|
||||
return (sdkBackend === 'codex' && agent?.codexRuntime === 'app-server')
|
||||
// Codex on the default `sdk` runtime also serves a live catalog: its driver
|
||||
// falls back to the App Server runtime's model/list in main (#3496), so
|
||||
// quick sends must await it instead of running with build-time presets.
|
||||
return sdkBackend === 'codex'
|
||||
|| sdkBackend === 'claude'
|
||||
|| sdkBackend === 'copilot'
|
||||
|| sdkBackend === 'cursor'
|
||||
|
||||
@@ -17,6 +17,7 @@ import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import type { GroupConfig } from "../domain/models";
|
||||
import { detectSshKeyType } from "../domain/keyTypeDetect";
|
||||
import { reorderVaultItems, sortByVaultOrder } from "../domain/vaultOrder";
|
||||
import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/storageKeys";
|
||||
import { logger } from "../lib/logger";
|
||||
@@ -422,16 +423,16 @@ echo $3 >> "$FILE"`);
|
||||
}
|
||||
|
||||
// Detect key type from private key content
|
||||
let detectedType: KeyType = "ED25519";
|
||||
const pk = draftKey.privateKey.toLowerCase();
|
||||
if (pk.includes("rsa")) detectedType = "RSA";
|
||||
else if (pk.includes("ecdsa") || pk.includes("ec ")) detectedType = "ECDSA";
|
||||
else if (pk.includes("ed25519")) detectedType = "ED25519";
|
||||
const detected = detectSshKeyType(
|
||||
draftKey.privateKey,
|
||||
draftKey.publicKey,
|
||||
);
|
||||
|
||||
const newKey: SSHKey = {
|
||||
id: crypto.randomUUID(),
|
||||
label: draftKey.label.trim(),
|
||||
type: (draftKey.type as KeyType) || detectedType,
|
||||
type: detected.type,
|
||||
keySize: detected.keySize,
|
||||
privateKey: draftKey.privateKey.trim(),
|
||||
publicKey: draftKey.publicKey?.trim() || undefined,
|
||||
certificate: draftKey.certificate?.trim() || undefined,
|
||||
@@ -541,12 +542,7 @@ echo $3 >> "$FILE"`);
|
||||
const content = e.target?.result as string;
|
||||
if (content) {
|
||||
// Try to detect key type from content
|
||||
let detectedType: KeyType = "ED25519";
|
||||
const lc = content.toLowerCase();
|
||||
if (lc.includes("rsa")) detectedType = "RSA";
|
||||
else if (lc.includes("ecdsa") || lc.includes("ec private"))
|
||||
detectedType = "ECDSA";
|
||||
else if (lc.includes("ed25519")) detectedType = "ED25519";
|
||||
const detected = detectSshKeyType(content);
|
||||
|
||||
// Extract label from filename (remove extension)
|
||||
const label = file.name.replace(/\.(pem|key|pub|ppk)$/i, "");
|
||||
@@ -555,7 +551,8 @@ echo $3 >> "$FILE"`);
|
||||
...prev,
|
||||
privateKey: content,
|
||||
label: prev.label || label,
|
||||
type: detectedType,
|
||||
type: detected.type,
|
||||
keySize: detected.keySize,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import { formatHostPort } from "../domain/host";
|
||||
import {
|
||||
buildQuickConnectHost,
|
||||
getQuickConnectDefaultPort,
|
||||
isQuickConnectIdentityCompatible,
|
||||
isQuickConnectIdentityUsable,
|
||||
type QuickConnectAuthMethod,
|
||||
type QuickConnectProtocol,
|
||||
@@ -104,13 +105,13 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
[keys, protocol, selectedIdentity],
|
||||
);
|
||||
const identityOptions = useMemo(
|
||||
() => identities.map((identity) => ({
|
||||
() => identities.filter((identity) => isQuickConnectIdentityCompatible(identity, target)).map((identity) => ({
|
||||
value: identity.id,
|
||||
label: identity.label,
|
||||
sublabel: identity.username,
|
||||
icon: <User size={14} />,
|
||||
})),
|
||||
[identities],
|
||||
[identities, target],
|
||||
);
|
||||
|
||||
const detachSelectedIdentity = () => {
|
||||
@@ -131,7 +132,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
return;
|
||||
}
|
||||
const identity = identities.find((candidate) => candidate.id === identityId);
|
||||
if (!identity) return;
|
||||
if (!identity || !isQuickConnectIdentityCompatible(identity, target)) return;
|
||||
setSelectedIdentityId(identity.id);
|
||||
setUsername(identity.username);
|
||||
setAuthMethod(identity.authMethod);
|
||||
@@ -431,7 +432,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{protocol !== "telnet" && identities.length > 0 && (
|
||||
{protocol !== "telnet" && identityOptions.length > 0 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label className="text-sm font-medium">{t("quickConnect.identity.label")}</Label>
|
||||
|
||||
@@ -561,6 +561,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
||||
setTabBarPosition={settings.setTabBarPosition}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
setShowSftpTab={settings.setShowSftpTab}
|
||||
sftpInSidebar={settings.sftpInSidebar}
|
||||
setSftpInSidebar={settings.setSftpInSidebar}
|
||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||
setShowHostTreeSidebar={settings.setShowHostTreeSidebar}
|
||||
windowOpacity={settings.windowOpacity}
|
||||
|
||||
@@ -347,6 +347,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
listLocalDir,
|
||||
listDrives,
|
||||
openPath,
|
||||
statSftp,
|
||||
} = useSftpBackend();
|
||||
|
||||
const sftpRef = useRef(sftp);
|
||||
@@ -1337,6 +1338,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
selectDirectory={selectDirectory}
|
||||
listLocalDir={listLocalDir}
|
||||
listDrives={listDrives}
|
||||
statSftp={statSftp}
|
||||
openPath={openPath}
|
||||
t={t}
|
||||
/>
|
||||
@@ -1389,6 +1391,7 @@ type SftpSidePanelInteractiveBodyProps = {
|
||||
selectDirectory: ReturnType<typeof useSftpBackend>["selectDirectory"];
|
||||
listLocalDir: ReturnType<typeof useSftpBackend>["listLocalDir"];
|
||||
listDrives: ReturnType<typeof useSftpBackend>["listDrives"];
|
||||
statSftp: ReturnType<typeof useSftpBackend>["statSftp"];
|
||||
openPath: ReturnType<typeof useSftpBackend>["openPath"];
|
||||
t: ReturnType<typeof useI18n>["t"];
|
||||
};
|
||||
@@ -1431,6 +1434,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
selectDirectory,
|
||||
listLocalDir,
|
||||
listDrives,
|
||||
statSftp,
|
||||
openPath,
|
||||
t,
|
||||
}) => {
|
||||
@@ -1540,6 +1544,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
||||
statSftp,
|
||||
listLocalFiles: listLocalDir,
|
||||
listDrives,
|
||||
});
|
||||
|
||||
@@ -193,6 +193,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
listLocalDir,
|
||||
listDrives,
|
||||
openPath,
|
||||
statSftp,
|
||||
} = useSftpBackend();
|
||||
|
||||
// Store sftp in a ref so callbacks can access the latest instance
|
||||
@@ -424,6 +425,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
||||
statSftp,
|
||||
listLocalFiles: listLocalDir,
|
||||
listDrives,
|
||||
});
|
||||
|
||||
+29
-9
@@ -82,6 +82,7 @@ import { usePluginTerminalProviders } from "../application/state/usePluginTermin
|
||||
import type { PluginTerminalDecorationRule } from "../domain/pluginTerminalProviders";
|
||||
import { terminalReconnectRegistry } from "../application/state/terminalReconnectRegistry";
|
||||
import { resolveSftpReuseSourceSessionId } from "../application/state/terminalConnectionReuse";
|
||||
import { resolvePasswordAuthSftpHost } from "../domain/authIdentityPicker";
|
||||
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
|
||||
import { Button } from "./ui/button";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
|
||||
@@ -1238,6 +1239,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
const auth = useTerminalAuthState({
|
||||
host,
|
||||
identities,
|
||||
pendingAuthRef,
|
||||
termRef,
|
||||
onUpdateHost: handleUpdateHostFromTerminal,
|
||||
@@ -1438,8 +1440,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
hasEverConnectedRef.current = true;
|
||||
clearAutoReconnect();
|
||||
}
|
||||
onStatusChange?.(sessionId, next);
|
||||
}, [clearAutoReconnect, onStatusChange, sessionId]);
|
||||
const sftpHost = next === "connected"
|
||||
? resolvePasswordAuthSftpHost(host, identities, pendingAuthRef.current)
|
||||
: host;
|
||||
onStatusChange?.(sessionId, next, sftpHost === host ? undefined : sftpHost);
|
||||
}, [clearAutoReconnect, host, identities, onStatusChange, sessionId]);
|
||||
const updateStatusRef = useRef(updateStatus);
|
||||
updateStatusRef.current = updateStatus;
|
||||
|
||||
@@ -1960,7 +1965,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
sensitivePromptOutputTailRef.current,
|
||||
chunk,
|
||||
);
|
||||
const promptSecurityOptions = { allowHostStyleGreaterThan: isNetworkDevice };
|
||||
const promptSecurityOptions = {
|
||||
allowHostStyleGreaterThan: isNetworkDevice,
|
||||
alternateScreen: termRef.current
|
||||
? isTerminalAlternateScreenActive(termRef.current)
|
||||
: false,
|
||||
};
|
||||
if (typeof meta?.pluginPipelineSensitiveInput === "boolean") {
|
||||
passwordPromptActiveRef.current = meta.pluginPipelineSensitiveInput;
|
||||
if (meta.pluginPipelineSensitiveInput) {
|
||||
@@ -3462,8 +3472,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (onOpenSftp) {
|
||||
// Delegate to parent (TerminalLayer) for shared SFTP side panel
|
||||
const initialPath = await resolveSftpInitialPath();
|
||||
const sftpHost = statusRef.current === 'connected'
|
||||
? resolvePasswordAuthSftpHost(host, identities, pendingAuthRef.current)
|
||||
: host;
|
||||
onOpenSftp(
|
||||
host,
|
||||
sftpHost,
|
||||
initialPath,
|
||||
undefined,
|
||||
sessionId,
|
||||
@@ -3478,7 +3491,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
setShowSFTP(true);
|
||||
}, [host, onOpenSftp, resolveSftpInitialPath, sessionId, showSFTP]);
|
||||
}, [host, identities, onOpenSftp, resolveSftpInitialPath, sessionId, showSFTP]);
|
||||
|
||||
const handleSendYmodem = useCallback(async () => {
|
||||
if (!isSerialConnection || statusRef.current !== "connected") return;
|
||||
@@ -3956,6 +3969,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
requiresUserInput: auth.needsAuth || needsHostKeyVerification || isConnectionAwaitingUserInput,
|
||||
});
|
||||
|
||||
const dragDropSftpHost = status === 'connected'
|
||||
? resolvePasswordAuthSftpHost(host, identities, pendingAuthRef.current)
|
||||
: host;
|
||||
const dragDropSudoPassword = dragDropSftpHost === host
|
||||
? resolvedSudoAutofillPassword
|
||||
: identities.find(identity => identity.id === dragDropSftpHost.identityId)?.password;
|
||||
|
||||
const {
|
||||
handleDragEnter,
|
||||
handleDragLeave,
|
||||
@@ -3963,9 +3983,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
handleDrop,
|
||||
isDraggingOver,
|
||||
} = useTerminalDragDrop({
|
||||
host,
|
||||
resolvedLoginUsername,
|
||||
resolvedSudoPassword: resolvedSudoAutofillPassword,
|
||||
host: dragDropSftpHost,
|
||||
resolvedLoginUsername: dragDropSftpHost === host ? resolvedLoginUsername : dragDropSftpHost.username,
|
||||
resolvedSudoPassword: dragDropSudoPassword,
|
||||
isLocalConnection,
|
||||
isNetworkDevice,
|
||||
onOpenSftp,
|
||||
@@ -4540,7 +4560,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
|
||||
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
|
||||
<ScriptExecutionOverlay
|
||||
run={activeScriptRun}
|
||||
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
|
||||
|
||||
+225
-30
@@ -20,6 +20,7 @@ import {
|
||||
} from '../application/state/codingCliSessionSignalController';
|
||||
import { collectSessionIds } from '../domain/workspace';
|
||||
import { isPluginHostProtocol } from '../domain/pluginConnection';
|
||||
import { isSameSftpHostSelection } from '../domain/sftpTerminalIdentity';
|
||||
|
||||
import { cn, normalizeLineEndings } from '../lib/utils';
|
||||
import { detectLocalOs } from '../lib/localShell';
|
||||
@@ -27,6 +28,7 @@ import { useStoredString } from '../application/state/useStoredString';
|
||||
import { useStoredNumber } from '../application/state/useStoredNumber';
|
||||
import { useStoredBoolean } from '../application/state/useStoredBoolean';
|
||||
import {
|
||||
STORAGE_KEY_SIDE_PANEL_HEIGHT,
|
||||
STORAGE_KEY_SIDE_PANEL_WIDTH,
|
||||
STORAGE_KEY_TERMINAL_COMPOSE_BAR_OPEN,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
@@ -119,8 +121,10 @@ import { classifyDistroId, shouldProbeSessionCwd } from '../domain/host';
|
||||
import {
|
||||
collectSidePanelPanes,
|
||||
sidePanelLayoutHasTool,
|
||||
type SidePanelLayout,
|
||||
type SidePanelSplitDirection,
|
||||
} from '../domain/sidePanelLayout';
|
||||
import { useWorkspaceLayoutPresetState } from '../application/state/useWorkspaceLayoutPresetState';
|
||||
import {
|
||||
isPaneMagnificationSelectionValid,
|
||||
resolvePaneMagnificationCandidate,
|
||||
@@ -129,9 +133,12 @@ import {
|
||||
} from '../domain/paneMagnification';
|
||||
import { useTerminalSidePanelLayoutState } from '../application/state/useTerminalSidePanelLayoutState';
|
||||
import {
|
||||
TERMINAL_SIDE_PANEL_MAX_HEIGHT,
|
||||
TERMINAL_SIDE_PANEL_MAX_WIDTH,
|
||||
TERMINAL_SIDE_PANEL_MIN_HEIGHT,
|
||||
TERMINAL_SIDE_PANEL_MIN_WIDTH,
|
||||
} from '../application/state/terminalSidePanelWidth';
|
||||
import { isSidePanelDockPosition } from '../domain/sidePanelLayout';
|
||||
|
||||
import {
|
||||
AIChatPanelsHost,
|
||||
@@ -455,53 +462,188 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const sftpFollowTerminalCwdRef = useRef(sftpFollowTerminalCwd);
|
||||
sftpFollowTerminalCwdRef.current = sftpFollowTerminalCwd;
|
||||
|
||||
const handleStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
|
||||
// Destructure the stable hook callbacks: the hook returns a fresh object
|
||||
// each render, and depending on that object would recreate the preset
|
||||
// callbacks (and in turn handleStatusChange) on unrelated state updates,
|
||||
// defeating the terminal pane memoization.
|
||||
const { resolveDefaultLayoutForSession, saveWorkspaceLayoutAsDefault: persistWorkspaceLayoutAsDefault } =
|
||||
useWorkspaceLayoutPresetState();
|
||||
|
||||
const applyWorkspaceLayoutPresetForSession = useCallback((session: TerminalSession, tabId: string, sftpHostOverride?: Host) => {
|
||||
// Preset persistence and resolution live in the application layer; the
|
||||
// component only mounts the resolved panes into its UI state.
|
||||
const resolved = resolveDefaultLayoutForSession(session);
|
||||
if (!resolved) return false;
|
||||
const { layout, focusedTool, protocol: proto } = resolved;
|
||||
|
||||
for (const pane of collectSidePanelPanes(layout.root)) {
|
||||
if (pane.tool === 'ai') {
|
||||
setAiMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (pane.tool === 'scripts') {
|
||||
setScriptsMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (pane.tool === 'theme') {
|
||||
setThemeMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (pane.tool === 'system') {
|
||||
setSystemMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (pane.tool === 'notes') {
|
||||
setNotesMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (pane.tool === 'sftp') {
|
||||
// Mirror the normal SFTP open path: a previously retained panel (kept
|
||||
// mounted while transfers or external edits finished after close) must
|
||||
// not keep suppressing command-triggered refreshes once the preset
|
||||
// reopens SFTP for this tab. Mark the tab as opening first so an
|
||||
// activity callback firing before React commits the open state still
|
||||
// sees the panel as open instead of pruning the retained host state.
|
||||
const cleanupTimer = sftpRetainedCleanupTimersRef.current.get(tabId);
|
||||
if (cleanupTimer !== undefined) {
|
||||
window.clearTimeout(cleanupTimer);
|
||||
sftpRetainedCleanupTimersRef.current.delete(tabId);
|
||||
}
|
||||
sftpOpeningTabIdsRef.current.add(tabId);
|
||||
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
|
||||
sftpPaneClosedTabIdsRef.current.delete(tabId);
|
||||
setSftpHostSourceSessionForTab(prev => new Map(prev).set(tabId, session.id));
|
||||
const host = hostsRef.current.find(h => h.id === session.hostId);
|
||||
const hostWithOverrides: Host = host
|
||||
? {
|
||||
...host,
|
||||
protocol: session.protocol ?? host.protocol,
|
||||
port: session.port ?? host.port,
|
||||
moshEnabled: session.moshEnabled ?? host.moshEnabled,
|
||||
etEnabled: session.etEnabled ?? host.etEnabled,
|
||||
}
|
||||
: {
|
||||
id: session.hostId || session.id,
|
||||
hostname: session.hostname,
|
||||
username: session.username,
|
||||
port: session.port ?? 22,
|
||||
protocol: proto,
|
||||
label: session.customName || session.hostLabel || session.hostname,
|
||||
} as Host;
|
||||
setSftpHostForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(tabId, sftpHostOverride ?? hostWithOverrides);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setSidePanelLayouts((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(tabId, layout);
|
||||
return next;
|
||||
});
|
||||
lastSidePanelTabRef.current.set(tabId, focusedTool);
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(tabId, focusedTool);
|
||||
return next;
|
||||
});
|
||||
return true;
|
||||
}, [resolveDefaultLayoutForSession, setSidePanelLayouts, setSidePanelOpenTabs]);
|
||||
|
||||
const handleSaveWorkspaceLayoutAsDefault = useCallback((layout: SidePanelLayout): boolean => {
|
||||
if (!persistWorkspaceLayoutAsDefault(layout)) {
|
||||
toast.error(t('terminal.layer.layoutSaveFailed'));
|
||||
return false;
|
||||
}
|
||||
toast.success(t('terminal.layer.layoutSavedAsDefault'));
|
||||
return true;
|
||||
}, [persistWorkspaceLayoutAsDefault, t]);
|
||||
|
||||
const handleStatusChange = useCallback((sessionId: string, status: TerminalSession['status'], sftpHostOverride?: Host) => {
|
||||
onUpdateSessionStatus(sessionId, status);
|
||||
|
||||
if (status !== 'connected') return;
|
||||
|
||||
setSftpAuthHostBySessionId(prev => {
|
||||
const next = new Map(prev);
|
||||
if (sftpHostOverride) next.set(sessionId, sftpHostOverride);
|
||||
else next.delete(sessionId);
|
||||
return next;
|
||||
});
|
||||
|
||||
const session = sessionsRef.current.find(s => s.id === sessionId);
|
||||
if (!session) return;
|
||||
const proto = session.protocol ?? 'ssh';
|
||||
const tabId = session.workspaceId || sessionId;
|
||||
const workspaceId = session.workspaceId;
|
||||
const tabId = workspaceId || sessionId;
|
||||
|
||||
// Within a workspace every member session maps to the same tab id, so the
|
||||
// preset (and any auto-open host binding) must come from the workspace's
|
||||
// focused session; otherwise whichever pane connects first would decide
|
||||
// SFTP pruning and host selection regardless of focus.
|
||||
let presetSession = session;
|
||||
if (workspaceId) {
|
||||
const focusedSessionId = workspacesRef.current.find(ws => ws.id === workspaceId)?.focusedSessionId;
|
||||
const focusedSession = focusedSessionId
|
||||
? sessionsRef.current.find(s => s.id === focusedSessionId)
|
||||
: undefined;
|
||||
if (focusedSession) presetSession = focusedSession;
|
||||
}
|
||||
const proto = presetSession.protocol ?? 'ssh';
|
||||
|
||||
if (presetSession.id === sessionId && (sftpHostOverride || sftpHostSourceSessionForTabRef.current.get(tabId) === sessionId)) {
|
||||
const connectedHost = sftpHostOverride ?? sessionHostsMapRef.current.get(sessionId);
|
||||
if (connectedHost) {
|
||||
setSftpHostForTab(prev => new Map(prev).set(tabId, connectedHost));
|
||||
}
|
||||
setSftpHostSourceSessionForTab(prev => {
|
||||
if (!sftpHostOverride) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(tabId, sessionId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (sidePanelOpenTabsRef.current.has(tabId)) return;
|
||||
|
||||
const targetPanel = resolveSessionSidePanelAutoOpen({
|
||||
session,
|
||||
session: presetSession,
|
||||
terminalEnabled: terminalSidePanelAutoOpenRef.current,
|
||||
terminalTab: terminalSidePanelAutoOpenTabRef.current,
|
||||
localEnabled: localShellSidePanelAutoOpenRef.current,
|
||||
localTab: localShellSidePanelAutoOpenTabRef.current,
|
||||
legacySftpEnabled: sftpAutoOpenSidebarRef.current,
|
||||
});
|
||||
if (!targetPanel) return;
|
||||
|
||||
// Explicit SFTP (JumpServer deep link / host.autoOpenSftpPanel) wins over a
|
||||
// saved default that may omit file transfer — only when SFTP is actually
|
||||
// available. Ordinary auto-open still yields to the saved default.
|
||||
if (targetPanel !== "sftp") {
|
||||
if (applyWorkspaceLayoutPresetForSession(
|
||||
presetSession,
|
||||
tabId,
|
||||
presetSession.id === sessionId ? sftpHostOverride : undefined,
|
||||
)) return;
|
||||
if (!targetPanel) return;
|
||||
}
|
||||
|
||||
lastSidePanelTabRef.current.set(tabId, targetPanel);
|
||||
|
||||
if (targetPanel === 'sftp') {
|
||||
sftpPaneClosedTabIdsRef.current.delete(tabId);
|
||||
const host = hostsRef.current.find(h => h.id === session.hostId);
|
||||
setSftpHostSourceSessionForTab(prev => new Map(prev).set(tabId, presetSession.id));
|
||||
const host = hostsRef.current.find(h => h.id === presetSession.hostId);
|
||||
const hostWithOverrides: Host = host
|
||||
? {
|
||||
...host,
|
||||
protocol: session.protocol ?? host.protocol,
|
||||
port: session.port ?? host.port,
|
||||
moshEnabled: session.moshEnabled ?? host.moshEnabled,
|
||||
etEnabled: session.etEnabled ?? host.etEnabled,
|
||||
protocol: presetSession.protocol ?? host.protocol,
|
||||
port: presetSession.port ?? host.port,
|
||||
moshEnabled: presetSession.moshEnabled ?? host.moshEnabled,
|
||||
etEnabled: presetSession.etEnabled ?? host.etEnabled,
|
||||
}
|
||||
: {
|
||||
id: session.hostId || sessionId,
|
||||
hostname: session.hostname,
|
||||
username: session.username,
|
||||
port: session.port ?? 22,
|
||||
id: presetSession.hostId || presetSession.id,
|
||||
hostname: presetSession.hostname,
|
||||
username: presetSession.username,
|
||||
port: presetSession.port ?? 22,
|
||||
protocol: proto,
|
||||
label: session.label || session.hostname,
|
||||
label: presetSession.customName || presetSession.hostLabel || presetSession.hostname,
|
||||
} as Host;
|
||||
|
||||
setSftpHostForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(tabId, hostWithOverrides);
|
||||
next.set(tabId, presetSession.id === sessionId && sftpHostOverride ? sftpHostOverride : hostWithOverrides);
|
||||
return next;
|
||||
});
|
||||
} else if (targetPanel === 'ai') {
|
||||
@@ -521,7 +663,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
next.set(tabId, targetPanel);
|
||||
return next;
|
||||
});
|
||||
}, [onUpdateSessionStatus, setSidePanelOpenTabs, sidePanelOpenTabsRef]);
|
||||
}, [applyWorkspaceLayoutPresetForSession, onUpdateSessionStatus, setSidePanelOpenTabs, sidePanelOpenTabsRef]);
|
||||
|
||||
const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => {
|
||||
const intent = resolveTerminalSessionExitIntent(
|
||||
@@ -621,10 +763,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
420,
|
||||
{ min: TERMINAL_SIDE_PANEL_MIN_WIDTH, max: TERMINAL_SIDE_PANEL_MAX_WIDTH },
|
||||
);
|
||||
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right'>(
|
||||
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right' | 'bottom'>(
|
||||
'netcatty_side_panel_position',
|
||||
'left',
|
||||
(v): v is 'left' | 'right' => v === 'left' || v === 'right',
|
||||
isSidePanelDockPosition,
|
||||
);
|
||||
const [sidePanelHeight, setSidePanelHeight, persistSidePanelHeight] = useStoredNumber(
|
||||
STORAGE_KEY_SIDE_PANEL_HEIGHT,
|
||||
360,
|
||||
{ min: TERMINAL_SIDE_PANEL_MIN_HEIGHT, max: TERMINAL_SIDE_PANEL_MAX_HEIGHT },
|
||||
);
|
||||
// Remember the last sub-panel shown per tab so the toggle shortcut can
|
||||
// restore it after a close. Overwritten on open, never cleared on close.
|
||||
@@ -633,6 +780,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
// The host to pass to the SFTP panel - stored when the user opens SFTP
|
||||
const [sftpHostForTab, setSftpHostForTab] = useState<Map<string, Host>>(new Map());
|
||||
const [sftpHostSourceSessionForTab, setSftpHostSourceSessionForTab] = useState<Map<string, string>>(new Map());
|
||||
const [sftpAuthHostBySessionId, setSftpAuthHostBySessionId] = useState<Map<string, Host>>(new Map());
|
||||
useEffect(() => {
|
||||
setSftpAuthHostBySessionId(prev => {
|
||||
const liveIds = new Set(sessions.map(session => session.id));
|
||||
const next = new Map([...prev].filter(([id]) => liveIds.has(id)));
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}, [sessions]);
|
||||
const [sftpInitialLocationForTab, setSftpInitialLocationForTab] = useState<
|
||||
Map<string, { hostId: string; path: string }>
|
||||
>(new Map());
|
||||
@@ -645,6 +801,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const notesOpenRequestIdRef = useRef(0);
|
||||
const sftpHostForTabRef = useRef(sftpHostForTab);
|
||||
sftpHostForTabRef.current = sftpHostForTab;
|
||||
const sftpHostSourceSessionForTabRef = useRef(sftpHostSourceSessionForTab);
|
||||
sftpHostSourceSessionForTabRef.current = sftpHostSourceSessionForTab;
|
||||
const sftpActiveTransfersByTabRef = useRef<Map<string, number>>(new Map());
|
||||
const sftpActiveExternalEditsByTabRef = useRef<Map<string, number>>(new Map());
|
||||
const sftpRetainedAfterCloseTabIdsRef = useRef<Set<string>>(new Set());
|
||||
@@ -692,6 +850,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
next.delete(tabId);
|
||||
return next;
|
||||
});
|
||||
setSftpHostSourceSessionForTab(prev => {
|
||||
if (!prev.has(tabId)) return prev;
|
||||
const next = new Map(prev);
|
||||
next.delete(tabId);
|
||||
return next;
|
||||
});
|
||||
setSftpPendingUploadsForTab(prev => {
|
||||
if (!prev.has(tabId)) return prev;
|
||||
const next = new Map(prev);
|
||||
@@ -877,14 +1041,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
// Compare full endpoint identity so that session-time overrides
|
||||
// (different port/protocol for the same host ID) trigger a switch
|
||||
// instead of toggling the panel closed.
|
||||
const isSameEndpoint = currentHost
|
||||
&& currentHost.id === host.id
|
||||
&& currentHost.hostname === host.hostname
|
||||
&& currentHost.port === host.port
|
||||
&& currentHost.protocol === host.protocol
|
||||
&& currentHost.username === host.username
|
||||
&& currentHost.sftpSudo === host.sftpSudo
|
||||
&& (currentHost.sftpFileProtocol || "auto") === (host.sftpFileProtocol || "auto");
|
||||
const isSameEndpoint = isSameSftpHostSelection(currentHost, host);
|
||||
|
||||
const currentLayout = sidePanelLayoutsRef.current.get(tabId);
|
||||
const paneCount = currentLayout
|
||||
@@ -921,6 +1078,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
next.set(tabId, host);
|
||||
return next;
|
||||
});
|
||||
setSftpHostSourceSessionForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
if (originSessionId) next.set(tabId, originSessionId);
|
||||
else next.delete(tabId);
|
||||
return next;
|
||||
});
|
||||
if (originSessionId) {
|
||||
const sessionHost = sessionHostsMapRef.current.get(originSessionId);
|
||||
if (sessionHost && (host.username !== sessionHost.username || host.identityId !== sessionHost.identityId)) {
|
||||
setSftpAuthHostBySessionId(prev => new Map(prev).set(originSessionId, host));
|
||||
}
|
||||
}
|
||||
|
||||
const { connectionKey, effectiveInitialPath } = resolveSftpOpenTarget({
|
||||
tabId,
|
||||
@@ -1455,16 +1624,35 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sftpPaneClosedTabIdsRef.current.delete(tabId);
|
||||
}
|
||||
|
||||
if (tab === 'sftp') {
|
||||
const sourceSessionId = getActiveTerminalSessionId();
|
||||
const sessionHost = sourceSessionId ? sessionHostsMapRef.current.get(sourceSessionId) : undefined;
|
||||
const authHost = sourceSessionId ? sftpAuthHostBySessionId.get(sourceSessionId) : undefined;
|
||||
const storedHost = sftpHostForTabRef.current.get(tabId);
|
||||
if (sourceSessionId && sessionHost && authHost
|
||||
&& (!storedHost || sftpHostSourceSessionForTabRef.current.has(tabId)
|
||||
|| (storedHost.id === sessionHost.id
|
||||
&& storedHost.hostname === sessionHost.hostname
|
||||
&& (storedHost.port || 22) === (sessionHost.port || 22)))) {
|
||||
setSftpHostForTab(prev => new Map(prev).set(tabId, authHost));
|
||||
setSftpHostSourceSessionForTab(prev => new Map(prev).set(tabId, sourceSessionId));
|
||||
}
|
||||
}
|
||||
|
||||
// If switching to SFTP and no host is stored yet, resolve it
|
||||
if (tab === 'sftp' && !sftpHostForTabRef.current.has(tabId)) {
|
||||
const host = resolveSftpHostForTab(tabId);
|
||||
const sourceSessionId = getActiveTerminalSessionId();
|
||||
const host = (sourceSessionId && sftpAuthHostBySessionId.get(sourceSessionId))
|
||||
?? resolveSftpHostForTab(tabId);
|
||||
if (!host) return;
|
||||
if (sourceSessionId) {
|
||||
setSftpHostSourceSessionForTab(prev => new Map(prev).set(tabId, sourceSessionId));
|
||||
}
|
||||
setSftpHostForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(tabId, host);
|
||||
return next;
|
||||
});
|
||||
const sourceSessionId = getActiveTerminalSessionId();
|
||||
const { effectiveInitialPath } = resolveSftpOpenTarget({
|
||||
tabId,
|
||||
host,
|
||||
@@ -1486,7 +1674,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
// explicitly closes the now-idle panel, preserving the completed history.
|
||||
markSidePanelSubTabOpened(tabId, tab);
|
||||
return true;
|
||||
}, [getActiveTerminalSessionId, markSidePanelSubTabOpened, resolveSftpHostForTab, resolveSftpOpenTarget]);
|
||||
}, [getActiveTerminalSessionId, markSidePanelSubTabOpened, resolveSftpHostForTab, resolveSftpOpenTarget, sftpAuthHostBySessionId]);
|
||||
|
||||
// Switch only the focused pane. If the tool is already open elsewhere in
|
||||
// the tree, the domain helper focuses that pane instead of duplicating it.
|
||||
@@ -2307,6 +2495,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleSftpActiveExternalEditsChange,
|
||||
handleSftpInitialLocationApplied,
|
||||
persistSidePanelWidth,
|
||||
persistSidePanelHeight,
|
||||
handleSnippetClickForFocusedSession,
|
||||
handleSnippetFromPanel,
|
||||
handleRunScriptFromPanel,
|
||||
@@ -2420,20 +2609,26 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setSidePanelOpenTabs,
|
||||
setSidePanelLayouts,
|
||||
setSidePanelWidth,
|
||||
setSidePanelHeight,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpHostForTab,
|
||||
setSftpHostSourceSessionForTab,
|
||||
setSftpInitialLocationForTab,
|
||||
onSaveWorkspaceLayoutAsDefault: handleSaveWorkspaceLayoutAsDefault,
|
||||
setSftpPendingUploadsForTab,
|
||||
showHostTreeSidebar,
|
||||
sidePanelOpenTabs,
|
||||
sidePanelLayouts,
|
||||
sidePanelPosition,
|
||||
sidePanelWidth,
|
||||
sidePanelHeight,
|
||||
sftpAutoSync,
|
||||
sftpDefaultViewMode,
|
||||
sftpDoubleClickBehavior,
|
||||
sftpFollowTerminalCwd,
|
||||
sftpHostForTab,
|
||||
sftpHostSourceSessionForTab,
|
||||
sftpAuthHostBySessionId,
|
||||
sftpInitialLocationForTab,
|
||||
sftpPendingUploadsForTab,
|
||||
sftpPaneClosedTabIdsRef,
|
||||
|
||||
@@ -1202,9 +1202,13 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fixed right controls — utility icons + window controls share one h-7 row */}
|
||||
{/* Keep window controls above the top drag stripe so their full height remains clickable. */}
|
||||
<div
|
||||
className={cn("flex-shrink-0 flex items-center gap-0.5 app-drag h-7 overflow-visible", tabsAtBottom ? "self-start" : "self-end")}
|
||||
className={cn(
|
||||
"flex-shrink-0 flex items-center gap-0.5 app-drag overflow-visible",
|
||||
showWindowControls ? "relative z-20 h-9" : "h-7",
|
||||
tabsAtBottom ? "self-start" : "self-end",
|
||||
)}
|
||||
style={dragRegionStyle}
|
||||
data-section="top-tabs-toolbar-actions"
|
||||
>
|
||||
|
||||
@@ -14,5 +14,8 @@ test("host context menu offers dual-pane SFTP without extra prop drilling", () =
|
||||
assert.match(source, /requestOpenDualPaneSftp/);
|
||||
assert.match(source, /vault\.hosts\.openSftp/);
|
||||
assert.match(source, /useSettingsChromeStore/);
|
||||
assert.match(source, /!showSftpTab\s*\|\|\s*!canOpenDualPaneSftp/);
|
||||
assert.match(
|
||||
source,
|
||||
/\(\s*!showSftpTab\s*&&\s*!sftpInSidebar\s*\)\s*\|\|\s*!canOpenDualPaneSftp/,
|
||||
);
|
||||
});
|
||||
@@ -24,8 +24,9 @@ export interface HostTreeHostContextMenuHandlers {
|
||||
|
||||
export const OpenDualPaneSftpMenuItem: React.FC<{ host: Host }> = ({ host }) => {
|
||||
const { t } = useI18n();
|
||||
const { showSftpTab } = useSettingsChromeStore();
|
||||
if (!showSftpTab || !canOpenDualPaneSftp(host)) return null;
|
||||
const { showSftpTab, sftpInSidebar } = useSettingsChromeStore();
|
||||
// Effective availability: SFTP is reachable as a top tab or in the sidebar.
|
||||
if ((!showSftpTab && !sftpInSidebar) || !canOpenDualPaneSftp(host)) return null;
|
||||
return (
|
||||
<ContextMenuItem onClick={() => requestOpenDualPaneSftp(host.id)}>
|
||||
<Files className="mr-2 h-4 w-4" /> {t('vault.hosts.openSftp')}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
import { detectKeyType } from './utils';
|
||||
import { detectSshKeyType } from '../../domain/keyTypeDetect';
|
||||
|
||||
interface ImportKeyPanelProps {
|
||||
draftKey: Partial<SSHKey>;
|
||||
@@ -38,14 +38,15 @@ export const ImportKeyPanel: React.FC<ImportKeyPanelProps> = ({
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
if (content) {
|
||||
const detectedType = detectKeyType(content);
|
||||
const detected = detectSshKeyType(content);
|
||||
const label = file.name.replace(/\.(pem|key|pub|ppk)$/i, '');
|
||||
|
||||
setDraftKey({
|
||||
...draftKey,
|
||||
privateKey: content,
|
||||
label: draftKey.label || label,
|
||||
type: detectedType,
|
||||
type: detected.type,
|
||||
keySize: detected.keySize,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -64,14 +65,15 @@ export const ImportKeyPanel: React.FC<ImportKeyPanelProps> = ({
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
if (content) {
|
||||
const detectedType = detectKeyType(content);
|
||||
const detected = detectSshKeyType(content);
|
||||
const label = file.name.replace(/\.(pem|key|pub|ppk)$/i, '');
|
||||
|
||||
setDraftKey({
|
||||
...draftKey,
|
||||
privateKey: content,
|
||||
label: draftKey.label || label,
|
||||
type: detectedType,
|
||||
type: detected.type,
|
||||
keySize: detected.keySize,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { BadgeCheck, Key } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { logger } from '../../lib/logger';
|
||||
import { detectSshKeyType } from '../../domain/keyTypeDetect';
|
||||
import { KeyType, SSHKey } from '../../types';
|
||||
|
||||
/**
|
||||
@@ -27,10 +28,7 @@ export const getKeyTypeDisplay = (key: SSHKey, isMac: boolean): string => {
|
||||
* Detect key type from private key content
|
||||
*/
|
||||
export const detectKeyType = (privateKey: string): KeyType => {
|
||||
const pk = privateKey.toLowerCase();
|
||||
if (pk.includes('rsa')) return 'RSA';
|
||||
if (pk.includes('ecdsa') || pk.includes('ec ')) return 'ECDSA';
|
||||
return 'ED25519';
|
||||
return detectSshKeyType(privateKey).type;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,6 +63,8 @@ function SettingsAppearanceTab(props: {
|
||||
tabBarPosition: 'top' | 'bottom';
|
||||
setTabBarPosition: (position: 'top' | 'bottom') => void;
|
||||
showSftpTab: boolean;
|
||||
sftpInSidebar: boolean;
|
||||
setSftpInSidebar: (enabled: boolean) => void;
|
||||
setShowSftpTab: (enabled: boolean) => void;
|
||||
showHostTreeSidebar: boolean;
|
||||
setShowHostTreeSidebar: (enabled: boolean) => void;
|
||||
@@ -134,6 +136,8 @@ function SettingsAppearanceTab(props: {
|
||||
tabBarPosition,
|
||||
setTabBarPosition,
|
||||
showSftpTab,
|
||||
sftpInSidebar,
|
||||
setSftpInSidebar,
|
||||
setShowSftpTab,
|
||||
showHostTreeSidebar,
|
||||
setShowHostTreeSidebar,
|
||||
@@ -513,6 +517,13 @@ function SettingsAppearanceTab(props: {
|
||||
>
|
||||
<Toggle checked={showSftpTab} onChange={setShowSftpTab} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="appearance-vault-sftp-in-sidebar"
|
||||
label={t('settings.vault.sftpInSidebar')}
|
||||
description={t('settings.vault.sftpInSidebarDesc')}
|
||||
>
|
||||
<Toggle checked={sftpInSidebar} onChange={setSftpInSidebar} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="appearance-vault-host-tree"
|
||||
label={t('settings.vault.showHostTreeSidebar')}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
createDomRenderer,
|
||||
dispatchDomEvent,
|
||||
flushEffects,
|
||||
installDomEnvironment,
|
||||
} from "../test-support/renderReactDom.tsx";
|
||||
|
||||
const cases = [
|
||||
{ name: "file", isDirectory: false, existingType: "file", action: "replace", label: "Replace" },
|
||||
{ name: "folder", isDirectory: true, existingType: "directory", action: "merge", label: "Merge" },
|
||||
{ name: "folder with a file target", isDirectory: true, existingType: "file", action: "duplicate", label: "Duplicate" },
|
||||
{ name: "folder with unknown target type", isDirectory: true, existingType: undefined, action: "duplicate", label: "Duplicate" },
|
||||
{ name: "legacy file with unknown target type", isDirectory: false, existingType: undefined, action: "duplicate", label: "Duplicate" },
|
||||
] as const;
|
||||
|
||||
for (const scenario of cases) {
|
||||
test(`initial ${scenario.name} conflict focuses ${scenario.label} and runs that action`, async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
const previousMutationObserver = globalThis.MutationObserver;
|
||||
const previousNodeFilter = globalThis.NodeFilter;
|
||||
const previousHTMLInputElement = globalThis.HTMLInputElement;
|
||||
Object.defineProperty(globalThis, "MutationObserver", { configurable: true, writable: true, value: env.window.MutationObserver });
|
||||
Object.defineProperty(globalThis, "NodeFilter", { configurable: true, writable: true, value: env.window.NodeFilter });
|
||||
Object.defineProperty(globalThis, "HTMLInputElement", { configurable: true, writable: true, value: env.window.HTMLInputElement });
|
||||
const { I18nProvider } = await import("../../application/i18n/I18nProvider.tsx");
|
||||
const { SftpConflictDialog } = await import("./SftpConflictDialog.tsx");
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
const resolved: Array<{ id: string; action: string }> = [];
|
||||
t.after(async () => {
|
||||
await renderer.unmount();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
Object.defineProperty(globalThis, "MutationObserver", { configurable: true, writable: true, value: previousMutationObserver });
|
||||
Object.defineProperty(globalThis, "NodeFilter", { configurable: true, writable: true, value: previousNodeFilter });
|
||||
Object.defineProperty(globalThis, "HTMLInputElement", { configurable: true, writable: true, value: previousHTMLInputElement });
|
||||
env.cleanup();
|
||||
});
|
||||
|
||||
await renderer.render(React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(SftpConflictDialog, {
|
||||
conflicts: [{
|
||||
transferId: scenario.name,
|
||||
fileName: "test.txt",
|
||||
sourcePath: "/src/test.txt",
|
||||
targetPath: "/dst/test.txt",
|
||||
isDirectory: scenario.isDirectory,
|
||||
existingType: scenario.existingType,
|
||||
existingSize: 1,
|
||||
newSize: 2,
|
||||
existingModified: 1,
|
||||
newModified: 2,
|
||||
}],
|
||||
onResolve: (id, action) => resolved.push({ id, action }),
|
||||
formatFileSize: (size: number) => `${size} B`,
|
||||
}),
|
||||
));
|
||||
await flushEffects();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
const focused = env.document.activeElement;
|
||||
assert.equal(focused?.textContent, scenario.label);
|
||||
assert.ok(focused, "a conflict action should receive focus");
|
||||
await dispatchDomEvent(focused, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
assert.deepEqual(resolved, [{ id: scenario.name, action: scenario.action }]);
|
||||
});
|
||||
}
|
||||
@@ -107,6 +107,11 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
const conflict = conflicts[0]; // Handle first conflict
|
||||
const currentCanMerge = conflict?.isDirectory === true && conflict.existingType === 'directory';
|
||||
const currentCanReplace = conflict ? canReplaceConflict(conflict) : false;
|
||||
const preferredActionRef = currentCanMerge
|
||||
? mergeButtonRef
|
||||
: currentCanReplace && conflict?.existingType
|
||||
? replaceButtonRef
|
||||
: duplicateButtonRef;
|
||||
|
||||
useEffect(() => {
|
||||
const currentConflictId = conflict?.transferId;
|
||||
@@ -114,11 +119,7 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
previousConflictIdRef.current = currentConflictId;
|
||||
if (!currentConflictId || !previousConflictId || currentConflictId === previousConflictId) return;
|
||||
|
||||
const nextAction = currentCanMerge
|
||||
? mergeButtonRef.current
|
||||
: currentCanReplace
|
||||
? replaceButtonRef.current
|
||||
: duplicateButtonRef.current;
|
||||
const nextAction = preferredActionRef.current;
|
||||
if (!nextAction) return;
|
||||
|
||||
// If the previously focused action disappears or becomes disabled,
|
||||
@@ -130,7 +131,7 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
}
|
||||
const timer = globalThis.setTimeout(() => nextAction.focus(), 0);
|
||||
return () => globalThis.clearTimeout(timer);
|
||||
}, [conflict?.transferId, currentCanMerge, currentCanReplace]);
|
||||
}, [conflict?.transferId, preferredActionRef]);
|
||||
|
||||
if (!conflict) return null;
|
||||
|
||||
@@ -160,6 +161,10 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
<DialogContent
|
||||
className="gap-4 p-5 sm:max-w-[600px] sm:p-6"
|
||||
aria-describedby={describedBy}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
preferredActionRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="space-y-1.5 pr-8">
|
||||
<DialogTitle
|
||||
@@ -262,7 +267,6 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
|
||||
size="sm"
|
||||
onClick={() => handleAction('merge')}
|
||||
disabled={!canMerge}
|
||||
autoFocus={presentation.showDirectoryReplaceWarning}
|
||||
className="min-w-24 border border-primary"
|
||||
>
|
||||
{t('sftp.conflict.action.merge')}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { toast } from "../../ui/toast";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { getFileExtension, getLanguageId, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
import { isNavigableDirectory } from "../utils";
|
||||
import { resolveDownloadSourceSnapshot } from "../../../application/state/sftp/downloadSourceSnapshot";
|
||||
import { reportSftpUploadResults } from "../reportSftpUploadResults";
|
||||
import { editorTabStore } from "../../../application/state/editorTabStore";
|
||||
import { toEditorTabId, activeTabStore } from "../../../application/state/activeTabStore";
|
||||
@@ -36,6 +37,8 @@ export const useSftpViewFileOps = ({
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
getSftpIdForConnection,
|
||||
statSftp,
|
||||
listSftp,
|
||||
}: UseSftpViewFileOpsParams): UseSftpViewFileOpsResult => {
|
||||
const [permissionsState, setPermissionsState] = useState<{
|
||||
file: SftpFileEntry;
|
||||
@@ -404,7 +407,7 @@ export const useSftpViewFileOps = ({
|
||||
if (!pane.connection) return;
|
||||
|
||||
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
const isDirectory = isNavigableDirectory(file);
|
||||
let isDirectory = isNavigableDirectory(file);
|
||||
|
||||
try {
|
||||
// For local files, use blob download.
|
||||
@@ -442,6 +445,18 @@ export const useSftpViewFileOps = ({
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
// The listed entry can be stale when the file was deleted / recreated
|
||||
// in the terminal without refreshing the side panel. Re-stat so the
|
||||
// planned snapshot size and entry type match the real source.
|
||||
const sourceSnapshot = await resolveDownloadSourceSnapshot(
|
||||
statSftp,
|
||||
sftpId,
|
||||
resolvedFullPath,
|
||||
pane.filenameEncoding,
|
||||
listSftp,
|
||||
);
|
||||
isDirectory = sourceSnapshot.isDirectory;
|
||||
|
||||
if (isDirectory) {
|
||||
if (!selectDirectory) {
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
@@ -451,6 +466,15 @@ export const useSftpViewFileOps = ({
|
||||
const selectedDirectory = await selectDirectory(t("sftp.context.download"));
|
||||
if (!selectedDirectory) return;
|
||||
|
||||
// The picker may stay open while the remote entry changes again.
|
||||
const selectedSnapshot = await resolveDownloadSourceSnapshot(
|
||||
statSftp,
|
||||
sftpId, resolvedFullPath, pane.filenameEncoding,
|
||||
listSftp,
|
||||
);
|
||||
if (!selectedSnapshot.isDirectory) {
|
||||
throw new Error("Remote source changed while choosing the download target");
|
||||
}
|
||||
const targetPath = joinFsPath(selectedDirectory, file.name);
|
||||
|
||||
try {
|
||||
@@ -481,14 +505,17 @@ export const useSftpViewFileOps = ({
|
||||
|
||||
return;
|
||||
}
|
||||
// Show save dialog to get target path
|
||||
const targetPath = await showSaveDialog(file.name);
|
||||
if (!targetPath) {
|
||||
// User cancelled
|
||||
return;
|
||||
}
|
||||
if (!targetPath) return;
|
||||
|
||||
const fileSize = typeof file.size === "string" ? parseInt(file.size, 10) || 0 : (file.size || 0);
|
||||
const selectedSnapshot = await resolveDownloadSourceSnapshot(
|
||||
statSftp,
|
||||
sftpId, resolvedFullPath, pane.filenameEncoding,
|
||||
listSftp,
|
||||
);
|
||||
if (selectedSnapshot.isDirectory) {
|
||||
throw new Error("Remote source changed while choosing the download target");
|
||||
}
|
||||
// Route through downloadToLocal so FileZilla-style transfer pool
|
||||
// sessions are used (browse session stays free for listing).
|
||||
const status = await sftpRef.current.downloadToLocal({
|
||||
@@ -501,7 +528,7 @@ export const useSftpViewFileOps = ({
|
||||
sourceHostLabel: pane.connection.hostLabel,
|
||||
sourceEncoding: pane.filenameEncoding,
|
||||
isDirectory: false,
|
||||
totalBytes: fileSize,
|
||||
totalBytes: selectedSnapshot.size,
|
||||
});
|
||||
if (status === "completed") {
|
||||
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
|
||||
@@ -523,6 +550,8 @@ export const useSftpViewFileOps = ({
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
getSftpIdForConnection,
|
||||
statSftp,
|
||||
listSftp,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -633,8 +662,16 @@ export const useSftpViewFileOps = ({
|
||||
try {
|
||||
const sourcePath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
const targetPath = joinFsPath(selectedDirectory, file.name);
|
||||
const isDirectory = isNavigableDirectory(file);
|
||||
const fileSize = typeof file.size === "string" ? parseInt(file.size, 10) || 0 : (file.size || 0);
|
||||
// Re-stat each root: the listed entries can be stale after
|
||||
// terminal-side delete + recreate without a refresh.
|
||||
const sourceSnapshot = await resolveDownloadSourceSnapshot(
|
||||
statSftp,
|
||||
sftpId,
|
||||
sourcePath,
|
||||
pane.filenameEncoding,
|
||||
listSftp,
|
||||
);
|
||||
const isDirectory = sourceSnapshot.isDirectory;
|
||||
|
||||
const status = await sftpRef.current.downloadToLocal({
|
||||
fileName: file.name,
|
||||
@@ -646,7 +683,7 @@ export const useSftpViewFileOps = ({
|
||||
sourceHostLabel: pane.connection.hostLabel,
|
||||
sourceEncoding: pane.filenameEncoding,
|
||||
isDirectory,
|
||||
totalBytes: isDirectory ? undefined : fileSize,
|
||||
totalBytes: isDirectory ? undefined : sourceSnapshot.size,
|
||||
});
|
||||
results[index] = { status: "fulfilled", value: { file, status } };
|
||||
} catch (reason) {
|
||||
@@ -679,6 +716,8 @@ export const useSftpViewFileOps = ({
|
||||
t,
|
||||
selectDirectory,
|
||||
getSftpIdForConnection,
|
||||
statSftp,
|
||||
listSftp,
|
||||
handleDownloadFileForSide,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type React from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import type { RemoteFile, SftpFileEntry, SftpFilenameEncoding } from "../../../types";
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
import type { TextEditorModalSnapshot } from "../../TextEditorModal";
|
||||
@@ -21,6 +21,9 @@ export interface UseSftpViewFileOpsParams {
|
||||
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
|
||||
selectDirectory?: (title?: string, defaultPath?: string) => Promise<string | null>;
|
||||
getSftpIdForConnection?: (connectionId: string) => string | undefined;
|
||||
/** Application-layer fresh stat for download source snapshots (kept off the raw bridge). */
|
||||
statSftp?: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpStatResult>;
|
||||
listSftp?: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<RemoteFile[]>;
|
||||
}
|
||||
|
||||
export interface UseSftpViewFileOpsResult {
|
||||
|
||||
@@ -28,6 +28,7 @@ interface UseSftpViewPaneCallbacksParams {
|
||||
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
|
||||
selectDirectory?: (title?: string, defaultPath?: string) => Promise<string | null>;
|
||||
getSftpIdForConnection?: (connectionId: string) => string | undefined;
|
||||
statSftp?: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpStatResult>;
|
||||
listLocalFiles: (path: string) => Promise<RemoteFile[]>;
|
||||
mkdirLocal?: (path: string) => Promise<void>;
|
||||
deleteLocalFile?: (path: string) => Promise<void>;
|
||||
@@ -45,6 +46,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
getSftpIdForConnection,
|
||||
statSftp,
|
||||
listLocalFiles,
|
||||
listDrives,
|
||||
}: UseSftpViewPaneCallbacksParams) => {
|
||||
@@ -59,6 +61,8 @@ export const useSftpViewPaneCallbacks = ({
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
getSftpIdForConnection,
|
||||
statSftp,
|
||||
listSftp,
|
||||
});
|
||||
|
||||
const listLocalFilesRef = useRef(listLocalFiles);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import type { SftpFileEntry } from "../../types";
|
||||
import type { SftpStateApi } from "../../application/state/useSftpState";
|
||||
import { useSftpViewFileOps } from "./hooks/useSftpViewFileOps";
|
||||
|
||||
const file = (
|
||||
name: string, size: number, type: SftpFileEntry["type"] = "file",
|
||||
linkTarget?: SftpFileEntry["linkTarget"],
|
||||
): SftpFileEntry => ({
|
||||
name, size, type, linkTarget, lastModified: 0, sizeFormatted: "", lastModifiedFormatted: "",
|
||||
});
|
||||
|
||||
test("single and batch downloads route by current remote type and size", async () => {
|
||||
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const current = new Map<string, SftpStatResult>([
|
||||
["/remote/smaller", { name: "smaller", size: 50_000, sizeKnown: true, type: "file", lastModified: 0 }],
|
||||
["/remote/larger", { name: "larger", size: 50_000, sizeKnown: true, type: "file", lastModified: 0 }],
|
||||
["/remote/became-dir", { name: "became-dir", size: 0, sizeKnown: true, type: "directory", lastModified: 0 }],
|
||||
["/remote/became-file", { name: "became-file", size: 12, sizeKnown: true, type: "file", lastModified: 0 }],
|
||||
["/remote/changes-in-picker", { name: "changes-in-picker", size: 15, sizeKnown: true, type: "file", lastModified: 0 }],
|
||||
["/remote/type-changes-in-picker", { name: "type-changes-in-picker", size: 15, sizeKnown: true, type: "file", lastModified: 0 }],
|
||||
["/remote/statless", { name: "statless", size: 0, sizeKnown: false, type: "file", lastModified: 0 }],
|
||||
["/remote/file-link", { name: "file-link", size: 9, sizeKnown: true, type: "symlink", lastModified: 0 }],
|
||||
["/remote/dir-link", { name: "dir-link", size: 9, sizeKnown: true, type: "symlink", lastModified: 0 }],
|
||||
]);
|
||||
const downloads: Array<{ sourcePath: string; totalBytes?: number; isDirectory: boolean }> = [];
|
||||
let saveCalls = 0;
|
||||
let directoryCalls = 0;
|
||||
let onSave: (name: string) => void = () => undefined;
|
||||
const connection = { id: "conn-1", hostId: "host-1", hostLabel: "Host", currentPath: "/remote", isLocal: false };
|
||||
const pane = { connection, filenameEncoding: "auto" };
|
||||
const sftpRef = { current: {
|
||||
leftPane: pane, rightPane: pane,
|
||||
joinPath: (parent: string, name: string) => `${parent}/${name}`,
|
||||
downloadToLocal: async (params: { sourcePath: string; totalBytes?: number; isDirectory: boolean }) => {
|
||||
downloads.push(params);
|
||||
return "completed";
|
||||
},
|
||||
} as unknown as SftpStateApi };
|
||||
let ops: ReturnType<typeof useSftpViewFileOps> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpViewFileOps({
|
||||
sftpRef,
|
||||
behaviorRef: { current: "" },
|
||||
autoSyncRef: { current: false },
|
||||
getOpenerForFileRef: { current: () => null },
|
||||
setOpenerForExtension: () => undefined,
|
||||
t: (key) => key,
|
||||
showSaveDialog: async (name) => { saveCalls++; onSave(name); return `/downloads/${name}`; },
|
||||
selectDirectory: async () => { directoryCalls++; return "/downloads"; },
|
||||
getSftpIdForConnection: () => "sftp-1",
|
||||
statSftp: async (_id, path) => current.get(path) ?? null as unknown as SftpStatResult,
|
||||
listSftp: async (_id, parent) => {
|
||||
assert.equal(parent, "/remote");
|
||||
return [
|
||||
{ name: "file-link", type: "symlink", linkTarget: "file", size: "9 bytes", lastModified: "" },
|
||||
{ name: "dir-link", type: "symlink", linkTarget: "directory", size: "9 bytes", lastModified: "" },
|
||||
];
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
const single = ops!.onDownloadFileLeft as unknown as (entry: SftpFileEntry) => Promise<void>;
|
||||
const batch = ops!.onDownloadFilesLeft as unknown as (entries: SftpFileEntry[]) => Promise<void>;
|
||||
|
||||
await act(async () => { await single(file("smaller", 100_000)); });
|
||||
await act(async () => { await single(file("larger", 25_000)); });
|
||||
assert.deepEqual(downloads.map(({ totalBytes }) => totalBytes), [50_000, 50_000]);
|
||||
assert.equal(saveCalls, 2, "save dialog still opens for every single-file download");
|
||||
|
||||
onSave = (name) => {
|
||||
if (name === "changes-in-picker") current.set("/remote/changes-in-picker", {
|
||||
name, size: 30, sizeKnown: true, type: "file", lastModified: 0,
|
||||
});
|
||||
if (name === "type-changes-in-picker") current.set("/remote/type-changes-in-picker", {
|
||||
name, size: 0, sizeKnown: true, type: "directory", lastModified: 0,
|
||||
});
|
||||
};
|
||||
await act(async () => { await single(file("changes-in-picker", 100)); });
|
||||
assert.equal(downloads.at(-1)?.totalBytes, 30, "size is checked again after Save As");
|
||||
const countBeforeTypeChange = downloads.length;
|
||||
await act(async () => { await single(file("type-changes-in-picker", 100)); });
|
||||
assert.equal(downloads.length, countBeforeTypeChange, "type changes during Save As abort the transfer");
|
||||
const countBeforeMissing = saveCalls;
|
||||
await act(async () => { await single(file("missing", 100_000)); });
|
||||
assert.equal(saveCalls, countBeforeMissing, "missing source never opens Save As");
|
||||
|
||||
await act(async () => { await single(file("statless", 100_000)); });
|
||||
assert.equal(downloads.at(-1)?.totalBytes, undefined, "stat-less SCP does not use listed size");
|
||||
await act(async () => { await single(file("file-link", 9, "symlink", "directory")); });
|
||||
assert.equal(downloads.at(-1)?.isDirectory, false);
|
||||
assert.equal(downloads.at(-1)?.totalBytes, undefined, "link node size is not file size");
|
||||
|
||||
await act(async () => { await single(file("became-dir", 100_000)); });
|
||||
assert.equal(downloads.at(-1)?.isDirectory, true);
|
||||
assert.equal(directoryCalls, 1);
|
||||
|
||||
await act(async () => {
|
||||
await batch([
|
||||
file("became-file", 0, "directory"),
|
||||
file("missing", 100_000),
|
||||
file("dir-link", 9, "symlink", "file"),
|
||||
]);
|
||||
});
|
||||
assert.equal(directoryCalls, 2, "batch chooses one directory");
|
||||
const becameFile = downloads.find(({ sourcePath }) => sourcePath === "/remote/became-file");
|
||||
assert.equal(becameFile?.isDirectory, false);
|
||||
assert.equal(becameFile?.totalBytes, 12);
|
||||
assert.equal(downloads.find(({ sourcePath }) => sourcePath === "/remote/dir-link")?.isDirectory, true);
|
||||
assert.equal(downloads.some(({ sourcePath }) => sourcePath === "/remote/missing"), false);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
import type { Identity } from '../../types';
|
||||
import type { Host } from '../../types';
|
||||
import type { PendingAuth } from './runtime/createTerminalSessionStarters';
|
||||
|
||||
test('saved password identity retries with its username and supports both submit paths', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
|
||||
pretendToBeVisual: true,
|
||||
url: 'http://localhost',
|
||||
});
|
||||
const { window } = dom;
|
||||
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
|
||||
const installGlobal = (key: string, value: unknown) => {
|
||||
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
};
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
for (const key of [
|
||||
'window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement',
|
||||
'HTMLTextAreaElement', 'Element', 'SVGElement', 'Node', 'NodeFilter',
|
||||
'MutationObserver', 'CustomEvent', 'DOMRect', 'Event', 'KeyboardEvent',
|
||||
'MouseEvent', 'PointerEvent',
|
||||
]) {
|
||||
installGlobal(key, key === 'window' ? window : window[key as keyof typeof window]);
|
||||
}
|
||||
installGlobal('getComputedStyle', window.getComputedStyle.bind(window));
|
||||
installGlobal('requestAnimationFrame', window.requestAnimationFrame.bind(window));
|
||||
installGlobal('cancelAnimationFrame', window.cancelAnimationFrame.bind(window));
|
||||
installGlobal('ResizeObserver', ResizeObserverStub);
|
||||
installGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../application/i18n/I18nProvider.tsx');
|
||||
const { TerminalAuthDialog } = await import('./TerminalAuthDialog.tsx');
|
||||
const { useTerminalAuthState } = await import('./hooks/useTerminalAuthState.ts');
|
||||
const identities: Identity[] = [
|
||||
{ id: 'saved', label: 'Password B', username: 'deploy', authMethod: 'password', password: 'saved-secret', created: 0 },
|
||||
{ id: 'unusable', label: 'Unreadable', username: 'root', authMethod: 'password', password: 'enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==', created: 1 },
|
||||
];
|
||||
const host: Host = {
|
||||
id: 'host', label: 'Host', hostname: 'localhost', port: 22, username: 'root',
|
||||
identityId: 'old', authMethod: 'password', password: 'wrong', tags: [], os: 'linux',
|
||||
};
|
||||
const submissions: Array<{ username?: string; password?: string; save?: boolean }> = [];
|
||||
const savedHosts: Host[] = [];
|
||||
const logs: string[][] = [];
|
||||
const Form = () => {
|
||||
const pendingAuthRef = React.useRef<PendingAuth>(null);
|
||||
const termRef = React.useRef({ clear() {} } as never);
|
||||
const auth = useTerminalAuthState({
|
||||
host, identities, pendingAuthRef, termRef,
|
||||
onUpdateHost: (updated) => savedHosts.push(updated),
|
||||
onStartSession: () => submissions.push({
|
||||
username: pendingAuthRef.current?.username,
|
||||
password: pendingAuthRef.current?.password,
|
||||
save: pendingAuthRef.current?.savedToHost,
|
||||
}),
|
||||
setStatus: () => {}, setProgressLogs: (next) => {
|
||||
logs.push(typeof next === 'function' ? next([]) : next);
|
||||
},
|
||||
});
|
||||
return <TerminalAuthDialog
|
||||
authMethod={auth.authMethod} setAuthMethod={auth.setAuthMethod}
|
||||
authUsername={auth.authUsername} setAuthUsername={auth.setAuthUsername}
|
||||
authPassword={auth.authPassword} setAuthPassword={auth.setAuthPassword}
|
||||
selectedIdentityId={auth.selectedIdentityId} onSelectIdentity={auth.selectIdentity}
|
||||
authKeyId={auth.authKeyId} setAuthKeyId={auth.setAuthKeyId}
|
||||
authPassphrase={auth.authPassphrase} setAuthPassphrase={auth.setAuthPassphrase}
|
||||
showAuthPassphrase={auth.showAuthPassphrase} setShowAuthPassphrase={auth.setShowAuthPassphrase}
|
||||
showAuthPassword={auth.showAuthPassword} setShowAuthPassword={auth.setShowAuthPassword}
|
||||
authRetryMessage="Authentication failed" keys={[]} identities={identities}
|
||||
onSubmit={() => auth.submit({ saveToHost: true })}
|
||||
onSubmitWithoutSave={() => auth.submit({ saveToHost: false })}
|
||||
onCancel={() => {}} isValid={auth.isValid}
|
||||
/>;
|
||||
};
|
||||
const root = createRoot(window.document.getElementById('root')!);
|
||||
const button = (label: string) => (Array.from(window.document.querySelectorAll('button')) as HTMLButtonElement[])
|
||||
.find(item => item.textContent?.includes(label));
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider locale="en"><Form /></I18nProvider>));
|
||||
assert.ok(button('Use saved identity'));
|
||||
await act(async () => button('Use saved identity')!.click());
|
||||
assert.ok(button('Password B'));
|
||||
assert.equal(button('Unreadable'), undefined);
|
||||
await act(async () => button('Password B')!.click());
|
||||
assert.equal((window.document.getElementById('auth-username') as HTMLInputElement).value, 'deploy');
|
||||
assert.equal((window.document.getElementById('auth-password') as HTMLInputElement).value, '');
|
||||
assert.equal(window.document.body.textContent?.includes('saved-secret'), false);
|
||||
assert.equal(window.document.querySelector<HTMLInputElement>('#auth-password')?.outerHTML.includes('saved-secret'), false);
|
||||
assert.equal((window.document.querySelector('#auth-password + button') as HTMLButtonElement).disabled, true);
|
||||
await act(async () => button('Continue')!.click());
|
||||
assert.deepEqual(submissions.at(-1), { username: 'deploy', password: 'saved-secret', save: false });
|
||||
assert.equal(savedHosts.length, 0);
|
||||
await act(async () => window.document.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]')!.click());
|
||||
await act(async () => button('Continue & Save')!.click());
|
||||
assert.deepEqual(submissions.at(-1), { username: 'deploy', password: 'saved-secret', save: true });
|
||||
assert.equal(savedHosts.at(-1)?.identityId, 'saved');
|
||||
assert.equal(savedHosts.at(-1)?.password, undefined);
|
||||
assert.equal(savedHosts.at(-1)?.savePassword, true);
|
||||
assert.equal(logs.flat().some((line) => line.includes('saved-secret')), false);
|
||||
const passwordInput = window.document.getElementById('auth-password') as HTMLInputElement;
|
||||
const setInputValue = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
|
||||
assert.ok(setInputValue);
|
||||
await act(async () => {
|
||||
setInputValue.call(passwordInput, 'manual-secret');
|
||||
passwordInput.dispatchEvent(new window.Event('input', { bubbles: true }));
|
||||
});
|
||||
assert.ok(button('Use saved identity'), 'manual edit must clear the saved identity label');
|
||||
await act(async () => button('Continue')!.click());
|
||||
assert.deepEqual(submissions.at(-1), { username: 'deploy', password: 'manual-secret', save: false });
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
window.close();
|
||||
for (const [key, descriptor] of previousGlobals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -2,11 +2,12 @@
|
||||
* Terminal Authentication Dialog
|
||||
* Displays auth form with password/key selection for SSH connection
|
||||
*/
|
||||
import { BadgeCheck, ChevronDown, Eye, EyeOff, Key, Lock, Unplug } from 'lucide-react';
|
||||
import { BadgeCheck, ChevronDown, Eye, EyeOff, Key, Lock, Unplug, UserRound } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { SSHKey } from '../../types';
|
||||
import { Identity, SSHKey } from '../../types';
|
||||
import { listPasswordAuthIdentities } from '../../domain/authIdentityPicker';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
@@ -32,6 +33,10 @@ export interface TerminalAuthDialogProps {
|
||||
setShowAuthPassword: (show: boolean) => void;
|
||||
authRetryMessage: string | null;
|
||||
keys: SSHKey[];
|
||||
/** Keychain password identities offered for reuse on re-authentication (#3475). */
|
||||
identities?: Identity[];
|
||||
selectedIdentityId?: string | null;
|
||||
onSelectIdentity?: (identityId: string) => void;
|
||||
onSubmit: () => void;
|
||||
onSubmitWithoutSave?: () => void;
|
||||
onCancel: () => void;
|
||||
@@ -55,6 +60,9 @@ export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
|
||||
setShowAuthPassword,
|
||||
authRetryMessage,
|
||||
keys,
|
||||
identities,
|
||||
selectedIdentityId,
|
||||
onSelectIdentity,
|
||||
onSubmit,
|
||||
onSubmitWithoutSave,
|
||||
onCancel,
|
||||
@@ -76,9 +84,25 @@ export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
|
||||
|
||||
const [keyDropdownOpen, setKeyDropdownOpen] = React.useState(false);
|
||||
const [submitOptionsOpen, setSubmitOptionsOpen] = React.useState(false);
|
||||
const [identityDropdownOpen, setIdentityDropdownOpen] = React.useState(false);
|
||||
|
||||
const selectedKey = authKeyId ? keys.find((k) => k.id === authKeyId) : null;
|
||||
|
||||
// Password identities reusable on re-authentication (#3475). Only rendered
|
||||
// when at least one usable saved password exists; manual entry stays.
|
||||
const passwordIdentities = React.useMemo(
|
||||
() => listPasswordAuthIdentities(identities),
|
||||
[identities],
|
||||
);
|
||||
const selectedIdentity = selectedIdentityId
|
||||
? passwordIdentities.find((identity) => identity.id === selectedIdentityId && identity.username === authUsername)
|
||||
: null;
|
||||
|
||||
const handleSelectIdentity = (identity: Identity) => {
|
||||
onSelectIdentity?.(identity.id);
|
||||
setIdentityDropdownOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Auth method tabs */}
|
||||
@@ -139,9 +163,9 @@ export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
|
||||
<Input
|
||||
id="auth-password"
|
||||
type={showAuthPassword ? 'text' : 'password'}
|
||||
value={authPassword}
|
||||
value={selectedIdentity ? '' : authPassword}
|
||||
onChange={(e) => setAuthPassword(e.target.value)}
|
||||
placeholder={t("terminal.auth.password.placeholder")}
|
||||
placeholder={selectedIdentity ? t("terminal.auth.selectIdentity") : t("terminal.auth.password.placeholder")}
|
||||
className={cn("pr-10", authRetryMessage && "border-destructive/50")}
|
||||
autoFocus={!!authRetryMessage}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -150,10 +174,73 @@ export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowAuthPassword(!showAuthPassword)}
|
||||
disabled={Boolean(selectedIdentity)}
|
||||
>
|
||||
{showAuthPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
{onSelectIdentity && passwordIdentities.length > 0 && (
|
||||
<Popover open={identityDropdownOpen} onOpenChange={setIdentityDropdownOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 px-3 py-2 rounded-lg border transition-colors text-left",
|
||||
selectedIdentity
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded-md flex items-center justify-center shrink-0 bg-primary/20 text-primary">
|
||||
<UserRound size={12} />
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 min-w-0 truncate text-sm",
|
||||
selectedIdentity ? "font-medium" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{selectedIdentity
|
||||
? selectedIdentity.label
|
||||
: t("terminal.auth.selectIdentity")}
|
||||
</span>
|
||||
{selectedIdentity?.username && (
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{selectedIdentity.username}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown size={14} className="text-muted-foreground shrink-0" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-1" align="start" style={{ width: 'var(--radix-popover-trigger-width)' }}>
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{passwordIdentities.map((identity) => (
|
||||
<button
|
||||
key={identity.id}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2 rounded-md transition-colors text-left",
|
||||
selectedIdentity?.id === identity.id
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-secondary/80"
|
||||
)}
|
||||
onClick={() => handleSelectIdentity(identity)}
|
||||
>
|
||||
<div className="h-7 w-7 rounded-md flex items-center justify-center shrink-0 bg-primary/20 text-primary">
|
||||
<UserRound size={12} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{identity.label}</div>
|
||||
{identity.username && (
|
||||
<div className="text-xs text-muted-foreground truncate">{identity.username}</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">{'\u2022'.repeat(8)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -453,6 +453,7 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
return (
|
||||
<div
|
||||
className="flex-shrink-0 flex flex-col"
|
||||
data-section="terminal-compose-bar"
|
||||
style={{
|
||||
height: barHeight,
|
||||
backgroundColor: theme.resolvedBg,
|
||||
|
||||
@@ -259,3 +259,57 @@ test("disconnected observer surfaces do not advertise a reconnect they cannot pe
|
||||
assert.equal(markup.includes("Start over"), false);
|
||||
assert.match(markup, /Close session/);
|
||||
});
|
||||
|
||||
test("re-auth dialog offers saved password identities from the vault (#3475)", () => {
|
||||
const baseAuthProps = {
|
||||
authMethod: "password" as const,
|
||||
setAuthMethod: () => {},
|
||||
authUsername: "root",
|
||||
setAuthUsername: () => {},
|
||||
authPassword: "",
|
||||
setAuthPassword: () => {},
|
||||
selectedIdentityId: null,
|
||||
onSelectIdentity: () => {},
|
||||
authKeyId: null,
|
||||
setAuthKeyId: () => {},
|
||||
authPassphrase: "",
|
||||
setAuthPassphrase: () => {},
|
||||
showAuthPassphrase: false,
|
||||
setShowAuthPassphrase: () => {},
|
||||
showAuthPassword: false,
|
||||
setShowAuthPassword: () => {},
|
||||
authRetryMessage: "Authentication failed. Please try again.",
|
||||
onSubmit: () => {},
|
||||
onCancel: () => {},
|
||||
isValid: true,
|
||||
};
|
||||
|
||||
const withIdentities = renderDialog({
|
||||
needsAuth: true,
|
||||
authProps: {
|
||||
...baseAuthProps,
|
||||
identities: [
|
||||
{
|
||||
id: "identity-1",
|
||||
label: "Password B",
|
||||
username: "deploy",
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
created: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(withIdentities, /Use saved identity/);
|
||||
assert.match(withIdentities, /Authentication failed\. Please try again\./);
|
||||
// Stored passwords are never rendered, only masked.
|
||||
assert.equal(withIdentities.includes("secret"), false);
|
||||
|
||||
const withoutIdentities = renderDialog({
|
||||
needsAuth: true,
|
||||
authProps: { ...baseAuthProps },
|
||||
});
|
||||
|
||||
assert.equal(withoutIdentities.includes("Use saved identity"), false);
|
||||
});
|
||||
@@ -391,7 +391,7 @@ function terminalViewCtxEqual(
|
||||
}
|
||||
|
||||
function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalViewContext; isPaneMagnified?: boolean }) {
|
||||
const { Activity, Button, Clock3, Copy, Maximize2, Radio, RefreshCcw, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, Unplug, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable, reconnectNoticeMessage, timeLeft, toast, zmodem } = ctx;
|
||||
const { Activity, Button, Clock3, Copy, Maximize2, Radio, RefreshCcw, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, Unplug, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable, reconnectNoticeMessage, timeLeft, toast, zmodem } = ctx;
|
||||
// Context menu only needs a snapshot at open; avoid selection state lifting into Terminal.
|
||||
const [contextMenuHasSelection, setContextMenuHasSelection] = useState(false);
|
||||
const isNetworkDevice = host.deviceType === 'network'
|
||||
@@ -1262,6 +1262,9 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
showAuthPassword: auth.showAuthPassword,
|
||||
setShowAuthPassword: auth.setShowAuthPassword,
|
||||
authRetryMessage: auth.authRetryMessage,
|
||||
identities,
|
||||
selectedIdentityId: auth.selectedIdentityId,
|
||||
onSelectIdentity: auth.selectIdentity,
|
||||
onSubmit: () => auth.submit(),
|
||||
onSubmitWithoutSave: () => auth.submit({ saveToHost: false }),
|
||||
onCancel: handleCancelConnect,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import type { Host, TerminalSession } from "../../../types";
|
||||
import type { Host, Identity, TerminalSession } from "../../../types";
|
||||
import { listPasswordAuthIdentities } from "../../../domain/authIdentityPicker";
|
||||
import type { PendingAuth } from "../runtime/createTerminalSessionStarters";
|
||||
import type { TerminalAuthMethod } from "../TerminalAuthDialog";
|
||||
import { logger } from "../../../lib/logger";
|
||||
@@ -21,27 +22,27 @@ export const buildSavedAuthHostUpdate = (
|
||||
username: string;
|
||||
password: string;
|
||||
keyId: string | null;
|
||||
identityId?: string | null;
|
||||
},
|
||||
): Host => ({
|
||||
...host,
|
||||
username: auth.username,
|
||||
authMethod: auth.authMethod,
|
||||
password: auth.authMethod === "password" ? auth.password : undefined,
|
||||
password: auth.identityId ? undefined : auth.authMethod === "password" ? auth.password : undefined,
|
||||
savePassword: auth.authMethod === "password" ? true : host.savePassword,
|
||||
identityFileId:
|
||||
auth.authMethod === "key" || auth.authMethod === "certificate"
|
||||
? (auth.keyId ?? undefined)
|
||||
: undefined,
|
||||
// Detach stale Keychain identity on explicit credential save (#1956):
|
||||
// resolveHostAuth prefers identity credentials over host fields.
|
||||
// Empty string (not undefined) so applyGroupDefaults treats this as an explicit
|
||||
// host-level override and does not re-inherit a group-level identity; consumers
|
||||
// check host.identityId truthiness so "" behaves as "no identity".
|
||||
identityId: "",
|
||||
// An explicitly chosen saved identity remains linked to the host. Manual
|
||||
// credentials detach the stale identity; empty string prevents inheriting
|
||||
// an unrelated group identity on the next connection.
|
||||
identityId: auth.identityId || "",
|
||||
});
|
||||
|
||||
export const useTerminalAuthState = ({
|
||||
host,
|
||||
identities,
|
||||
pendingAuthRef,
|
||||
termRef,
|
||||
onUpdateHost,
|
||||
@@ -50,6 +51,7 @@ export const useTerminalAuthState = ({
|
||||
setProgressLogs,
|
||||
}: {
|
||||
host: Host;
|
||||
identities: Identity[];
|
||||
pendingAuthRef: RefObject<PendingAuth>;
|
||||
termRef: RefObject<XTerm | null>;
|
||||
onUpdateHost?: (host: Host) => void;
|
||||
@@ -62,6 +64,7 @@ export const useTerminalAuthState = ({
|
||||
const [authUsername, setAuthUsername] = useState(host.username || "root");
|
||||
const [authMethod, setAuthMethod] = useState<TerminalAuthMethod>("password");
|
||||
const [authPassword, setAuthPassword] = useState("");
|
||||
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(null);
|
||||
const [authKeyId, setAuthKeyId] = useState<string | null>(null);
|
||||
const [authPassphrase, setAuthPassphrase] = useState("");
|
||||
const [showAuthPassword, setShowAuthPassword] = useState(false);
|
||||
@@ -73,6 +76,7 @@ export const useTerminalAuthState = ({
|
||||
setAuthRetryMessage(null);
|
||||
setAuthUsername(host.username || "root");
|
||||
setAuthPassword("");
|
||||
setSelectedIdentityId(null);
|
||||
setAuthKeyId(null);
|
||||
setAuthPassphrase("");
|
||||
setShowAuthPassword(false);
|
||||
@@ -80,12 +84,38 @@ export const useTerminalAuthState = ({
|
||||
setSaveCredentials(true);
|
||||
}, [host.id, host.username]);
|
||||
|
||||
const selectedIdentity = useMemo(() =>
|
||||
selectedIdentityId
|
||||
? listPasswordAuthIdentities(identities).find((identity) => identity.id === selectedIdentityId && identity.username === authUsername)
|
||||
: undefined,
|
||||
[authUsername, identities, selectedIdentityId]);
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
if (!authUsername.trim()) return false;
|
||||
if (authMethod === "password") return isAuthPasswordProvided(authPassword);
|
||||
if (authMethod === "password") return Boolean(selectedIdentity) || isAuthPasswordProvided(authPassword);
|
||||
if (authMethod === "key" || authMethod === "certificate") return !!authKeyId;
|
||||
return false;
|
||||
}, [authKeyId, authMethod, authPassword, authUsername]);
|
||||
}, [authKeyId, authMethod, authPassword, authUsername, selectedIdentity]);
|
||||
|
||||
const updateAuthPassword = useCallback((password: string) => {
|
||||
setSelectedIdentityId(null);
|
||||
setAuthPassword(password);
|
||||
}, []);
|
||||
|
||||
const updateAuthUsername = useCallback((username: string) => {
|
||||
setSelectedIdentityId(null);
|
||||
setAuthUsername(username);
|
||||
}, []);
|
||||
|
||||
const selectIdentity = useCallback((identityId: string) => {
|
||||
const identity = listPasswordAuthIdentities(identities).find((item) => item.id === identityId);
|
||||
if (!identity) return;
|
||||
setAuthMethod("password");
|
||||
setAuthUsername(identity.username);
|
||||
setAuthPassword("");
|
||||
setShowAuthPassword(false);
|
||||
setSelectedIdentityId(identity.id);
|
||||
}, [identities]);
|
||||
|
||||
const resetForRetry = useCallback(() => {
|
||||
setNeedsAuth(false);
|
||||
@@ -98,10 +128,11 @@ export const useTerminalAuthState = ({
|
||||
if (!isValid) return;
|
||||
|
||||
const shouldSave = opts?.saveToHost ?? saveCredentials;
|
||||
const password = selectedIdentity?.password ?? authPassword;
|
||||
pendingAuthRef.current = {
|
||||
authMethod,
|
||||
username: authUsername,
|
||||
password: authMethod === "password" ? authPassword : undefined,
|
||||
password: authMethod === "password" ? password : undefined,
|
||||
keyId:
|
||||
authMethod === "key" || authMethod === "certificate"
|
||||
? (authKeyId ?? undefined)
|
||||
@@ -118,8 +149,9 @@ export const useTerminalAuthState = ({
|
||||
buildSavedAuthHostUpdate(host, {
|
||||
authMethod,
|
||||
username: authUsername,
|
||||
password: authPassword,
|
||||
password,
|
||||
keyId: authKeyId,
|
||||
identityId: authMethod === "password" ? selectedIdentity?.id : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -152,6 +184,7 @@ export const useTerminalAuthState = ({
|
||||
onUpdateHost,
|
||||
pendingAuthRef,
|
||||
saveCredentials,
|
||||
selectedIdentity,
|
||||
setProgressLogs,
|
||||
setStatus,
|
||||
termRef,
|
||||
@@ -164,11 +197,13 @@ export const useTerminalAuthState = ({
|
||||
authRetryMessage,
|
||||
setAuthRetryMessage,
|
||||
authUsername,
|
||||
setAuthUsername,
|
||||
setAuthUsername: updateAuthUsername,
|
||||
authMethod,
|
||||
setAuthMethod,
|
||||
authPassword,
|
||||
setAuthPassword,
|
||||
setAuthPassword: updateAuthPassword,
|
||||
selectedIdentityId,
|
||||
selectIdentity,
|
||||
authKeyId,
|
||||
setAuthKeyId,
|
||||
authPassphrase,
|
||||
|
||||
@@ -2869,6 +2869,14 @@ test("startSSH does not use unsaved retry passwords for sudo autofill", async ()
|
||||
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
assert.equal(captured?.password, "temporary-secret");
|
||||
assert.equal(captured?.sftpReuseOptions, undefined);
|
||||
|
||||
// A different login user must never alias the original profile to SFTP.
|
||||
Object.assign(ctx.host, { password: undefined });
|
||||
ctx.pendingAuthRef.current.username = "deploy";
|
||||
captured = undefined;
|
||||
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
assert.equal(captured?.username, "deploy");
|
||||
assert.equal(captured?.sftpReuseOptions, undefined);
|
||||
});
|
||||
|
||||
test("startSSH uses pending saved auth for sudo autofill on the first saved connection", async () => {
|
||||
|
||||
@@ -675,7 +675,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
requiresMfa: !!ctx.host.requiresMfa,
|
||||
port: ctx.host.port || 22,
|
||||
password: attempt.password,
|
||||
sftpReuseOptions: sftpReuseOptions?.password ? undefined : sftpReuseOptions,
|
||||
sftpReuseOptions: sftpReuseOptions?.password || sftpReuseOptions?.username !== effectiveUsername
|
||||
? undefined
|
||||
: sftpReuseOptions,
|
||||
privateKey: attempt.key?.source === 'reference' ? undefined : (sanitizeCredentialValue(attempt.key?.privateKey) || undefined),
|
||||
certificate: attempt.key?.certificate,
|
||||
publicKey: attempt.key?.publicKey,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
resolveTerminalDropErrorMessage,
|
||||
} from "./hooks/useTerminalDragDrop";
|
||||
import { resolvePreferredTerminalCwd } from "./sftpCwd";
|
||||
import { resolvePasswordAuthSftpHost } from "../../domain/authIdentityPicker";
|
||||
|
||||
const host = {
|
||||
id: "host-1",
|
||||
@@ -610,6 +611,36 @@ test("remote SSH folder drop to /root uses a resolved identity password", async
|
||||
assert.equal(openedHost?.identityId, "id-1");
|
||||
});
|
||||
|
||||
test("folder drop after choosing deploy identity keeps its saved sudo password", async () => {
|
||||
const rootHost = { ...host, username: "root", identityId: "root-id" };
|
||||
const identity = {
|
||||
id: "deploy-id", label: "Deploy", username: "deploy", authMethod: "password" as const,
|
||||
password: "deploy-secret", created: 0,
|
||||
};
|
||||
const sftpHost = resolvePasswordAuthSftpHost(rootHost, [identity], {
|
||||
authMethod: "password", username: "deploy", password: "deploy-secret", savedToHost: false,
|
||||
});
|
||||
let openedHost: Host | undefined;
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries: [{ file: null, relativePath: "docs", isDirectory: true }],
|
||||
host: sftpHost,
|
||||
resolvedLoginUsername: sftpHost.username,
|
||||
resolvedSudoPassword: identity.password,
|
||||
isLocalConnection: false,
|
||||
onOpenSftp: (nextHost) => { openedHost = nextHost; },
|
||||
resolveSftpInitialPath: async () => "/root",
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
sessionId: "session-1",
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: { writeToSession: () => {} },
|
||||
termRef: { current: null },
|
||||
});
|
||||
assert.equal(openedHost?.username, "deploy");
|
||||
assert.equal(openedHost?.identityId, "deploy-id");
|
||||
assert.equal(openedHost?.sftpSudo, true);
|
||||
assert.equal(rootHost.identityId, "root-id");
|
||||
});
|
||||
|
||||
test("remote SSH folder drop to /root fails closed without a saved sudo password", async () => {
|
||||
let openedSftp = false;
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ export interface TerminalProps {
|
||||
keyBindings?: KeyBinding[];
|
||||
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
|
||||
onTerminalFontSizeChange?: (fontSize: number) => void;
|
||||
onStatusChange?: (sessionId: string, status: TerminalSession["status"]) => void;
|
||||
onStatusChange?: (sessionId: string, status: TerminalSession["status"], sftpHost?: Host) => void;
|
||||
onSessionExit?: (sessionId: string, evt: TerminalSessionExitEvent) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onOsDetected?: (hostId: string, distro: string) => void;
|
||||
|
||||
@@ -168,7 +168,10 @@ test('password-prompt input is consumed before every semantic command callback',
|
||||
});
|
||||
|
||||
test('terminal output treats unknown prompt-shaped input boundaries as sensitive', () => {
|
||||
assert.match(terminalSource, /const promptSecurityOptions = \{ allowHostStyleGreaterThan: isNetworkDevice \};/);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/const promptSecurityOptions = \{\s*allowHostStyleGreaterThan: isNetworkDevice,\s*alternateScreen: termRef\.current\s*\? isTerminalAlternateScreenActive\(termRef\.current\)\s*: false,\s*\};/,
|
||||
);
|
||||
assert.match(terminalSource, /isUntrustedTerminalInputPrompt\([\s\S]*?promptSecurityOptions/);
|
||||
assert.match(terminalSource, /passwordPromptActiveRef\.current = true;[\s\S]*?autocompleteCloseRef\.current\?\.\(\);/);
|
||||
assert.match(terminalSource, /isConfirmedTerminalShellPrompt\([\s\S]*?passwordPromptActiveRef\.current = false;/);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Activity, FolderTree, History, Maximize2, MessageSquare, Minimize2, NotebookText, Palette, PanelLeft, PanelRight, Play, SplitSquareHorizontal, SplitSquareVertical, X } from 'lucide-react';
|
||||
import { Activity, FolderTree, History, Maximize2, MessageSquare, Minimize2, NotebookText, Palette, PanelBottom, PanelLeft, PanelRight, Play, Save, SplitSquareHorizontal, SplitSquareVertical, X } from 'lucide-react';
|
||||
import {
|
||||
buildSidePanelChromeThemeFromTerminalTheme,
|
||||
buildTerminalSidePanelCssVars,
|
||||
@@ -22,10 +22,15 @@ import {
|
||||
useTerminalSidePanelTabOrder,
|
||||
} from '../../application/state/terminalSidePanelTabs';
|
||||
import {
|
||||
clampTerminalSidePanelHeight,
|
||||
clampTerminalSidePanelWidth,
|
||||
getTerminalSidePanelAvailableHeight,
|
||||
getTerminalSidePanelAvailableWidth,
|
||||
getTerminalSidePanelMaxShownTools,
|
||||
getTerminalSidePanelMaxHeight,
|
||||
getTerminalSidePanelMaxWidth,
|
||||
TERMINAL_SIDE_PANEL_MIN_HEIGHT,
|
||||
TERMINAL_SIDE_PANEL_TOOLBAR_HEIGHT,
|
||||
} from '../../application/state/terminalSidePanelWidth';
|
||||
import { terminalLayoutSuppressStore } from '../../application/state/terminalLayoutSuppressStore';
|
||||
import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics';
|
||||
@@ -41,10 +46,12 @@ import {
|
||||
MAX_SIDE_PANEL_PANES,
|
||||
canSplitSidePanelPaneAtSize,
|
||||
collectSidePanelPanes,
|
||||
cycleSidePanelDockPosition,
|
||||
getFocusedSidePanelPane,
|
||||
getSidePanelNodeMinimumPixels,
|
||||
getSidePanelSplitResizeBounds,
|
||||
SIDE_PANEL_SPLIT_DIVIDER_PIXELS,
|
||||
type SidePanelDockPosition,
|
||||
type SidePanelLayout,
|
||||
type SidePanelLayoutNode,
|
||||
type SidePanelSplitDirection,
|
||||
@@ -593,8 +600,11 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
setSidePanelPosition,
|
||||
setSidePanelWidth,
|
||||
persistSidePanelWidth,
|
||||
setSidePanelHeight,
|
||||
persistSidePanelHeight,
|
||||
sidePanelPosition,
|
||||
sidePanelWidth,
|
||||
sidePanelHeight,
|
||||
t,
|
||||
terminalTheme,
|
||||
hotkeyScheme,
|
||||
@@ -617,10 +627,18 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
: ctxResolvedPreviewTheme);
|
||||
|
||||
const [resizePreviewWidth, setResizePreviewWidth] = useState<number | null>(null);
|
||||
const [resizePreviewHeight, setResizePreviewHeight] = useState<number | null>(null);
|
||||
const isBottomDock = sidePanelPosition === 'bottom';
|
||||
const shellRef = useRef<HTMLDivElement>(null);
|
||||
const shellResizeCleanupRef = useRef<(() => void) | null>(null);
|
||||
const updateAvailableSurfaceRef = useRef<() => void>(() => {});
|
||||
const [availableSurfaceWidth, setAvailableSurfaceWidth] = useState(0);
|
||||
const availableSurfaceWidthRef = useRef(availableSurfaceWidth);
|
||||
// Full terminal-layer width; unlike `availableSurfaceWidth` this does not
|
||||
// subtract the workspace focus sidebar (relevant for the bottom dock).
|
||||
const [terminalLayerWidth, setTerminalLayerWidth] = useState(0);
|
||||
const [availableSurfaceHeight, setAvailableSurfaceHeight] = useState(0);
|
||||
const availableSurfaceHeightRef = useRef(availableSurfaceHeight);
|
||||
const [paneHosts, setPaneHosts] = useState<Map<SidePanelTab, HTMLElement>>(new Map());
|
||||
const [parkingHost, setParkingHost] = useState<HTMLElement | null>(null);
|
||||
const [overlayRoot, setOverlayRoot] = useState<HTMLElement | null>(null);
|
||||
@@ -640,9 +658,14 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
setOverlayRoot(terminalLayer);
|
||||
|
||||
let observedFocusSidebar: Element | null = null;
|
||||
let observedComposeBar: Element | null = null;
|
||||
let observedWorkspaceColumn: Element | null = null;
|
||||
const resizeObserver = typeof ResizeObserver === 'undefined'
|
||||
? null
|
||||
: new ResizeObserver(() => updateAvailableWidth());
|
||||
const mutationObserver = typeof MutationObserver === 'undefined'
|
||||
? null
|
||||
: new MutationObserver(() => updateAvailableWidth());
|
||||
const updateAvailableWidth = () => {
|
||||
const focusSidebar = terminalLayer.querySelector('[data-section="terminal-workspace-sidebar"]');
|
||||
if (resizeObserver && focusSidebar !== observedFocusSidebar) {
|
||||
@@ -650,27 +673,77 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
observedFocusSidebar = focusSidebar;
|
||||
if (focusSidebar) resizeObserver.observe(focusSidebar);
|
||||
}
|
||||
// The workspace compose bar is a flex-shrink-0 sibling of the terminal
|
||||
// area inside the workspace column; re-discover it on mount/unmount and
|
||||
// measure it on its own resize so the dock never starves the terminal.
|
||||
const composeBar = terminalLayer.querySelector('[data-section="terminal-compose-bar"]');
|
||||
if (resizeObserver && composeBar !== observedComposeBar) {
|
||||
if (observedComposeBar) resizeObserver.unobserve(observedComposeBar);
|
||||
observedComposeBar = composeBar;
|
||||
if (composeBar) resizeObserver.observe(composeBar);
|
||||
}
|
||||
const workspaceColumn = terminalLayer.querySelector(
|
||||
'[data-section="terminal-workspace-column"]',
|
||||
);
|
||||
// MutationObserver has no unobserve; observe() on a node replaces its
|
||||
// previous registration, so only newly seen columns need registering.
|
||||
if (mutationObserver && workspaceColumn && workspaceColumn !== observedWorkspaceColumn) {
|
||||
observedWorkspaceColumn = workspaceColumn;
|
||||
mutationObserver.observe(workspaceColumn, { childList: true });
|
||||
}
|
||||
const layerWidth = terminalLayer.getBoundingClientRect().width;
|
||||
const nextWidth = getTerminalSidePanelAvailableWidth(
|
||||
terminalLayer.getBoundingClientRect().width,
|
||||
layerWidth,
|
||||
focusSidebar?.getBoundingClientRect().width ?? 0,
|
||||
);
|
||||
setTerminalLayerWidth((current) => current === layerWidth ? current : layerWidth);
|
||||
availableSurfaceWidthRef.current = nextWidth;
|
||||
setAvailableSurfaceWidth((current) => current === nextWidth ? current : nextWidth);
|
||||
// Bottom dock shares the full terminal layer height; only the host-tree
|
||||
// sidebar overlaps horizontally. The compose bar is a flex-shrink-0
|
||||
// sibling below the terminal area, though, so its height is reserved
|
||||
// too — otherwise the MIN_TERMINAL_HEIGHT floor applies to the whole
|
||||
// workspace row and a tall compose bar can collapse the terminal.
|
||||
const nextHeight = getTerminalSidePanelAvailableHeight(
|
||||
terminalLayer.getBoundingClientRect().height,
|
||||
composeBar?.getBoundingClientRect().height ?? 0,
|
||||
);
|
||||
availableSurfaceHeightRef.current = nextHeight;
|
||||
setAvailableSurfaceHeight((current) => current === nextHeight ? current : nextHeight);
|
||||
};
|
||||
updateAvailableSurfaceRef.current = updateAvailableWidth;
|
||||
|
||||
updateAvailableWidth();
|
||||
resizeObserver?.observe(terminalLayer);
|
||||
const mutationObserver = typeof MutationObserver === 'undefined'
|
||||
? null
|
||||
: new MutationObserver(updateAvailableWidth);
|
||||
mutationObserver?.observe(terminalLayer, { childList: true });
|
||||
// The focus sidebar is nested inside the workspace row wrapper, so a
|
||||
// focus-mode toggle mutates the wrapper rather than the terminal layer
|
||||
// (whose own dimensions stay unchanged). Watch the wrapper as well so the
|
||||
// nested sidebar is discovered and observed when it mounts/unmounts.
|
||||
const workspaceRow = terminalLayer.querySelector(
|
||||
'[data-section="terminal-workspace-row"]',
|
||||
);
|
||||
if (workspaceRow) mutationObserver?.observe(workspaceRow, { childList: true });
|
||||
updateAvailableWidth();
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
mutationObserver?.disconnect();
|
||||
observedFocusSidebar = null;
|
||||
observedComposeBar = null;
|
||||
observedWorkspaceColumn = null;
|
||||
updateAvailableSurfaceRef.current = () => {};
|
||||
setOverlayRoot(null);
|
||||
};
|
||||
}, []);
|
||||
// The compose bar mounts inside the terminal pane, below the observed
|
||||
// workspace column. Remeasure when it opens/closes or the active tab changes;
|
||||
// the ResizeObserver above then follows subsequent compose-bar height drags.
|
||||
useLayoutEffect(() => {
|
||||
updateAvailableSurfaceRef.current();
|
||||
// The compose bar is rendered by a sibling subtree. Its mount can land
|
||||
// after this layout effect, so measure again once that commit has painted.
|
||||
const frame = requestAnimationFrame(() => updateAvailableSurfaceRef.current());
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [activeTabId, ctx.isComposeBarOpen]);
|
||||
const handlePaneHostChange = useCallback((tool: SidePanelTab, host: HTMLElement | null) => {
|
||||
setPaneHosts((current) => {
|
||||
if (host && current.get(tool) === host) return current;
|
||||
@@ -735,9 +808,22 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
resizePreviewWidth,
|
||||
sidePanelWidth,
|
||||
});
|
||||
// Bottom dock reuses the same open/hidden gating with the height axis.
|
||||
const requestedShellHeight = getTerminalSidePanelShellWidth({
|
||||
activeSidePanelTab,
|
||||
forceHideAiShell: AI_PANEL_FORCE_HIDE_SHELL && activePaneCount <= 1,
|
||||
isSidePanelOpenForCurrentTab,
|
||||
resizePreviewWidth: resizePreviewHeight,
|
||||
sidePanelWidth: sidePanelHeight,
|
||||
});
|
||||
const sidePanelContentMinimumWidth = activeSidePanelLayout
|
||||
? getSidePanelNodeMinimumPixels(activeSidePanelLayout.root, 'vertical')
|
||||
: 0;
|
||||
const sidePanelContentMinimumHeight = activeSidePanelLayout
|
||||
? getSidePanelNodeMinimumPixels(activeSidePanelLayout.root, 'horizontal')
|
||||
// The shared toolbar sits above the pane tree and consumes shell height.
|
||||
+ TERMINAL_SIDE_PANEL_TOOLBAR_HEIGHT
|
||||
: 0;
|
||||
const shellWidth = requestedShellWidth > 0
|
||||
? clampTerminalSidePanelWidth(
|
||||
requestedShellWidth,
|
||||
@@ -745,12 +831,67 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
sidePanelContentMinimumWidth,
|
||||
)
|
||||
: 0;
|
||||
const shellHeight = requestedShellHeight > 0
|
||||
? clampTerminalSidePanelHeight(
|
||||
requestedShellHeight,
|
||||
availableSurfaceHeight,
|
||||
sidePanelContentMinimumHeight,
|
||||
)
|
||||
: 0;
|
||||
const shellSize = isBottomDock ? shellHeight : shellWidth;
|
||||
|
||||
const handleSidePanelResizeStart = useCallback((event: React.MouseEvent) => {
|
||||
if (!isSidePanelOpenForCurrentTab) return;
|
||||
event.preventDefault();
|
||||
shellResizeCleanupRef.current?.();
|
||||
terminalLayoutSuppressStore.begin();
|
||||
if (sidePanelPosition === 'bottom') {
|
||||
const startY = event.clientY;
|
||||
const startHeight = shellRef.current?.getBoundingClientRect().height ?? shellHeight;
|
||||
let lastHeight = startHeight;
|
||||
let rafId: number | null = null;
|
||||
|
||||
// Dragging the top edge upward grows the bottom-docked panel.
|
||||
const onMouseMove = (moveEvent: MouseEvent) => {
|
||||
lastHeight = clampTerminalSidePanelHeight(
|
||||
startHeight + (startY - moveEvent.clientY),
|
||||
availableSurfaceHeightRef.current,
|
||||
sidePanelContentMinimumHeight,
|
||||
);
|
||||
if (rafId !== null) return;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
setResizePreviewHeight(lastHeight);
|
||||
});
|
||||
};
|
||||
let cleanedUp = false;
|
||||
const cleanup = () => {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
terminalLayoutSuppressStore.end();
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', finish);
|
||||
window.removeEventListener('blur', finish);
|
||||
if (shellResizeCleanupRef.current === cleanup) shellResizeCleanupRef.current = null;
|
||||
};
|
||||
const finish = () => {
|
||||
// A narrow viewport may temporarily force the shell below its saved
|
||||
// height. A click or blur must not overwrite that preference.
|
||||
if (lastHeight !== startHeight && lastHeight >= TERMINAL_SIDE_PANEL_MIN_HEIGHT) {
|
||||
setSidePanelHeight(lastHeight);
|
||||
persistSidePanelHeight(lastHeight);
|
||||
}
|
||||
setResizePreviewHeight(null);
|
||||
cleanup();
|
||||
};
|
||||
shellResizeCleanupRef.current = cleanup;
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', finish);
|
||||
window.addEventListener('blur', finish);
|
||||
return;
|
||||
}
|
||||
const startX = event.clientX;
|
||||
const startWidth = shellRef.current?.getBoundingClientRect().width ?? shellWidth;
|
||||
let lastWidth = startWidth;
|
||||
@@ -793,10 +934,14 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
window.addEventListener('blur', finish);
|
||||
}, [
|
||||
isSidePanelOpenForCurrentTab,
|
||||
persistSidePanelHeight,
|
||||
persistSidePanelWidth,
|
||||
setSidePanelHeight,
|
||||
setSidePanelWidth,
|
||||
sidePanelContentMinimumHeight,
|
||||
sidePanelContentMinimumWidth,
|
||||
sidePanelPosition,
|
||||
shellHeight,
|
||||
shellWidth,
|
||||
]);
|
||||
|
||||
@@ -967,18 +1112,21 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
}
|
||||
return parts;
|
||||
}, [activeSidePanelTab, partitionSidePanelTabs]);
|
||||
// Bottom dock spans the full layer width (below the workspace focus
|
||||
// sidebar row), so measure tab overflow against the terminal layer itself.
|
||||
const sidePanelTabFitWidth = isBottomDock ? terminalLayerWidth : shellWidth;
|
||||
const { shown: shownSidePanelTabs, collapsed: collapsedSidePanelTabs } = useMemo(
|
||||
() => fitTerminalSidePanelTabs({
|
||||
shown: configuredShownSidePanelTabs,
|
||||
collapsed: configuredCollapsedSidePanelTabs,
|
||||
active: activeSidePanelTab,
|
||||
maxShown: getTerminalSidePanelMaxShownTools(shellWidth),
|
||||
maxShown: getTerminalSidePanelMaxShownTools(sidePanelTabFitWidth),
|
||||
}),
|
||||
[
|
||||
activeSidePanelTab,
|
||||
configuredCollapsedSidePanelTabs,
|
||||
configuredShownSidePanelTabs,
|
||||
shellWidth,
|
||||
sidePanelTabFitWidth,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1001,14 +1149,26 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
ref={shellRef}
|
||||
inert={activeMagnifiedPane ? true : undefined}
|
||||
style={{
|
||||
width: shellWidth,
|
||||
maxWidth: getTerminalSidePanelMaxWidth(availableSurfaceWidth),
|
||||
...(isBottomDock
|
||||
? {
|
||||
height: shellSize,
|
||||
maxHeight: getTerminalSidePanelMaxHeight(availableSurfaceHeight),
|
||||
}
|
||||
: {
|
||||
width: shellSize,
|
||||
maxWidth: getTerminalSidePanelMaxWidth(availableSurfaceWidth),
|
||||
}),
|
||||
contain: 'layout paint style',
|
||||
}}
|
||||
className={cn(
|
||||
'flex-shrink-0 h-full relative z-20',
|
||||
shellWidth === 0 && 'overflow-hidden',
|
||||
sidePanelPosition === 'right' && 'order-last',
|
||||
'flex-shrink-0 relative z-20',
|
||||
isBottomDock ? 'w-full' : 'h-full',
|
||||
shellSize === 0 && 'overflow-hidden',
|
||||
// Bottom dock: the section is the first child of the outer flex-col
|
||||
// (stable tree position across dock changes); order-last keeps it
|
||||
// visually below the workspace row. Side dock: order-last moves it to
|
||||
// the right edge when docked right.
|
||||
(sidePanelPosition === 'right' || isBottomDock) && 'order-last',
|
||||
)}
|
||||
data-section="terminal-side-panel-shell"
|
||||
data-side-panel-position={sidePanelPosition}
|
||||
@@ -1016,8 +1176,13 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
{isSidePanelOpenForCurrentTab && !isAiShellForceHidden && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-0 h-full w-2 cursor-ew-resize z-30',
|
||||
sidePanelPosition === 'left' ? 'right-[-3px]' : 'left-[-3px]',
|
||||
'absolute z-30',
|
||||
isBottomDock
|
||||
? 'left-0 w-full h-2 cursor-ns-resize top-[-3px]'
|
||||
: cn(
|
||||
'top-0 h-full w-2 cursor-ew-resize',
|
||||
sidePanelPosition === 'left' ? 'right-[-3px]' : 'left-[-3px]',
|
||||
),
|
||||
)}
|
||||
data-section="terminal-side-panel-resizer"
|
||||
onMouseDown={handleSidePanelResizeStart}
|
||||
@@ -1044,6 +1209,9 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
...(isSidePanelOpenForCurrentTab && sidePanelPosition === 'right'
|
||||
? { borderLeft: `1px solid ${sidePanelTheme.separator}` }
|
||||
: {}),
|
||||
...(isSidePanelOpenForCurrentTab && isBottomDock
|
||||
? { borderTop: `1px solid ${sidePanelTheme.separator}` }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{isSidePanelOpenForCurrentTab && !isAiShellForceHidden && (
|
||||
@@ -1210,13 +1378,41 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{ color: sidePanelTheme.mutedFg }}
|
||||
onClick={() => setSidePanelPosition((p: 'left' | 'right') => (p === 'left' ? 'right' : 'left'))}
|
||||
disabled={!activeSidePanelLayout || !ctx.onSaveWorkspaceLayoutAsDefault}
|
||||
aria-label={t('terminal.layer.saveLayoutAsDefault')}
|
||||
onClick={() => {
|
||||
if (activeSidePanelLayout) {
|
||||
ctx.onSaveWorkspaceLayoutAsDefault?.(activeSidePanelLayout);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{sidePanelPosition === 'left' ? <PanelRight size={15} /> : <PanelLeft size={15} />}
|
||||
<Save size={15} />
|
||||
</Btn>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{sidePanelPosition === 'left' ? t('terminal.layer.movePanelRight') : t('terminal.layer.movePanelLeft')}
|
||||
{t('terminal.layer.saveLayoutAsDefault')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Btn
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{ color: sidePanelTheme.mutedFg }}
|
||||
onClick={() => setSidePanelPosition((p: SidePanelDockPosition) => cycleSidePanelDockPosition(p))}
|
||||
>
|
||||
{sidePanelPosition === 'left' && <PanelRight size={15} />}
|
||||
{sidePanelPosition === 'right' && <PanelBottom size={15} />}
|
||||
{sidePanelPosition === 'bottom' && <PanelLeft size={15} />}
|
||||
</Btn>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{sidePanelPosition === 'left'
|
||||
? t('terminal.layer.movePanelRight')
|
||||
: sidePanelPosition === 'right'
|
||||
? t('terminal.layer.movePanelBottom')
|
||||
: t('terminal.layer.movePanelLeft')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
|
||||
@@ -805,7 +805,7 @@ interface TerminalPaneProps {
|
||||
onOpenTheme: () => void;
|
||||
onOpenSystem?: () => void;
|
||||
onCloseSession: (sessionId: string) => void;
|
||||
onStatusChange: (sessionId: string, status: TerminalSession['status']) => void;
|
||||
onStatusChange: (sessionId: string, status: TerminalSession['status'], sftpHost?: Host) => void;
|
||||
onSessionExit: (sessionId: string, evt: TerminalSessionExitEvent) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onOsDetected: (hostId: string, distro: string) => void;
|
||||
@@ -1707,7 +1707,7 @@ interface TerminalPanesHostProps {
|
||||
onOpenTheme: () => void;
|
||||
onOpenSystem?: () => void;
|
||||
onCloseSession: (sessionId: string) => void;
|
||||
onStatusChange: (sessionId: string, status: TerminalSession['status']) => void;
|
||||
onStatusChange: (sessionId: string, status: TerminalSession['status'], sftpHost?: Host) => void;
|
||||
onSessionExit: (sessionId: string, evt: TerminalSessionExitEvent) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onOsDetected: (hostId: string, distro: string) => void;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useSystemCapabilitiesWarmup } from '../../application/state/useSystemMa
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { Host, TerminalSession, Workspace } from '../../types';
|
||||
import { resolveTerminalHibernateEnabled } from '../../domain/terminalHibernate';
|
||||
import { resolveTerminalSftpHost } from '../../domain/sftpTerminalIdentity';
|
||||
import { shouldMeasureTerminalLayerLayout } from '../terminalPaneVisibility';
|
||||
import { TerminalLayerView } from './TerminalLayerView';
|
||||
import { useTerminalAiContexts } from '../../application/state/useTerminalAiContexts';
|
||||
@@ -47,6 +48,8 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
const sessions = s.sessions as TerminalSession[];
|
||||
const sessionHostsMap = s.sessionHostsMap as Map<string, Host>;
|
||||
const sftpHostForTab = s.sftpHostForTab as Map<string, Host>;
|
||||
const sftpHostSourceSessionForTab = s.sftpHostSourceSessionForTab as Map<string, string>;
|
||||
const sftpAuthHostBySessionId = s.sftpAuthHostBySessionId as Map<string, Host>;
|
||||
const sidePanelOpenTabs = s.sidePanelOpenTabs as Map<string, SidePanelTab>;
|
||||
const sidePanelLayouts = s.sidePanelLayouts as Map<string, SidePanelLayout>;
|
||||
const showHostTreeSidebar = s.showHostTreeSidebar as boolean | undefined;
|
||||
@@ -134,14 +137,19 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
|
||||
const sftpActiveHost = useMemo((): Host | null => {
|
||||
if (!isSftpOpenForCurrentTab || !activeTabId) return null;
|
||||
if (activeWorkspace && focusedSessionId) {
|
||||
return sessionHostsMap.get(focusedSessionId) ?? sftpHostForTab.get(activeTabId) ?? null;
|
||||
}
|
||||
if (activeSession) {
|
||||
return sessionHostsMap.get(activeSession.id) ?? sftpHostForTab.get(activeTabId) ?? null;
|
||||
}
|
||||
return sftpHostForTab.get(activeTabId) ?? null;
|
||||
}, [activeSession, activeTabId, activeWorkspace, focusedSessionId, isSftpOpenForCurrentTab, sessionHostsMap, sftpHostForTab]);
|
||||
const sessionId = activeWorkspace ? focusedSessionId : activeSession?.id;
|
||||
const sessionHost = sessionId ? sessionHostsMap.get(sessionId) : null;
|
||||
const stored = sftpHostForTab.get(activeTabId);
|
||||
const authHost = sessionId && sftpHostSourceSessionForTab.has(activeTabId)
|
||||
? sftpAuthHostBySessionId.get(sessionId)
|
||||
: undefined;
|
||||
return resolveTerminalSftpHost({
|
||||
sessionHost,
|
||||
storedHost: stored,
|
||||
authenticatedHost: authHost,
|
||||
followsTerminal: sftpHostSourceSessionForTab.has(activeTabId),
|
||||
});
|
||||
}, [activeSession?.id, activeTabId, activeWorkspace, focusedSessionId, isSftpOpenForCurrentTab, sessionHostsMap, sftpAuthHostBySessionId, sftpHostForTab, sftpHostSourceSessionForTab]);
|
||||
|
||||
// Keep the same-endpoint SSH session id across disconnected/connecting so
|
||||
// SftpSidePanel can observe status transitions and rebind after Start over.
|
||||
@@ -155,11 +163,14 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
const sessionHost = sessionHostsMap.get(session.id);
|
||||
if (!sessionHost) return null;
|
||||
const sameEndpoint =
|
||||
sessionHost.hostname === sftpActiveHost.hostname
|
||||
sessionHost.id === sftpActiveHost.id
|
||||
&& sessionHost.hostname === sftpActiveHost.hostname
|
||||
&& (sessionHost.port || 22) === (sftpActiveHost.port || 22)
|
||||
&& (sessionHost.username || 'root') === (sftpActiveHost.username || 'root');
|
||||
&& ((sessionHost.username || 'root') === (sftpActiveHost.username || 'root')
|
||||
|| (activeTabId && sftpHostSourceSessionForTab.has(activeTabId)
|
||||
&& sftpAuthHostBySessionId.get(session.id) === sftpActiveHost));
|
||||
return sameEndpoint ? session.id : null;
|
||||
}, [activeSession?.id, activeWorkspace, focusedSessionId, isSftpOpenForCurrentTab, sessions, sessionHostsMap, sftpActiveHost]);
|
||||
}, [activeSession?.id, activeTabId, activeWorkspace, focusedSessionId, isSftpOpenForCurrentTab, sessions, sessionHostsMap, sftpActiveHost, sftpAuthHostBySessionId, sftpHostSourceSessionForTab]);
|
||||
|
||||
const linkedTerminalSessionIdForSftp = useMemo((): string | null => {
|
||||
if (!isSftpOpenForCurrentTab) return null;
|
||||
@@ -375,6 +386,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
Set,
|
||||
setDropHint,
|
||||
setSftpHostForTab: s.setSftpHostForTab,
|
||||
setSftpHostSourceSessionForTab: s.setSftpHostSourceSessionForTab,
|
||||
setSftpInitialLocationForTab: s.setSftpInitialLocationForTab,
|
||||
setSftpPendingUploadsForTab: s.setSftpPendingUploadsForTab,
|
||||
setAiMountedTabIds: s.setAiMountedTabIds,
|
||||
@@ -388,6 +400,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
setWorkspaceArea,
|
||||
sidePanelPosition: s.sidePanelPosition,
|
||||
sidePanelWidth: s.sidePanelWidth,
|
||||
sidePanelHeight: s.sidePanelHeight,
|
||||
sftpActiveHost,
|
||||
sftpHostForTab,
|
||||
sftpPaneClosedTabIdsRef: s.sftpPaneClosedTabIdsRef,
|
||||
@@ -497,6 +510,8 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
handleSftpInitialLocationApplied: s.handleSftpInitialLocationApplied,
|
||||
persistSidePanelWidth: s.persistSidePanelWidth,
|
||||
setSidePanelWidth: s.setSidePanelWidth,
|
||||
persistSidePanelHeight: s.persistSidePanelHeight,
|
||||
setSidePanelHeight: s.setSidePanelHeight,
|
||||
handleSnippetClickForFocusedSession: s.handleSnippetClickForFocusedSession,
|
||||
handleSnippetFromPanel: s.handleSnippetFromPanel,
|
||||
handleRunScriptFromPanel: s.handleRunScriptFromPanel,
|
||||
@@ -566,6 +581,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
onOpenVaultHostFromChat: s.onOpenVaultHostFromChat,
|
||||
onOpenVaultSectionFromChat: s.onOpenVaultSectionFromChat,
|
||||
onOpenVaultSnippetFromChat: s.onOpenVaultSnippetFromChat,
|
||||
onSaveWorkspaceLayoutAsDefault: s.onSaveWorkspaceLayoutAsDefault,
|
||||
onStartSessionDrag: s.onStartSessionDrag,
|
||||
onEndSessionDrag: s.onEndSessionDrag,
|
||||
onSplitSession: s.onSplitSession,
|
||||
@@ -609,6 +625,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
sftpUseCompressedUpload: s.sftpUseCompressedUpload,
|
||||
sidePanelPosition: s.sidePanelPosition,
|
||||
sidePanelWidth: s.sidePanelWidth,
|
||||
sidePanelHeight: s.sidePanelHeight,
|
||||
sidePanelOpenTabs,
|
||||
sidePanelLayouts,
|
||||
snippetPackages: s.snippetPackages,
|
||||
@@ -670,6 +687,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
handleTerminalContextReaderChange,
|
||||
hibernateHiddenTabs,
|
||||
historySessionId,
|
||||
s.isComposeBarOpen,
|
||||
isFocusMode,
|
||||
isSidePanelOpenForCurrentTab,
|
||||
isTerminalLayerVisible,
|
||||
@@ -680,6 +698,10 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
sidePanelLayouts,
|
||||
s.resolvedSessionHostIds,
|
||||
sessions,
|
||||
// Dock geometry lives in stableRef state; without these deps the memoized
|
||||
// ctx keeps stale values after dock-cycle / bottom-height-drag updates.
|
||||
s.sidePanelHeight,
|
||||
s.sidePanelPosition,
|
||||
s.terminalSettings,
|
||||
showHostTreeSidebar,
|
||||
sftpActiveHost,
|
||||
|
||||
@@ -17,10 +17,12 @@ function TerminalLayerViewInner({ ctx }: { ctx: TerminalLayerViewContext }) {
|
||||
ctx.hibernateHiddenTabs,
|
||||
);
|
||||
|
||||
const isBottomDock = ctx.sidePanelPosition === 'bottom';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ctx.workspaceOuterRef}
|
||||
className="absolute inset-0 bg-background flex min-h-0"
|
||||
className={`absolute inset-0 bg-background flex min-h-0${isBottomDock ? ' flex-col' : ''}`}
|
||||
data-section="terminal-workspace"
|
||||
inert={ctx.isTerminalLayerVisible ? undefined : true}
|
||||
style={{
|
||||
@@ -28,9 +30,19 @@ function TerminalLayerViewInner({ ctx }: { ctx: TerminalLayerViewContext }) {
|
||||
left: hostTreeLayoutWidth,
|
||||
}}
|
||||
>
|
||||
{/* Keep TerminalLayerSidePanelSection at a stable tree position (first
|
||||
child) so cycling between side and bottom docks reorders it via flex
|
||||
`order` instead of unmounting/recreating it. The wrapper below uses
|
||||
`display: contents` in side-dock mode so its children participate in
|
||||
the outer flex row exactly like direct children. */}
|
||||
<TerminalLayerSidePanelSection ctx={ctx} />
|
||||
<TerminalLayerFocusSidebarSection ctx={ctx} />
|
||||
<TerminalLayerWorkspaceSection ctx={ctx} />
|
||||
<div
|
||||
data-section="terminal-workspace-row"
|
||||
className={isBottomDock ? 'flex min-h-0 w-full flex-1' : 'contents'}
|
||||
>
|
||||
<TerminalLayerFocusSidebarSection ctx={ctx} />
|
||||
<TerminalLayerWorkspaceSection ctx={ctx} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
}, [activeMagnifiedTerminal]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div data-section="terminal-workspace-column" className="flex-1 min-h-0 flex flex-col">
|
||||
<div ref={workspaceInnerRef} className="flex-1 min-h-0 overflow-hidden relative">
|
||||
{draggingSessionId && !isFocusMode && (
|
||||
<div
|
||||
|
||||
@@ -154,6 +154,14 @@ test("terminal layer side panel stable ctx ignores linked terminal cwd changes",
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal layer side panel remeasures when the compose bar opens", () => {
|
||||
const closed = { isComposeBarOpen: false };
|
||||
assert.equal(
|
||||
terminalLayerSidePanelStableCtxEqual(closed, { isComposeBarOpen: true }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal layer side panel stable ctx re-renders when SFTP-relevant session fields change", () => {
|
||||
const baseCtx = {
|
||||
mountedSftpTabIds: ["workspace-1"],
|
||||
|
||||
@@ -239,7 +239,9 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
|
||||
'HistorySidePanel',
|
||||
'History',
|
||||
'sidePanelWidth',
|
||||
'sidePanelHeight',
|
||||
'sidePanelPosition',
|
||||
'isComposeBarOpen',
|
||||
'sidePanelOpenTabs',
|
||||
'sidePanelLayouts',
|
||||
'sftpHostForTab',
|
||||
@@ -304,6 +306,8 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
|
||||
'handlePendingTerminalSelectionConsumed',
|
||||
'setSidePanelWidth',
|
||||
'persistSidePanelWidth',
|
||||
'setSidePanelHeight',
|
||||
'persistSidePanelHeight',
|
||||
'handleToggleSftpFromBar',
|
||||
'handleOpenScripts',
|
||||
'handleOpenTheme',
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
resolveSftpTransferNavigationTarget,
|
||||
} from '../../domain/sftpTransferNavigation';
|
||||
import { collectSidePanelPanes, sidePanelLayoutHasTool } from '../../domain/sidePanelLayout';
|
||||
import { isSameSftpHostSelection } from '../../domain/sftpTerminalIdentity';
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import {
|
||||
moveSidePanelTabMap,
|
||||
@@ -108,7 +109,7 @@ export function pruneTerminalTabMemoryState(
|
||||
|
||||
export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
const { openPath } = useSftpBackend();
|
||||
const { activeSidePanelTab, activeSidePanelLayout, activeTabId, activeTabIdRef, activeWorkspace, activityTrackedSessions, cancelAnimationFrame, ChunkedEscapeFilter, clearTopTabsPreviewVars, document, dropHint, effectiveHosts, filterTabsMap, focusedSessionId, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isComposeBarOpen, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, onConnectToHost, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, prevFocusedSessionIdRef, refocusActiveTerminalSession, requestAnimationFrame, ResizeObserver, sessionActivityStore, sessions, Set, setAiMountedTabIds, setDropHint, setNotesMountedTabIds, setScriptsMountedTabIds, setSystemMountedTabIds, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setSidePanelLayouts, setThemeMountedTabIds, setWorkspaceArea, shouldMeasureTerminalLayerLayout, sidePanelPosition, sidePanelWidth, sftpActiveHost, sftpHostForTab, sftpPaneClosedTabIdsRef, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces } = ctx;
|
||||
const { activeSidePanelTab, activeSidePanelLayout, activeTabId, activeTabIdRef, activeWorkspace, activityTrackedSessions, cancelAnimationFrame, ChunkedEscapeFilter, clearTopTabsPreviewVars, document, dropHint, effectiveHosts, filterTabsMap, focusedSessionId, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isComposeBarOpen, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, onConnectToHost, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, prevFocusedSessionIdRef, refocusActiveTerminalSession, requestAnimationFrame, ResizeObserver, sessionActivityStore, sessions, Set, setAiMountedTabIds, setDropHint, setNotesMountedTabIds, setScriptsMountedTabIds, setSystemMountedTabIds, setSftpHostForTab, setSftpHostSourceSessionForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setSidePanelLayouts, setThemeMountedTabIds, setWorkspaceArea, shouldMeasureTerminalLayerLayout, sidePanelPosition, sidePanelHeight, sidePanelWidth, sftpActiveHost, sftpHostForTab, sftpPaneClosedTabIdsRef, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces } = ctx;
|
||||
|
||||
const activeWorkspaceId = activeWorkspace?.id;
|
||||
const activeWorkspaceViewMode = activeWorkspace?.viewMode;
|
||||
@@ -199,6 +200,13 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setSftpHostSourceSessionForTab((prev: Map<string, string>) => {
|
||||
let next = prev;
|
||||
for (const remap of remaps) {
|
||||
next = moveSidePanelTabMap(next, remap);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setSftpInitialLocationForTab((prev: Map<string, any>) => {
|
||||
let next = prev;
|
||||
for (const remap of remaps) {
|
||||
@@ -263,6 +271,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
setNotesMountedTabIds,
|
||||
setScriptsMountedTabIds,
|
||||
setSftpHostForTab,
|
||||
setSftpHostSourceSessionForTab,
|
||||
setSftpInitialLocationForTab,
|
||||
setSftpPendingUploadsForTab,
|
||||
setSidePanelLayouts,
|
||||
@@ -282,6 +291,17 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
resizePreviewWidth: null,
|
||||
sidePanelWidth,
|
||||
});
|
||||
// Bottom dock trades width for height; the same open/hidden gate applies.
|
||||
const sidePanelShellHeight = sidePanelPosition === 'bottom'
|
||||
? getTerminalSidePanelShellWidth({
|
||||
activeSidePanelTab,
|
||||
forceHideAiShell: AI_PANEL_FORCE_HIDE_SHELL
|
||||
&& (!activeSidePanelLayout || collectSidePanelPanes(activeSidePanelLayout.root).length <= 1),
|
||||
isSidePanelOpenForCurrentTab,
|
||||
resizePreviewWidth: null,
|
||||
sidePanelWidth: sidePanelHeight,
|
||||
})
|
||||
: 0;
|
||||
|
||||
const activityEscapeFiltersRef = useRef<any>(new Map());
|
||||
|
||||
@@ -291,6 +311,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
viewMode: undefined as string | undefined,
|
||||
composeBarOpen: false,
|
||||
shellWidth: 0,
|
||||
shellHeight: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
@@ -394,6 +415,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
useEffect(() => {
|
||||
setSidePanelOpenTabs(prev => filterTabsMap(prev, validAIScopeTargetIds));
|
||||
setSftpHostForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
|
||||
setSftpHostSourceSessionForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
|
||||
setSftpInitialLocationForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
|
||||
setSftpPendingUploadsForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
|
||||
setAiMountedTabIds((prev) => prev.filter((tabId) => validAIScopeTargetIds.has(tabId)));
|
||||
@@ -441,7 +463,8 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
&& height > 0
|
||||
&& prev.width === width
|
||||
&& prev.height === height
|
||||
&& prev.shellWidth === sidePanelShellWidth;
|
||||
&& prev.shellWidth === sidePanelShellWidth
|
||||
&& prev.shellHeight === sidePanelShellHeight;
|
||||
if (
|
||||
dimensionsUnchanged
|
||||
&& prev.workspaceId === activeWorkspaceId
|
||||
@@ -455,6 +478,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
viewMode: activeWorkspaceViewMode,
|
||||
composeBarOpen: isComposeBarOpen,
|
||||
shellWidth: sidePanelShellWidth,
|
||||
shellHeight: sidePanelShellHeight,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
@@ -467,6 +491,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
shouldMeasureTerminalLayerLayout,
|
||||
sidePanelPosition,
|
||||
sidePanelShellWidth,
|
||||
sidePanelShellHeight,
|
||||
]);
|
||||
|
||||
// Keep sftpHostForTab in sync with focus changes in workspace mode
|
||||
@@ -475,10 +500,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
if (!activeTabId || !sftpActiveHost) return;
|
||||
if (!sidePanelLayoutHasTool(activeSidePanelLayout, 'sftp')) return;
|
||||
const stored = sftpHostForTab.get(activeTabId);
|
||||
if (stored?.id === sftpActiveHost.id
|
||||
&& stored?.hostname === sftpActiveHost.hostname
|
||||
&& stored?.port === sftpActiveHost.port
|
||||
&& stored?.protocol === sftpActiveHost.protocol) return;
|
||||
if (isSameSftpHostSelection(stored, sftpActiveHost)) return;
|
||||
setSftpHostForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(activeTabId, sftpActiveHost);
|
||||
@@ -516,6 +538,11 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
// Bump initialLocation even when the host is already selected so the
|
||||
// path-navigation effect re-runs after reopen.
|
||||
setSftpHostForTab((prev: Map<string, any>) => new Map(prev).set(tabId, host));
|
||||
setSftpHostSourceSessionForTab((prev: Map<string, string>) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(tabId);
|
||||
return next;
|
||||
});
|
||||
setSftpInitialLocationForTab((prev: Map<string, any>) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(tabId);
|
||||
@@ -642,7 +669,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
};
|
||||
window.addEventListener('netcatty:open-sftp-transfer-target', handler);
|
||||
return () => window.removeEventListener('netcatty:open-sftp-transfer-target', handler);
|
||||
}, [activeTabIdRef, effectiveHosts, onConnectToHost, openPath, setSftpHostForTab, setSftpInitialLocationForTab, setSidePanelOpenTabs, sftpActiveHost, sftpHostForTab, window]);
|
||||
}, [activeTabIdRef, effectiveHosts, onConnectToHost, openPath, setSftpHostForTab, setSftpHostSourceSessionForTab, setSftpInitialLocationForTab, setSidePanelOpenTabs, sftpActiveHost, sftpHostForTab, window]);
|
||||
|
||||
useEffect(() => {
|
||||
const sessionIdsToClear = getSessionActivityIdsToClear(activeTabId, sessions);
|
||||
|
||||
@@ -312,7 +312,7 @@ export const WindowControls: React.FC = memo(() => {
|
||||
const closeControlClassName = 'window-control-btn window-control-btn--close app-no-drag';
|
||||
|
||||
return (
|
||||
<div className="ml-2 flex items-center h-7 overflow-visible app-no-drag">
|
||||
<div className="ml-2 flex h-9 items-center app-no-drag">
|
||||
<button type="button" className={controlClassName} onClick={handleMinimize}>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
} from "./VaultPageHeader";
|
||||
import { useConnectionLogsStore } from "../../application/state/connectionLogsStore";
|
||||
import { useNotesStore } from "../../application/state/notesStore";
|
||||
import { activeTabStore, useIsSftpActive } from "../../application/state/activeTabStore";
|
||||
import { useSettingsChromeStore } from "../../application/state/settingsChromeStore";
|
||||
import { vaultSidebarLayoutStore } from "../../application/state/vaultSidebarLayoutStore";
|
||||
import { Folder } from "lucide-react";
|
||||
import { LazyLoadBoundary } from "../ui/lazy-load-boundary";
|
||||
import { toast } from "../ui/toast";
|
||||
import { AppWordmark } from "../AppWordmark";
|
||||
@@ -462,6 +466,27 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
sidebarMinWidth,
|
||||
Math.min(sidebarMaxWidth, Number(sidebarWidth) || 208),
|
||||
);
|
||||
const { sftpInSidebar } = useSettingsChromeStore();
|
||||
const isSftpSurfaceActive = useIsSftpActive();
|
||||
// Sidebar SFTP placement is independent of the legacy "Show SFTP tab"
|
||||
// toggle: the whole point of the sidebar item is to host the SFTP surface
|
||||
// when the top tab is hidden or replaced.
|
||||
const showSftpSidebarNav = Boolean(sftpInSidebar);
|
||||
// While the SFTP tab is active, the vault view renders as a rail-only
|
||||
// surface: the sidebar stays visible beside the root SFTP view so vault
|
||||
// sections remain one click away.
|
||||
const sftpRailMode = showSftpSidebarNav && isSftpSurfaceActive;
|
||||
// While the SFTP rail is active the vault section still points at the
|
||||
// previously selected section, but the rail's SFTP button is the one that
|
||||
// must appear selected — suppress vault-section highlight to avoid two
|
||||
// active destinations in the rail.
|
||||
const vaultNavSection = sftpRailMode ? null : currentSection;
|
||||
const openVaultSectionFromSftpRail = React.useCallback(() => {
|
||||
if (isSftpSurfaceActive) activeTabStore.setActiveTabId("vault");
|
||||
}, [isSftpSurfaceActive]);
|
||||
React.useEffect(() => {
|
||||
vaultSidebarLayoutStore.setLayoutWidth(effectiveSidebarWidth);
|
||||
}, [effectiveSidebarWidth]);
|
||||
const handleDeleteVaultKey = React.useCallback(
|
||||
(keyId: string) => {
|
||||
void deleteVaultKey({
|
||||
@@ -534,8 +559,13 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="absolute inset-0 min-h-0 flex bg-secondary"
|
||||
className={cn(
|
||||
"absolute inset-0 min-h-0 flex bg-secondary",
|
||||
sftpRailMode && "overflow-hidden",
|
||||
)}
|
||||
style={sftpRailMode ? { width: effectiveSidebarWidth } : undefined}
|
||||
data-section="vault-view"
|
||||
data-sftp-rail-mode={sftpRailMode ? "true" : undefined}
|
||||
>
|
||||
{/* Sidebar */}
|
||||
<TooltipProvider delayDuration={100}>
|
||||
@@ -582,16 +612,17 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
variant={currentSection === "hosts" ? "secondary" : "ghost"}
|
||||
variant={vaultNavSection === "hosts" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed
|
||||
? "justify-center p-0"
|
||||
: "justify-start gap-3",
|
||||
currentSection === "hosts" &&
|
||||
vaultNavSection === "hosts" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => {
|
||||
openVaultSectionFromSftpRail();
|
||||
setCurrentSection("hosts");
|
||||
setSelectedGroupPath(null);
|
||||
}}
|
||||
@@ -606,19 +637,49 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
{showSftpSidebarNav && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
variant={isSftpSurfaceActive ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed
|
||||
? "justify-center p-0"
|
||||
: "justify-start gap-3",
|
||||
isSftpSurfaceActive &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isSftpSurfaceActive) activeTabStore.setActiveTabId("sftp");
|
||||
}}
|
||||
aria-pressed={isSftpSurfaceActive}
|
||||
>
|
||||
<Folder size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.sftp")}
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && (
|
||||
<TooltipContent side="right">
|
||||
{t("vault.nav.sftp")}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
variant={currentSection === "keys" ? "secondary" : "ghost"}
|
||||
variant={vaultNavSection === "keys" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed
|
||||
? "justify-center p-0"
|
||||
: "justify-start gap-3",
|
||||
currentSection === "keys" &&
|
||||
vaultNavSection === "keys" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => {
|
||||
openVaultSectionFromSftpRail();
|
||||
setCurrentSection("keys");
|
||||
}}
|
||||
>
|
||||
@@ -635,16 +696,17 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
variant={currentSection === "proxies" ? "secondary" : "ghost"}
|
||||
variant={vaultNavSection === "proxies" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed
|
||||
? "justify-center p-0"
|
||||
: "justify-start gap-3",
|
||||
currentSection === "proxies" &&
|
||||
vaultNavSection === "proxies" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => {
|
||||
openVaultSectionFromSftpRail();
|
||||
setCurrentSection("proxies");
|
||||
}}
|
||||
>
|
||||
@@ -661,16 +723,19 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
variant={currentSection === "port" ? "secondary" : "ghost"}
|
||||
variant={vaultNavSection === "port" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed
|
||||
? "justify-center p-0"
|
||||
: "justify-start gap-3",
|
||||
currentSection === "port" &&
|
||||
vaultNavSection === "port" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => setCurrentSection("port")}
|
||||
onClick={() => {
|
||||
openVaultSectionFromSftpRail();
|
||||
setCurrentSection("port");
|
||||
}}
|
||||
>
|
||||
<Plug size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.portForwarding")}
|
||||
@@ -686,17 +751,18 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
variant={
|
||||
currentSection === "snippets" ? "secondary" : "ghost"
|
||||
vaultNavSection === "snippets" ? "secondary" : "ghost"
|
||||
}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed
|
||||
? "justify-center p-0"
|
||||
: "justify-start gap-3",
|
||||
currentSection === "snippets" &&
|
||||
vaultNavSection === "snippets" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => {
|
||||
openVaultSectionFromSftpRail();
|
||||
setCurrentSection("snippets");
|
||||
}}
|
||||
>
|
||||
@@ -713,16 +779,17 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
variant={currentSection === "notes" ? "secondary" : "ghost"}
|
||||
variant={vaultNavSection === "notes" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed
|
||||
? "justify-center p-0"
|
||||
: "justify-start gap-3",
|
||||
currentSection === "notes" &&
|
||||
vaultNavSection === "notes" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => {
|
||||
openVaultSectionFromSftpRail();
|
||||
setCurrentSection("notes");
|
||||
}}
|
||||
>
|
||||
@@ -740,17 +807,20 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
variant={
|
||||
currentSection === "knownhosts" ? "secondary" : "ghost"
|
||||
vaultNavSection === "knownhosts" ? "secondary" : "ghost"
|
||||
}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed
|
||||
? "justify-center p-0"
|
||||
: "justify-start gap-3",
|
||||
currentSection === "knownhosts" &&
|
||||
vaultNavSection === "knownhosts" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => setCurrentSection("knownhosts")}
|
||||
onClick={() => {
|
||||
openVaultSectionFromSftpRail();
|
||||
setCurrentSection("knownhosts");
|
||||
}}
|
||||
>
|
||||
<BookMarked size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.knownHosts")}
|
||||
@@ -765,16 +835,19 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
variant={currentSection === "logs" ? "secondary" : "ghost"}
|
||||
variant={vaultNavSection === "logs" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed
|
||||
? "justify-center p-0"
|
||||
: "justify-start gap-3",
|
||||
currentSection === "logs" &&
|
||||
vaultNavSection === "logs" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => setCurrentSection("logs")}
|
||||
onClick={() => {
|
||||
openVaultSectionFromSftpRail();
|
||||
setCurrentSection("logs");
|
||||
}}
|
||||
>
|
||||
<Activity size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.logs")}
|
||||
@@ -835,6 +908,8 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
<div
|
||||
className="flex min-w-0 flex-1 py-0 pr-2 pb-2 pl-0"
|
||||
data-section="vault-stage"
|
||||
aria-hidden={sftpRailMode ? true : undefined}
|
||||
inert={sftpRailMode ? true : undefined}
|
||||
>
|
||||
<div
|
||||
className="relative flex min-h-0 flex-1 overflow-hidden rounded-xl border border-border/60 bg-background shadow-sm"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
// Regression test for issue #3476: window control buttons must fill the full
|
||||
// h-9 title bar so the hover highlight and hit area are vertically centered
|
||||
// around the icon, instead of the buttons sitting in a bottom-anchored h-7 row.
|
||||
test("window control buttons fill the h-9 title bar with centered hover highlight", () => {
|
||||
const css = readFileSync(new URL("../index.css", import.meta.url), "utf8");
|
||||
const buttonBlock = css.match(
|
||||
/\[data-section="top-tabs"\] \.window-control-btn \{[\s\S]*?\n\}/,
|
||||
)?.[0] ?? "";
|
||||
assert.notEqual(buttonBlock, "", "window-control-btn rule must exist in index.css");
|
||||
assert.match(buttonBlock, /height: 2\.25rem/, "buttons must span the full 2.25rem title bar height");
|
||||
|
||||
const beforeBlock = css.match(
|
||||
/\[data-section="top-tabs"\] \.window-control-btn::before \{[\s\S]*?\n\}/,
|
||||
)?.[0] ?? "";
|
||||
assert.notEqual(beforeBlock, "");
|
||||
assert.match(beforeBlock, /inset: 0/, "hover highlight must cover the whole button box");
|
||||
assert.doesNotMatch(beforeBlock, /calc\(/, "no offset hack may remain on the highlight");
|
||||
|
||||
const tsx = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
|
||||
const containerMatch = tsx.match(/<div className="[^"]*app-no-drag">\s*\n\s*<button type="button" className=\{controlClassName\}/);
|
||||
assert.notEqual(containerMatch, null, "WindowControls container must wrap the control buttons");
|
||||
assert.match(containerMatch?.[0] ?? "", /h-9/, "WindowControls container must be h-9 to match the title bar");
|
||||
|
||||
const toolbar = readFileSync(new URL("./TopTabs.tsx", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
toolbar,
|
||||
/showWindowControls \? "relative z-20 h-9" : "h-7"/,
|
||||
"the containing toolbar must fill the title bar and sit above its drag stripe",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { listPasswordAuthIdentities, resolvePasswordAuthSftpHost } from "./authIdentityPicker.ts";
|
||||
import type { Identity } from "./models.ts";
|
||||
import type { Host } from "./models.ts";
|
||||
|
||||
const passwordIdentity = (overrides: Partial<Identity> = {}): Identity => ({
|
||||
id: "id-1",
|
||||
label: "Default password",
|
||||
username: "root",
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
created: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("keeps only password identities with a usable stored password", () => {
|
||||
const identities: Identity[] = [
|
||||
passwordIdentity(),
|
||||
passwordIdentity({ id: "id-2", label: "Password B", username: "deploy" }),
|
||||
passwordIdentity({ id: "id-3", authMethod: "key", keyId: "key-1" }),
|
||||
passwordIdentity({ id: "id-4", password: undefined }),
|
||||
passwordIdentity({ id: "id-5", password: "" }),
|
||||
passwordIdentity({ id: "id-6", password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==" }),
|
||||
passwordIdentity({ id: "id-7", username: "" }),
|
||||
passwordIdentity({ id: "id-8", username: " " }),
|
||||
];
|
||||
|
||||
const result = listPasswordAuthIdentities(identities);
|
||||
|
||||
assert.deepEqual(
|
||||
result.map((identity) => identity.id),
|
||||
["id-1", "id-2"],
|
||||
);
|
||||
});
|
||||
|
||||
test("tolerates missing identity lists", () => {
|
||||
assert.deepEqual(listPasswordAuthIdentities(undefined), []);
|
||||
assert.deepEqual(listPasswordAuthIdentities([]), []);
|
||||
});
|
||||
|
||||
test("temporary deploy identity carries into SFTP without changing the saved host", () => {
|
||||
const host = { id: "host-1", hostname: "example.test", username: "root", identityId: "root-id" } as Host;
|
||||
const identities = [
|
||||
passwordIdentity({ id: "root-id", password: "bad-root" }),
|
||||
passwordIdentity({ id: "deploy-id", username: "deploy", password: "good-deploy" }),
|
||||
];
|
||||
const auth = { authMethod: "password", username: "deploy", password: "good-deploy", savedToHost: false };
|
||||
assert.deepEqual(resolvePasswordAuthSftpHost(host, identities, auth), {
|
||||
...host, username: "deploy", identityId: "deploy-id",
|
||||
});
|
||||
assert.equal(host.username, "root");
|
||||
assert.equal(host.identityId, "root-id");
|
||||
assert.equal(resolvePasswordAuthSftpHost(host, identities, { ...auth, password: "manual" }), host);
|
||||
assert.equal(resolvePasswordAuthSftpHost(host, identities, { ...auth, savedToHost: true }), host);
|
||||
assert.equal(resolvePasswordAuthSftpHost(host, identities, null), host);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Host, Identity } from "./models";
|
||||
import { sanitizeCredentialValue } from "./credentials";
|
||||
|
||||
/**
|
||||
* Saved password identities that can be reused from the terminal
|
||||
* re-authentication dialog. Only identities carrying a usable stored
|
||||
* password and username qualify. Key identities are already selectable
|
||||
* through the existing SSH key picker (#3475). Undecryptable vault ciphertext
|
||||
* (`enc:v1:` placeholders left when hydration fails) is filtered out so
|
||||
* those identities fall back to manual password re-entry.
|
||||
*/
|
||||
export const listPasswordAuthIdentities = (
|
||||
identities: readonly Identity[] | undefined,
|
||||
): Identity[] =>
|
||||
(identities ?? []).filter(
|
||||
(identity) =>
|
||||
identity.authMethod === "password"
|
||||
&& typeof identity.username === "string"
|
||||
&& identity.username.trim().length > 0
|
||||
&& typeof identity.password === "string"
|
||||
&& (sanitizeCredentialValue(identity.password) ?? "").length > 0,
|
||||
);
|
||||
|
||||
/** Use the temporary saved identity for SFTP opened from its authenticated terminal. */
|
||||
export const resolvePasswordAuthSftpHost = (
|
||||
host: Host,
|
||||
identities: readonly Identity[] | undefined,
|
||||
auth: { authMethod: string; username: string; password?: string; savedToHost?: boolean } | null,
|
||||
): Host => {
|
||||
if (!auth || auth.authMethod !== "password" || auth.savedToHost) return host;
|
||||
const identity = listPasswordAuthIdentities(identities).find(
|
||||
(candidate) => candidate.username === auth.username && candidate.password === auth.password,
|
||||
);
|
||||
return identity
|
||||
? { ...host, username: identity.username, identityId: identity.id }
|
||||
: host;
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Tests for SSH key-type detection of imported keys.
|
||||
* Fixtures are real ssh-keygen/openssl outputs committed inline so the tests
|
||||
* run offline; each is a throwaway key with no passphrase.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { detectSshKeyType } from "./keyTypeDetect";
|
||||
|
||||
const ecdsaP256 = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS\n1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQQvnbE+AaCddvtJFMjaax10oenmFmQM\npXAcjy2UGyDgtfu3lEaPtaIH46w6N20f9yYAON2MvWW0/eRuCpdJHTkMAAAAsHVdVZ91XV\nWfAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBC+dsT4BoJ12+0kU\nyNprHXSh6eYWZAylcByPLZQbIOC1+7eURo+1ogfjrDo3bR/3JgA43Yy9ZbT95G4Kl0kdOQ\nwAAAAgStX6Lob7zcAycW7KaJSCXLJrilDhJXG1WFQgGl9y62IAAAAUcnVubmVyQHJ1bm5l\ncnZtbHVuNXABAgME\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||
|
||||
const ecdsaP384 = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAiAAAABNlY2RzYS\n1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQSQS6EWgw29J9MLGrZLfMn76MmM5ALy\nQ4+04xJGATjEf6MEOIAK0wSBFZl8to7emVfBc1zzAj3ha+GBhJhb0um5SSm9FPyWwAiIJf\nBVzRLlmGzQovAI01eQmL1/4ucmSvUAAADgtNoSebTaEnkAAAATZWNkc2Etc2hhMi1uaXN0\ncDM4NAAAAAhuaXN0cDM4NAAAAGEEkEuhFoMNvSfTCxq2S3zJ++jJjOQC8kOPtOMSRgE4xH\n+jBDiACtMEgRWZfLaO3plXwXNc8wI94WvhgYSYW9LpuUkpvRT8lsAIiCXwVc0S5Zhs0KLw\nCNNXkJi9f+LnJkr1AAAAMQCYPLvcPHCq3bUIuaeu3p0bKq1/C2OHA1lC0hpq+1fDf6FTGR\nvFItzEY9ZtFvC3AWMAAAAUcnVubmVyQHJ1bm5lcnZtbHVuNXABAgM=\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||
|
||||
const ecdsaP521 = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAArAAAABNlY2RzYS\n1zaGEyLW5pc3RwNTIxAAAACG5pc3RwNTIxAAAAhQQBavWy9eXpHL30+xFqt3nDv+ePLDCD\nOg4N1cuMNIHckv5rgoDGvvDQMjtGczs3kC0xabLtv601MRUvBhFPwE4Y034A7vHCE6RAWp\nq00peOjvI+1LGT/O6VcK/jRek0Ff2SFyuRQnEr831OzbaSDmW2jGdy9nbqinb5YQJJxtap\nMZX1OywAAAEY25lDYduZQ2EAAAATZWNkc2Etc2hhMi1uaXN0cDUyMQAAAAhuaXN0cDUyMQ\nAAAIUEAWr1svXl6Ry99PsRard5w7/njywwgzoODdXLjDSB3JL+a4KAxr7w0DI7RnM7N5At\nMWmy7b+tNTEVLwYRT8BOGNN+AO7xwhOkQFqatNKXjo7yPtSxk/zulXCv40XpNBX9khcrkU\nJxK/N9Ts22kg5ltoxncvZ26op2+WECScbWqTGV9TssAAAAQgEPqPBDx3/8K4g0fzQn57FF\naCq4Cs2JZz9BHWx63zwlRQq8IhQKbZecPynvKNWLy8LOp8XfNVZTwQBKMz9QekGeuAAAAB\nRydW5uZXJAcnVubmVydm1sdW41cAECAwQFBg==\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||
|
||||
const ecdsaPkcs8 = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgugGgbsmFOKG8JT1q\n6Ay3bj1PDVhdp1XtdEHVE6NJzaahRANCAAToTR6xUeCdtmkog/Bo92A9ulj68geF\nKGBHlkOAK276jQJreRIbgzDvnuXJYIcdFT8Hld+NKcYpW3G9Ldk1dIaN\n-----END PRIVATE KEY-----\n";
|
||||
|
||||
const ed25519 = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACByAuOzP1a94fFc6lzn1dkzlZulhkgFw7iwLTQZDxkBXgAAAJhgsFpyYLBa\ncgAAAAtzc2gtZWQyNTUxOQAAACByAuOzP1a94fFc6lzn1dkzlZulhkgFw7iwLTQZDxkBXg\nAAAEB+G4mdoS5EtP1EOdUWi2dtZOSbzlKgwHiIMGnu0F4kJnIC47M/Vr3h8VzqXOfV2TOV\nm6WGSAXDuLAtNBkPGQFeAAAAFHJ1bm5lckBydW5uZXJ2bWx1bjVwAQ==\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||
|
||||
const ed25519Pkcs8 = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACDRXAZBDSSce/bfa6hj2HEd62xJWSwTu9SEMTH94ytWaQAAAJg4DMdFOAzH\nRQAAAAtzc2gtZWQyNTUxOQAAACDRXAZBDSSce/bfa6hj2HEd62xJWSwTu9SEMTH94ytWaQ\nAAAEDCmdEVlGRm4wzFTThmFJUlHe2P38nLyTIK75rLl5uqC9FcBkENJJx79t9rqGPYcR3r\nbElZLBO71IQxMf3jK1ZpAAAAFHJ1bm5lckBydW5uZXJ2bWx1bjVwAQ==\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||
|
||||
const rsa = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn\nNhAAAAAwEAAQAAAQEAksdZMjBmIgapaqsWuzUUxP3aiHf1YrPRrHlQVlojhSvhq9gKjUlW\n5kM2cy70jtPmw3hQHdY/aYXWDJ8VxOMiJ5HnacL0WzgRFxBh1UgbRUFPfu6z8I0xUvQxBT\nZdoIYqFL4UbfI8K4TrgqBDt1RQ7qipOsldeyM0sjVgspIpHa7m0nrlpKVcyGZOuQPaJxN6\npYlwEO/D9/0Y1Xy+x/RdWpFBDTR+4cJyBdF+u6QziLz4ckJPHVJ2i69WoWDFWk3YxKlfl0\nRXlSas/O07rrTvz8cflvVkcqt/tI3TuCic3eBpW5Y2z+49EKo764SiWmrQM4++75v0GC4T\nc0BJHKIUxQAAA9AbHmyUGx5slAAAAAdzc2gtcnNhAAABAQCSx1kyMGYiBqlqqxa7NRTE/d\nqId/Vis9GseVBWWiOFK+Gr2AqNSVbmQzZzLvSO0+bDeFAd1j9phdYMnxXE4yInkedpwvRb\nOBEXEGHVSBtFQU9+7rPwjTFS9DEFNl2ghioUvhRt8jwrhOuCoEO3VFDuqKk6yV17IzSyNW\nCykikdrubSeuWkpVzIZk65A9onE3qliXAQ78P3/RjVfL7H9F1akUENNH7hwnIF0X67pDOI\nvPhyQk8dUnaLr1ahYMVaTdjEqV+XRFeVJqz87TuutO/Pxx+W9WRyq3+0jdO4KJzd4Glblj\nbP7j0QqjvrhKJaatAzj77vm/QYLhNzQEkcohTFAAAAAwEAAQAAAQAJmgcrNyr55IQA/tfZ\n/5hwDzJ/BT3lADdh+Cj5unl4Cjf/36jyYiRQTtWehlP9jejFdmGN+h3O1Nr88LaMroEQTp\nqb+rgKdD9Qn26O9GHC+jPtl/GRyw1kIaZH8teqxcFgoaiWIqet/yAlkj9WcappBQPWXSWD\nhc46LtoNrxhdISJUw4hoLk6kF+gCvTxg+HFdnMzgP3uaTt30Nu50l6ObibNVA+HgfsW3X7\nNpNGQFICoz2dpXeR+W5IFc0RsetbafViOkINrh0VR4XwqT+O85SFw5uUeKdiI04zsdBuLc\n64RSi/pdhjeoigreLgnrNJdDBItr0MyGC4vxGCSzxgwXAAAAgQDM+iNHVDx7eeeArZ9QgZ\noDDIXO23QgVn0wy4fVqCA58c7jfDf38Zi+r1OAxmpOBC3uuxeFG9JCgPIaxrTMCpR/lm6A\nXTtwt7xnJng0kEb0HFo1OutA3kOnH7yFu4LePrLXGLHzLTSAItQvvkYXlC1qBzhsI+hOuo\nNy+tJZlAj8wwAAAIEAzbTSlahw6AU71OejF/Swx0Vzt1eJ+zVaWRbbXzwp8ikINWcOxx7P\nftVHoc6ym1p21ODzIy0MPJaouoFuuDkDNjppgdIRx4/7IdwsgV8WQGBmGnIkbl/LVAPiUa\n5UjwTzvihWuZWc0gV1SQ69ps/sTm//Nsz1wviy7UOe3M+eDm8AAACBALaqPXxHj1wqoEDz\nzshqIOB0lh2JdNdA0j69VV5CxBE3FxSz28yokRt8ujk6Tc7TxIq7Io3wKrwXRowfUgYifF\n21L/5v8oUIWiHhrNs2TP762AceiT/5Zcng+Lyho5LZlqhPbLJ942+EUqbLaAXd4gfaaAzJ\nULE8nem1SxH1uuoLAAAAFHJ1bm5lckBydW5uZXJ2bWx1bjVwAQIDBAUG\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||
|
||||
const ed25519Pkcs8Oid = "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIKVlSCmmBY6Wn8YWY26XAN5zkD/vJYwCSIwsKk8brU27\n-----END PRIVATE KEY-----\n";
|
||||
|
||||
const sec1Ecdsa = "-----BEGIN EC PARAMETERS-----\nBggqhkjOPQMBBw==\n-----END EC PARAMETERS-----\n-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIDdLFJiwPdl1iJSWyGZsKguGOMJ6RCHzktI1govWhNULoAoGCCqGSM49\nAwEHoUQDQgAE5r3AAArr/FMR+klnWbM1tZXjY5/bKZmHSUgHoAbOe513vc1qemKJ\nkHaaNfqXD5NoUNw6fuHoFkWfi7YqXSD8oA==\n-----END EC PRIVATE KEY-----\n";
|
||||
|
||||
test("OpenSSH ECDSA P-256 keys detect as ECDSA with keySize 256", () => {
|
||||
assert.deepEqual(detectSshKeyType(ecdsaP256), { type: "ECDSA", keySize: 256 });
|
||||
});
|
||||
|
||||
test("OpenSSH ECDSA P-384 / P-521 keys detect with matching key sizes", () => {
|
||||
assert.deepEqual(detectSshKeyType(ecdsaP384), { type: "ECDSA", keySize: 384 });
|
||||
assert.deepEqual(detectSshKeyType(ecdsaP521), { type: "ECDSA", keySize: 521 });
|
||||
});
|
||||
|
||||
test("PKCS#8 ECDSA keys detect via DER OID, not plaintext hints", () => {
|
||||
assert.deepEqual(detectSshKeyType(ecdsaPkcs8), { type: "ECDSA", keySize: 256 });
|
||||
});
|
||||
|
||||
test("SEC1 EC private keys detect as ECDSA", () => {
|
||||
assert.deepEqual(detectSshKeyType(sec1Ecdsa), { type: "ECDSA" });
|
||||
});
|
||||
|
||||
test("OpenSSH and PKCS#8 Ed25519 keys stay ED25519", () => {
|
||||
assert.deepEqual(detectSshKeyType(ed25519), { type: "ED25519" });
|
||||
assert.deepEqual(detectSshKeyType(ed25519Pkcs8), { type: "ED25519" });
|
||||
assert.deepEqual(detectSshKeyType(ed25519Pkcs8Oid), { type: "ED25519" });
|
||||
});
|
||||
|
||||
test("OpenSSH RSA keys stay RSA", () => {
|
||||
assert.deepEqual(detectSshKeyType(rsa), { type: "RSA" });
|
||||
});
|
||||
|
||||
test("OpenSSH RSA comment cannot override the public-key algorithm", () => {
|
||||
const body = rsa.split(/\r?\n/).filter((line) => !line.includes("-----")).join("");
|
||||
const decoded = atob(body);
|
||||
const originalComment = "runner@runnervmlun5";
|
||||
const misleadingComment = "ecdsa-sha2-nistp256";
|
||||
assert.equal(decoded.includes(originalComment), true);
|
||||
assert.equal(originalComment.length, misleadingComment.length);
|
||||
const changedBody = btoa(decoded.replace(originalComment, misleadingComment));
|
||||
const key = `-----BEGIN OPENSSH PRIVATE KEY-----\n${changedBody}\n-----END OPENSSH PRIVATE KEY-----`;
|
||||
assert.deepEqual(detectSshKeyType(key), { type: "RSA" });
|
||||
});
|
||||
|
||||
test("public key algorithm prefix is used as a fallback", () => {
|
||||
assert.deepEqual(
|
||||
detectSshKeyType("not a key", "ecdsa-sha2-nistp256 AAAAB2 user@host"),
|
||||
{ type: "ECDSA", keySize: 256 },
|
||||
);
|
||||
assert.deepEqual(detectSshKeyType(undefined, "ssh-ed25519 AAAAC3 user@host"), {
|
||||
type: "ED25519",
|
||||
});
|
||||
assert.deepEqual(detectSshKeyType(undefined, "ssh-rsa AAAAB3 user@host"), {
|
||||
type: "RSA",
|
||||
});
|
||||
});
|
||||
|
||||
test("unrecognizable content defaults to ED25519 (ssh-keygen default)", () => {
|
||||
assert.deepEqual(detectSshKeyType("random text"), { type: "ED25519" });
|
||||
assert.deepEqual(detectSshKeyType(undefined, undefined), { type: "ED25519" });
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Pure SSH key-type detection for imported keys.
|
||||
*
|
||||
* Modern OpenSSH private keys ("-----BEGIN OPENSSH PRIVATE KEY-----") store the
|
||||
* algorithm name inside the base64 body (RFC 5656 / PROTOCOL.key), so text-only
|
||||
* heuristics on the PEM armor misdetect ECDSA keys as ED25519. Decode the base64
|
||||
* body and read its public-key algorithm field; fall back to DER OIDs for
|
||||
* PKCS#8 / SEC1 PEM bodies, then to the public key's algorithm prefix.
|
||||
*/
|
||||
|
||||
import type { KeyType } from './models';
|
||||
|
||||
export interface SshKeyTypeInfo {
|
||||
type: KeyType;
|
||||
/** RSA: 4096/2048/1024, ECDSA: 521/384/256 */
|
||||
keySize?: number;
|
||||
}
|
||||
|
||||
interface CurveInfo {
|
||||
name: string;
|
||||
keySize: number;
|
||||
}
|
||||
|
||||
const ECDSA_CURVES: CurveInfo[] = [
|
||||
{ name: 'ecdsa-sha2-nistp256', keySize: 256 },
|
||||
{ name: 'ecdsa-sha2-nistp384', keySize: 384 },
|
||||
{ name: 'ecdsa-sha2-nistp521', keySize: 521 },
|
||||
];
|
||||
|
||||
const OPENSSH_ARMOR = 'openssh private key';
|
||||
|
||||
// DER OID fragments (id-RSAEncryption 1.2.840.113549.1.1.1, id-ecPublicKey
|
||||
// 1.2.840.10045.2.1, id-Ed25519 1.3.101.112) as raw byte strings, matched
|
||||
// against the base64-decoded PEM body.
|
||||
const RSA_OID = '\u{2a}\u{86}\u{48}\u{86}\u{f7}\u{0d}\u{01}\u{01}\u{01}';
|
||||
const EC_PUBLIC_KEY_OID = '\u{2a}\u{86}\u{48}\u{ce}\u{3d}\u{02}\u{01}';
|
||||
const ED25519_OID = '\u{2b}\u{65}\u{70}';
|
||||
|
||||
// Named-curve OIDs for id-ecPublicKey parameters (secp256r1/secp384r1/secp521r1).
|
||||
const EC_CURVE_OIDS: Array<{ oid: string; keySize: number }> = [
|
||||
{ oid: '\u{2a}\u{86}\u{48}\u{ce}\u{3d}\u{03}\u{01}\u{07}', keySize: 256 },
|
||||
{ oid: '\u{2b}\u{81}\u{04}\u{00}\u{22}', keySize: 384 },
|
||||
{ oid: '\u{2b}\u{81}\u{04}\u{00}\u{23}', keySize: 521 },
|
||||
];
|
||||
|
||||
const decodeBase64Body = (pem: string): string | undefined => {
|
||||
const body = pem
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => !line.includes('-----'))
|
||||
.join('')
|
||||
.replace(/\s+/g, '');
|
||||
if (!body) return undefined;
|
||||
try {
|
||||
return atob(body);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const readSshString = (binary: string, offset: number): { value: string; end: number } | undefined => {
|
||||
if (offset + 4 > binary.length) return undefined;
|
||||
const length = binary.charCodeAt(offset) * 0x1000000
|
||||
+ binary.charCodeAt(offset + 1) * 0x10000
|
||||
+ binary.charCodeAt(offset + 2) * 0x100
|
||||
+ binary.charCodeAt(offset + 3);
|
||||
const end = offset + 4 + length;
|
||||
if (end > binary.length) return undefined;
|
||||
return { value: binary.slice(offset + 4, end), end };
|
||||
};
|
||||
|
||||
const detectFromOpenSshBody = (binary: string): SshKeyTypeInfo | undefined => {
|
||||
const magic = 'openssh-key-v1\0';
|
||||
if (!binary.startsWith(magic)) return undefined;
|
||||
let offset = magic.length;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const field = readSshString(binary, offset); // cipher, KDF, KDF options
|
||||
if (!field) return undefined;
|
||||
offset = field.end;
|
||||
}
|
||||
if (offset + 4 > binary.length || binary.slice(offset, offset + 4) === '\0\0\0\0') {
|
||||
return undefined; // no public keys
|
||||
}
|
||||
const publicKey = readSshString(binary, offset + 4);
|
||||
const algorithm = publicKey && readSshString(publicKey.value, 0)?.value;
|
||||
if (!algorithm) return undefined;
|
||||
for (const curve of ECDSA_CURVES) {
|
||||
if (algorithm === curve.name) {
|
||||
return { type: 'ECDSA', keySize: curve.keySize };
|
||||
}
|
||||
}
|
||||
if (algorithm === 'ssh-ed25519') return { type: 'ED25519' };
|
||||
if (algorithm === 'ssh-rsa') return { type: 'RSA' };
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const detectFromDerBody = (binary: string): SshKeyTypeInfo | undefined => {
|
||||
if (binary.includes(ED25519_OID)) return { type: 'ED25519' };
|
||||
if (binary.includes(RSA_OID)) return { type: 'RSA' };
|
||||
if (binary.includes(EC_PUBLIC_KEY_OID)) {
|
||||
for (const curve of EC_CURVE_OIDS) {
|
||||
if (binary.includes(curve.oid)) {
|
||||
return { type: 'ECDSA', keySize: curve.keySize };
|
||||
}
|
||||
}
|
||||
return { type: 'ECDSA' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const detectFromPrivateKey = (privateKey: string): SshKeyTypeInfo | undefined => {
|
||||
const pk = privateKey.toLowerCase();
|
||||
|
||||
if (pk.includes(OPENSSH_ARMOR)) {
|
||||
const binary = decodeBase64Body(privateKey);
|
||||
const fromBlob = binary ? detectFromOpenSshBody(binary) : undefined;
|
||||
if (fromBlob) return fromBlob;
|
||||
}
|
||||
|
||||
// PKCS#8 / SEC1 PEM bodies have no plaintext algorithm hints; look for DER OIDs.
|
||||
if (pk.includes('begin ') && pk.includes('private key')) {
|
||||
const binary = decodeBase64Body(privateKey);
|
||||
const fromDer = binary ? detectFromDerBody(binary) : undefined;
|
||||
if (fromDer) return fromDer;
|
||||
}
|
||||
|
||||
// Last-resort plaintext heuristics (legacy PEM armor, PPK comments, ...).
|
||||
if (pk.includes('rsa')) return { type: 'RSA' };
|
||||
if (pk.includes('ecdsa') || pk.includes('ec ') || pk.includes('ec private')) {
|
||||
return { type: 'ECDSA' };
|
||||
}
|
||||
if (pk.includes('ed25519')) return { type: 'ED25519' };
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const detectFromPublicKey = (publicKey: string): SshKeyTypeInfo | undefined => {
|
||||
const algorithm = publicKey.trim().split(/\s+/)[0]?.toLowerCase();
|
||||
if (!algorithm) return undefined;
|
||||
for (const curve of ECDSA_CURVES) {
|
||||
if (algorithm.includes(curve.name)) {
|
||||
return { type: 'ECDSA', keySize: curve.keySize };
|
||||
}
|
||||
}
|
||||
if (algorithm.includes('ed25519')) return { type: 'ED25519' };
|
||||
if (algorithm.includes('rsa')) return { type: 'RSA' };
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect the key type (and key size when known) for an imported SSH key.
|
||||
* Private-key evidence wins; the public key is a fallback; defaults to ED25519
|
||||
* (ssh-keygen's default) when nothing is recognizable.
|
||||
*/
|
||||
export const detectSshKeyType = (
|
||||
privateKey?: string | null,
|
||||
publicKey?: string | null,
|
||||
): SshKeyTypeInfo => {
|
||||
const fromPrivate = privateKey ? detectFromPrivateKey(privateKey) : undefined;
|
||||
if (fromPrivate) return fromPrivate;
|
||||
const fromPublic = publicKey ? detectFromPublicKey(publicKey) : undefined;
|
||||
return fromPublic ?? { type: 'ED25519' };
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
isQuickConnectInput,
|
||||
parseQuickConnectInput,
|
||||
} from "./quickConnect";
|
||||
|
||||
test("quick connect keeps plain user@host targets unchanged", () => {
|
||||
assert.deepEqual(parseQuickConnectInput("root@10.2.0.8"), {
|
||||
hostname: "10.2.0.8",
|
||||
username: "root",
|
||||
port: undefined,
|
||||
});
|
||||
assert.deepEqual(parseQuickConnectInput("10.2.0.8:2200"), {
|
||||
hostname: "10.2.0.8",
|
||||
username: undefined,
|
||||
port: 2200,
|
||||
});
|
||||
});
|
||||
|
||||
test("quick connect keeps the asset selector in the JumpServer username", () => {
|
||||
assert.deepEqual(parseQuickConnectInput("root@10.2.0.8@jump.corp.example"), {
|
||||
hostname: "jump.corp.example",
|
||||
username: "root@10.2.0.8",
|
||||
port: undefined,
|
||||
isJumpServerLogin: true,
|
||||
});
|
||||
assert.equal(isQuickConnectInput("root@10.2.0.8@jump.corp.example"), true);
|
||||
});
|
||||
|
||||
test("quick connect parses the four-part JumpServer login from issue 3523", () => {
|
||||
assert.deepEqual(
|
||||
parseQuickConnectInput("chenyi@root@10.2.0.8@devjumpserver.example.cn"),
|
||||
{
|
||||
hostname: "devjumpserver.example.cn",
|
||||
username: "chenyi@root@10.2.0.8",
|
||||
port: undefined,
|
||||
isJumpServerLogin: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("quick connect applies a suffix port to the JumpServer endpoint", () => {
|
||||
assert.deepEqual(parseQuickConnectInput("chenyi@root@10.2.0.8@jump:2222"), {
|
||||
hostname: "jump",
|
||||
username: "chenyi@root@10.2.0.8",
|
||||
port: 2222,
|
||||
isJumpServerLogin: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("ssh command ports apply to the JumpServer endpoint", () => {
|
||||
assert.deepEqual(parseQuickConnectInput("ssh -p 2222 chenyi@root@10.2.0.8@jump.corp.example"), {
|
||||
hostname: "jump.corp.example",
|
||||
username: "chenyi@root@10.2.0.8",
|
||||
port: 2222,
|
||||
isJumpServerLogin: true,
|
||||
});
|
||||
assert.deepEqual(parseQuickConnectInput("ssh -o Port=2200 chenyi@root@10.2.0.8@jump"), {
|
||||
hostname: "jump",
|
||||
username: "chenyi@root@10.2.0.8",
|
||||
port: 2200,
|
||||
isJumpServerLogin: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("ssh HostName override keeps the JumpServer login name", () => {
|
||||
assert.deepEqual(
|
||||
parseQuickConnectInput("ssh -o HostName=actual-jump.example -p 2222 chenyi@root@10.2.0.8@jump"),
|
||||
{
|
||||
hostname: "actual-jump.example",
|
||||
username: "chenyi@root@10.2.0.8",
|
||||
port: 2222,
|
||||
isJumpServerLogin: true,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseQuickConnectInput("ssh -o HostName=actual-jump.example chenyi@root@10.2.0.8@alias_name"),
|
||||
{
|
||||
hostname: "actual-jump.example",
|
||||
username: "chenyi@root@10.2.0.8",
|
||||
port: undefined,
|
||||
isJumpServerLogin: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("ordinary SSH options still resolve single-@ targets", () => {
|
||||
assert.deepEqual(parseQuickConnectInput("ssh -p 2200 deploy@host.example"), {
|
||||
hostname: "host.example",
|
||||
username: "deploy",
|
||||
port: 2200,
|
||||
});
|
||||
assert.deepEqual(parseQuickConnectInput("ssh -o HostName=actual.example -l deploy alias"), {
|
||||
hostname: "actual.example",
|
||||
username: "deploy",
|
||||
port: undefined,
|
||||
});
|
||||
assert.deepEqual(parseQuickConnectInput("ssh -o HostName=actual.example deploy@alias_name"), {
|
||||
hostname: "actual.example",
|
||||
username: "deploy",
|
||||
port: undefined,
|
||||
});
|
||||
assert.deepEqual(parseQuickConnectInput("ssh -l user@realm host.example"), {
|
||||
hostname: "host.example",
|
||||
username: "user@realm",
|
||||
port: undefined,
|
||||
});
|
||||
assert.deepEqual(parseQuickConnectInput("ssh -o User=user@realm host.example"), {
|
||||
hostname: "host.example",
|
||||
username: "user@realm",
|
||||
port: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("quick connect rejects malformed multi-@ inputs", () => {
|
||||
assert.equal(parseQuickConnectInput("a@b@c@d@e"), null);
|
||||
assert.equal(parseQuickConnectInput("bad user@10.2.0.8@jump"), null);
|
||||
assert.equal(parseQuickConnectInput("root@10.2.0.8@"), null);
|
||||
assert.equal(parseQuickConnectInput("root@bad host@jump"), null);
|
||||
});
|
||||
+61
-3
@@ -2,6 +2,8 @@ export interface QuickConnectTarget {
|
||||
hostname: string;
|
||||
username?: string;
|
||||
port?: number;
|
||||
/** The @ separators in the address are part of a JumpServer login name. */
|
||||
isJumpServerLogin?: true;
|
||||
}
|
||||
|
||||
interface QuickConnectParseResult {
|
||||
@@ -77,6 +79,47 @@ const parseDirectTarget = (input: string): QuickConnectTarget | null => {
|
||||
};
|
||||
};
|
||||
|
||||
/** Non-empty pieces of a JumpServer login name. */
|
||||
const USERNAME_SEGMENT_RE = /^[^\s@]+$/;
|
||||
|
||||
const parseUsernameFromHostToken = (input: string): string | undefined => {
|
||||
const segments = input.trim().split("@");
|
||||
if (segments.length < 2 || segments.length > 4 || !segments[segments.length - 1]) {
|
||||
return undefined;
|
||||
}
|
||||
const usernameSegments = segments.slice(0, -1);
|
||||
return usernameSegments.every((segment) => USERNAME_SEGMENT_RE.test(segment))
|
||||
? usernameSegments.join("@")
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* JumpServer selects the asset from the SSH login name. OpenSSH connects to
|
||||
* the final host and sends every preceding segment as one username, including
|
||||
* the @ separators. This is a single SSH connection, not a ProxyJump chain.
|
||||
*/
|
||||
const parseJumpServerTarget = (input: string): QuickConnectTarget | null => {
|
||||
const trimmed = input.trim();
|
||||
const atCount = (trimmed.match(/@/g) || []).length;
|
||||
if (atCount < 2 || atCount > 3) return null;
|
||||
|
||||
const segments = trimmed.split("@");
|
||||
const host = parseDirectTarget(segments[segments.length - 1]);
|
||||
const username = parseUsernameFromHostToken(trimmed);
|
||||
if (!host || !username) return null;
|
||||
|
||||
return {
|
||||
hostname: host.hostname,
|
||||
username,
|
||||
port: host.port,
|
||||
isJumpServerLogin: true,
|
||||
};
|
||||
};
|
||||
|
||||
/** Parse a quick connect target, accepting JumpServer login names. */
|
||||
const parseTargetWithCompositeUser = (input: string): QuickConnectTarget | null =>
|
||||
parseJumpServerTarget(input) ?? parseDirectTarget(input);
|
||||
|
||||
const sshArgOptions = new Set([
|
||||
"-b",
|
||||
"-c",
|
||||
@@ -248,9 +291,21 @@ const parseSshCommand = (input: string): QuickConnectParseResult | null => {
|
||||
|
||||
if (!hostToken) return null;
|
||||
|
||||
const hostTokenTarget = parseTargetWithCompositeUser(hostToken);
|
||||
const hostTokenAtCount = (hostToken.match(/@/g) || []).length;
|
||||
const hasCompositeHostToken = hostTokenAtCount >= 2
|
||||
&& hostTokenAtCount <= 3
|
||||
&& Boolean(parseUsernameFromHostToken(hostToken));
|
||||
const hostnameOverride = optionHostname ? parseDirectTarget(optionHostname) : null;
|
||||
const base = optionHostname
|
||||
? parseDirectTarget(optionHostname)
|
||||
: parseDirectTarget(hostToken);
|
||||
? hostnameOverride && {
|
||||
...hostnameOverride,
|
||||
username: hostTokenTarget?.username
|
||||
?? parseUsernameFromHostToken(hostToken)
|
||||
?? hostnameOverride.username,
|
||||
...(hasCompositeHostToken ? { isJumpServerLogin: true as const } : {}),
|
||||
}
|
||||
: hostTokenTarget;
|
||||
if (!base) return null;
|
||||
|
||||
if (portInvalid) return null;
|
||||
@@ -273,6 +328,9 @@ const parseSshCommand = (input: string): QuickConnectParseResult | null => {
|
||||
hostname: base.hostname,
|
||||
username: optionUsername || username || base.username,
|
||||
port: resolvedPort,
|
||||
...(base.isJumpServerLogin && !optionUsername && !username
|
||||
? { isJumpServerLogin: true as const }
|
||||
: {}),
|
||||
},
|
||||
warnings: Array.from(new Set(warnings)),
|
||||
};
|
||||
@@ -288,7 +346,7 @@ export function parseQuickConnectInputWithWarnings(
|
||||
const sshTarget = parseSshCommand(trimmed);
|
||||
if (sshTarget) return sshTarget;
|
||||
|
||||
return { target: parseDirectTarget(trimmed), warnings: [] };
|
||||
return { target: parseTargetWithCompositeUser(trimmed), warnings: [] };
|
||||
}
|
||||
|
||||
// Parse user@host:port or ssh command formats
|
||||
|
||||
@@ -2,12 +2,34 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildQuickConnectHost,
|
||||
isQuickConnectIdentityCompatible,
|
||||
isQuickConnectIdentityUsable,
|
||||
} from "./quickConnectHost.ts";
|
||||
import { resolveHostAuth } from "./sshAuth.ts";
|
||||
|
||||
const target = { hostname: "example.com" };
|
||||
|
||||
test("JumpServer login only offers identities with the full asset selector", () => {
|
||||
const identity = {
|
||||
id: "jump-user",
|
||||
label: "Jump user",
|
||||
username: "chenyi",
|
||||
authMethod: "password" as const,
|
||||
password: "secret",
|
||||
created: 1,
|
||||
};
|
||||
const jumpTarget = { hostname: "jump.example", username: "chenyi@root@10.2.0.8", isJumpServerLogin: true as const };
|
||||
assert.equal(isQuickConnectIdentityCompatible(identity, jumpTarget), false);
|
||||
assert.equal(isQuickConnectIdentityCompatible(
|
||||
{ ...identity, username: "other" }, jumpTarget,
|
||||
), false);
|
||||
assert.equal(isQuickConnectIdentityCompatible(
|
||||
{ ...identity, username: jumpTarget.username }, jumpTarget,
|
||||
), true);
|
||||
assert.equal(isQuickConnectIdentityCompatible(identity, { hostname: "jump.example", username: "chenyi" }), true);
|
||||
assert.equal(isQuickConnectIdentityCompatible(identity, { hostname: "host.example", username: "user@realm" }), true);
|
||||
});
|
||||
|
||||
test("quick connect keeps a selected credential preset as the host identity", () => {
|
||||
const host = buildQuickConnectHost({
|
||||
id: "quick-1",
|
||||
|
||||
@@ -21,6 +21,12 @@ export const isQuickConnectIdentityUsable = (
|
||||
return Boolean(identity.keyId && keys.some((key) => key.id === identity.keyId));
|
||||
};
|
||||
|
||||
export const isQuickConnectIdentityCompatible = (
|
||||
identity: Identity,
|
||||
target: QuickConnectTarget,
|
||||
): boolean => !target.isJumpServerLogin
|
||||
|| identity.username === target.username;
|
||||
|
||||
type BuildQuickConnectHostInput = {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
|
||||
@@ -171,6 +171,13 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
|
||||
descriptionKey: "settings.vault.showSftpTabDesc",
|
||||
sectionKey: "settings.vault.title",
|
||||
},
|
||||
{
|
||||
id: "appearance-vault-sftp-in-sidebar",
|
||||
tab: "appearance",
|
||||
labelKey: "settings.vault.sftpInSidebar",
|
||||
descriptionKey: "settings.vault.sftpInSidebarDesc",
|
||||
sectionKey: "settings.vault.title",
|
||||
},
|
||||
{
|
||||
id: "appearance-vault-host-tree",
|
||||
tab: "appearance",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { Host } from "./models.ts";
|
||||
import { isSameSftpHostSelection, resolveTerminalSftpHost } from "./sftpTerminalIdentity.ts";
|
||||
|
||||
const host = (id: string, username: string): Host => ({
|
||||
id, label: id, hostname: `${id}.example.test`, port: 22, username,
|
||||
tags: [], os: "linux",
|
||||
});
|
||||
|
||||
test("SFTP target changes when a focused session uses another saved identity", () => {
|
||||
const root = host("shared", "root");
|
||||
const deploy = { ...root, username: "deploy", identityId: "deploy-id" };
|
||||
const alternateDeploy = { ...deploy, identityId: "alternate-deploy-id" };
|
||||
|
||||
assert.equal(isSameSftpHostSelection(root, deploy), false);
|
||||
assert.equal(isSameSftpHostSelection(deploy, alternateDeploy), false);
|
||||
assert.equal(isSameSftpHostSelection(deploy, { ...deploy }), true);
|
||||
assert.equal(isSameSftpHostSelection(deploy, { ...deploy, port: 2222 }), false);
|
||||
});
|
||||
|
||||
test("SFTP follows each focused terminal's authenticated identity across focus changes", () => {
|
||||
const root = host("shared", "root");
|
||||
const deploy = { ...root, username: "deploy", identityId: "deploy-id" };
|
||||
const other = host("other", "alice");
|
||||
|
||||
assert.equal(resolveTerminalSftpHost({
|
||||
sessionHost: root, storedHost: other, authenticatedHost: deploy, followsTerminal: true,
|
||||
}), deploy);
|
||||
assert.equal(resolveTerminalSftpHost({
|
||||
sessionHost: root, storedHost: root, authenticatedHost: deploy, followsTerminal: true,
|
||||
}), deploy);
|
||||
assert.equal(resolveTerminalSftpHost({
|
||||
sessionHost: other, storedHost: deploy, authenticatedHost: undefined, followsTerminal: true,
|
||||
}), other);
|
||||
assert.equal(resolveTerminalSftpHost({
|
||||
sessionHost: root, storedHost: other, authenticatedHost: deploy, followsTerminal: true,
|
||||
}), deploy);
|
||||
assert.equal(resolveTerminalSftpHost({
|
||||
sessionHost: root, storedHost: other, authenticatedHost: deploy, followsTerminal: false,
|
||||
}), root);
|
||||
assert.equal(resolveTerminalSftpHost({
|
||||
sessionHost: root, authenticatedHost: { ...deploy, port: 2222 }, followsTerminal: true,
|
||||
}), root);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Host } from "./models";
|
||||
|
||||
/** Compare the selected SFTP target, including the temporary login identity. */
|
||||
export function isSameSftpHostSelection(left: Host | null | undefined, right: Host | null | undefined): boolean {
|
||||
if (!left || !right) return false;
|
||||
return left.id === right.id
|
||||
&& left.hostname === right.hostname
|
||||
&& left.port === right.port
|
||||
&& left.protocol === right.protocol
|
||||
&& left.username === right.username
|
||||
&& left.identityId === right.identityId
|
||||
&& left.sftpSudo === right.sftpSudo
|
||||
&& (left.sftpFileProtocol || "auto") === (right.sftpFileProtocol || "auto");
|
||||
}
|
||||
|
||||
/** Keep a terminal's authenticated identity when its SFTP panel follows that terminal. */
|
||||
export function resolveTerminalSftpHost({
|
||||
sessionHost,
|
||||
storedHost,
|
||||
authenticatedHost,
|
||||
followsTerminal,
|
||||
}: {
|
||||
sessionHost?: Host | null;
|
||||
storedHost?: Host | null;
|
||||
authenticatedHost?: Host | null;
|
||||
followsTerminal: boolean;
|
||||
}): Host | null {
|
||||
if (followsTerminal && sessionHost && authenticatedHost
|
||||
&& authenticatedHost.id === sessionHost.id
|
||||
&& authenticatedHost.hostname === sessionHost.hostname
|
||||
&& (authenticatedHost.port || 22) === (sessionHost.port || 22)) {
|
||||
return authenticatedHost;
|
||||
}
|
||||
return sessionHost ?? storedHost ?? null;
|
||||
}
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
closeSidePanelPane,
|
||||
collectSidePanelPanes,
|
||||
createSidePanelLayout,
|
||||
cycleSidePanelDockPosition,
|
||||
focusSidePanelPane,
|
||||
getSidePanelNodeMinimumPixels,
|
||||
getSidePanelSplitResizeBounds,
|
||||
isSidePanelDockPosition,
|
||||
resizeSidePanelSplit,
|
||||
selectSidePanelTool,
|
||||
sidePanelLayoutHasTool,
|
||||
@@ -166,3 +168,15 @@ test('the pane limit rejects additional splits without changing the layout', ()
|
||||
assert.equal(collectSidePanelPanes(duplicateAttempt.root).length, 7);
|
||||
assert.equal(duplicateAttempt.focusedPaneId, 'pane-0');
|
||||
});
|
||||
|
||||
test('dock position cycles through left, right and bottom', () => {
|
||||
assert.equal(cycleSidePanelDockPosition('left'), 'right');
|
||||
assert.equal(cycleSidePanelDockPosition('right'), 'bottom');
|
||||
assert.equal(cycleSidePanelDockPosition('bottom'), 'left');
|
||||
});
|
||||
|
||||
test('dock position validator accepts only known positions', () => {
|
||||
assert.equal(isSidePanelDockPosition('bottom'), true);
|
||||
assert.equal(isSidePanelDockPosition('top'), false);
|
||||
assert.equal(isSidePanelDockPosition(42), false);
|
||||
});
|
||||
@@ -249,3 +249,23 @@ export function resizeSidePanelSplit(
|
||||
const root = patch(layout.root);
|
||||
return root === layout.root ? layout : { ...layout, root };
|
||||
}
|
||||
|
||||
export type SidePanelDockPosition = 'left' | 'right' | 'bottom';
|
||||
|
||||
export const SIDE_PANEL_DOCK_POSITIONS: readonly SidePanelDockPosition[] = [
|
||||
'left',
|
||||
'right',
|
||||
'bottom',
|
||||
];
|
||||
|
||||
export function isSidePanelDockPosition(value: unknown): value is SidePanelDockPosition {
|
||||
return typeof value === 'string'
|
||||
&& (SIDE_PANEL_DOCK_POSITIONS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function cycleSidePanelDockPosition(
|
||||
position: SidePanelDockPosition,
|
||||
): SidePanelDockPosition {
|
||||
const index = SIDE_PANEL_DOCK_POSITIONS.indexOf(position);
|
||||
return SIDE_PANEL_DOCK_POSITIONS[(index + 1) % SIDE_PANEL_DOCK_POSITIONS.length];
|
||||
}
|
||||
@@ -341,6 +341,8 @@ export interface SyncPayload {
|
||||
// Top tabs: show standalone SFTP view tab
|
||||
tabBarPosition?: 'top' | 'bottom';
|
||||
showSftpTab?: boolean;
|
||||
// Vault sidebar: keep the sidebar visible next to the SFTP view
|
||||
sftpInSidebar?: boolean;
|
||||
// Shortcuts: Cmd/Ctrl+[1...9] and Ctrl+Tab skip pinned Vault/SFTP tabs
|
||||
shellOnlyTabNumberShortcuts?: boolean;
|
||||
// Shortcuts: show 1...9 badges on tabs matching number switch shortcuts
|
||||
|
||||
@@ -112,6 +112,9 @@ function isUntrustedTerminalInputPrompt(value, options = {}) {
|
||||
const prompt = lastLogicalLine(value).trim();
|
||||
if (!prompt) return false;
|
||||
if (isSensitiveTerminalChallenge(prompt)) return true;
|
||||
// Vim command-mode entry on the alternate screen. A bare `:` on the
|
||||
// primary screen stays fail-closed (`sudo -p ':'`).
|
||||
if (options.alternateScreen && /^:\s*$/.test(prompt)) return false;
|
||||
if (!/[::>›»]\s*$/u.test(prompt)) return false;
|
||||
if (hasTypedInputAfterConfirmedPrompt(prompt, options)) return false;
|
||||
return !isConfirmedTerminalShellPrompt(prompt, options);
|
||||
|
||||
@@ -122,6 +122,16 @@ test('ordinary shell commands ending with a colon are not untrusted input prompt
|
||||
assert.equal(isUntrustedTerminalInputPrompt('Password: '), true);
|
||||
});
|
||||
|
||||
test('vim ex command-line is not an untrusted input prompt (#3422)', () => {
|
||||
assert.equal(isUntrustedTerminalInputPrompt(':', { alternateScreen: true }), false);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(': ', { alternateScreen: true }), false);
|
||||
// Primary screen: `sudo -p ':'` stays fail-closed.
|
||||
assert.equal(isUntrustedTerminalInputPrompt(':'), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(':', { alternateScreen: false }), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('Password:'), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('Token: '), true);
|
||||
});
|
||||
|
||||
test('renderer and main-process prompt classifiers stay aligned', () => {
|
||||
const cases: Array<[
|
||||
string,
|
||||
@@ -130,6 +140,8 @@ test('renderer and main-process prompt classifiers stay aligned', () => {
|
||||
]> = [
|
||||
['Custom authentication> ', undefined, true],
|
||||
['Please authenticate: ', undefined, true],
|
||||
[':', { alternateScreen: true }, false],
|
||||
[':', undefined, true],
|
||||
['alice@host:~$ ', undefined, false],
|
||||
['user@host:~$ lsof -i:', undefined, false],
|
||||
['Challenge # 1:', undefined, true],
|
||||
|
||||
@@ -96,6 +96,11 @@ export function isSensitiveTerminalChallenge(value: string): boolean {
|
||||
type ConfirmedPromptOptions = {
|
||||
/** Network-device shells commonly use a bare host name followed by `>`. */
|
||||
allowHostStyleGreaterThan?: boolean;
|
||||
/**
|
||||
* True when the terminal is on the alternate screen (DEC 1049). Used to
|
||||
* tell vim's command-line `:` from a custom `sudo -p ':'` prompt.
|
||||
*/
|
||||
alternateScreen?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -192,6 +197,9 @@ export function isUntrustedTerminalInputPrompt(
|
||||
const prompt = lastLogicalLine(value).trim();
|
||||
if (!prompt) return false;
|
||||
if (isSensitiveTerminalChallenge(prompt)) return true;
|
||||
// Vim command-mode entry on the alternate screen. A bare `:` on the
|
||||
// primary screen stays fail-closed (`sudo -p ':'`).
|
||||
if (options.alternateScreen && /^:\s*$/.test(prompt)) return false;
|
||||
if (!/[::>›»]\s*$/u.test(prompt)) return false;
|
||||
// Mid-command punctuation after a real shell prompt must keep broadcasting
|
||||
// (#2709). Standalone `Label:` / `Custom>` challenges still fail closed.
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createWorkspaceLayoutPreset,
|
||||
rekeySidePanelLayout,
|
||||
sanitizeWorkspaceLayoutPreset,
|
||||
withoutUnavailableSidePanelTools,
|
||||
} from './workspaceLayoutPreset.ts';
|
||||
import {
|
||||
collectSidePanelPanes,
|
||||
createSidePanelLayout,
|
||||
getFocusedSidePanelPane,
|
||||
splitSidePanelPane,
|
||||
} from './sidePanelLayout.ts';
|
||||
|
||||
function buildSplitLayout() {
|
||||
let layout = createSidePanelLayout('scripts', 'pane-scripts');
|
||||
layout = splitSidePanelPane(layout, 'pane-scripts', 'sftp', 'horizontal', {
|
||||
paneId: 'pane-sftp',
|
||||
splitId: 'split-root',
|
||||
}, 400);
|
||||
layout = splitSidePanelPane(layout, 'pane-scripts', 'notes', 'vertical', {
|
||||
paneId: 'pane-notes',
|
||||
splitId: 'split-scripts',
|
||||
}, 200);
|
||||
return layout;
|
||||
}
|
||||
|
||||
test('sanitize accepts a valid saved preset and rejects malformed data', () => {
|
||||
const preset = createWorkspaceLayoutPreset(buildSplitLayout());
|
||||
assert.ok(preset);
|
||||
assert.equal(sanitizeWorkspaceLayoutPreset(preset)?.layout.focusedPaneId, preset.layout.focusedPaneId);
|
||||
|
||||
assert.equal(sanitizeWorkspaceLayoutPreset(null), null);
|
||||
assert.equal(sanitizeWorkspaceLayoutPreset({ version: 2, layout: preset?.layout }), null);
|
||||
assert.equal(sanitizeWorkspaceLayoutPreset({ version: 1 }), null);
|
||||
assert.equal(sanitizeWorkspaceLayoutPreset({ version: 1, layout: { focusedPaneId: 'x' } }), null);
|
||||
assert.equal(
|
||||
sanitizeWorkspaceLayoutPreset({
|
||||
version: 1,
|
||||
layout: { root: { id: 'p', type: 'pane', tool: 'nope' }, focusedPaneId: 'p' },
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
sanitizeWorkspaceLayoutPreset({
|
||||
version: 1,
|
||||
layout: { root: { id: 'p', type: 'pane', tool: 'notes' }, focusedPaneId: 'missing' },
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
sanitizeWorkspaceLayoutPreset({
|
||||
version: 1,
|
||||
layout: {
|
||||
root: {
|
||||
id: 's',
|
||||
type: 'split',
|
||||
direction: 'diagonal',
|
||||
children: [
|
||||
{ id: 'a', type: 'pane', tool: 'notes' },
|
||||
{ id: 'b', type: 'pane', tool: 'ai' },
|
||||
],
|
||||
sizes: [0.5, 0.5],
|
||||
},
|
||||
focusedPaneId: 'a',
|
||||
},
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
sanitizeWorkspaceLayoutPreset({
|
||||
version: 1,
|
||||
layout: {
|
||||
root: {
|
||||
id: 's',
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ id: 'a', type: 'pane', tool: 'notes' },
|
||||
{ id: 'b', type: 'pane', tool: 'ai' },
|
||||
],
|
||||
sizes: [0.5, 0, 0.5],
|
||||
},
|
||||
focusedPaneId: 'a',
|
||||
},
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('sanitize rejects duplicate node ids', () => {
|
||||
assert.equal(
|
||||
sanitizeWorkspaceLayoutPreset({
|
||||
version: 1,
|
||||
layout: {
|
||||
root: {
|
||||
id: 's',
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ id: 'dup', type: 'pane', tool: 'notes' },
|
||||
{ id: 'dup', type: 'pane', tool: 'ai' },
|
||||
],
|
||||
sizes: [0.5, 0.5],
|
||||
},
|
||||
focusedPaneId: 'dup',
|
||||
},
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('sanitize rejects duplicate pane tools', () => {
|
||||
assert.equal(
|
||||
sanitizeWorkspaceLayoutPreset({
|
||||
version: 1,
|
||||
layout: {
|
||||
root: {
|
||||
id: 's',
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ id: 'a', type: 'pane', tool: 'notes' },
|
||||
{ id: 'b', type: 'pane', tool: 'notes' },
|
||||
],
|
||||
sizes: [0.5, 0.5],
|
||||
},
|
||||
focusedPaneId: 'a',
|
||||
},
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('rekey keeps structure, sizes and focused ordinal with fresh ids', () => {
|
||||
const source = buildSplitLayout();
|
||||
const sourceFocusedTool = getFocusedSidePanelPane(source).tool;
|
||||
let counter = 0;
|
||||
const rekeyed = rekeySidePanelLayout(source, () => `fresh-${++counter}`);
|
||||
|
||||
assert.deepEqual(collectSidePanelPanes(rekeyed.root).map((pane) => pane.tool),
|
||||
collectSidePanelPanes(source.root).map((pane) => pane.tool));
|
||||
assert.equal(getFocusedSidePanelPane(rekeyed).tool, sourceFocusedTool);
|
||||
// Fresh ids are assigned depth-first; focus follows the focused pane's
|
||||
// ordinal position in the source layout.
|
||||
const sourcePanes = collectSidePanelPanes(source.root);
|
||||
const rekeyedPanes = collectSidePanelPanes(rekeyed.root);
|
||||
const focusedIndex = sourcePanes.findIndex((pane) => pane.id === 'pane-notes');
|
||||
assert.equal(rekeyed.focusedPaneId, rekeyedPanes[focusedIndex].id);
|
||||
assert.equal(getFocusedSidePanelPane(rekeyed).id, rekeyedPanes[focusedIndex].id);
|
||||
assert.ok(!rekeyedPanes.some((pane) => pane.id === 'pane-notes'));
|
||||
|
||||
const sourceSplits = (source.root as { children: unknown[] }).children;
|
||||
const rekeyedSplits = (rekeyed.root as { children: unknown[] }).children;
|
||||
assert.notDeepEqual(rekeyedSplits, sourceSplits);
|
||||
assert.notEqual(rekeyed.root.id, source.root.id);
|
||||
});
|
||||
|
||||
test('withoutUnavailableSidePanelTools prunes only unavailable panes', () => {
|
||||
const layout = buildSplitLayout();
|
||||
const kept = withoutUnavailableSidePanelTools(layout, (tool) => tool !== 'sftp');
|
||||
assert.ok(kept);
|
||||
assert.deepEqual(collectSidePanelPanes(kept.root).map((pane) => pane.tool), ['scripts', 'notes']);
|
||||
assert.ok(getFocusedSidePanelPane(kept));
|
||||
|
||||
// The remaining split sizes renormalize after pruning (close collapse).
|
||||
const split = kept.root as { type: 'split'; sizes: number[] };
|
||||
assert.equal(split.sizes.reduce((sum, size) => sum + size, 0), 1);
|
||||
|
||||
// Removing the only remaining pane collapses the layout entirely.
|
||||
assert.equal(withoutUnavailableSidePanelTools(createSidePanelLayout('notes', 'p'), () => false), null);
|
||||
// Nothing unavailable keeps the layout untouched.
|
||||
assert.equal(withoutUnavailableSidePanelTools(layout, () => true), layout);
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
MAX_SIDE_PANEL_PANES,
|
||||
closeSidePanelPane,
|
||||
collectSidePanelPanes,
|
||||
getFocusedSidePanelPane,
|
||||
type SidePanelLayout,
|
||||
type SidePanelLayoutNode,
|
||||
type SidePanelTool,
|
||||
} from './sidePanelLayout';
|
||||
|
||||
export const WORKSPACE_LAYOUT_PRESET_VERSION = 1 as const;
|
||||
|
||||
/**
|
||||
* Device-local snapshot of a side panel arrangement (split panes + tools)
|
||||
* the user promoted to their default. New terminal sessions apply it on
|
||||
* connect so the preferred workspace needs no manual re-setup.
|
||||
*/
|
||||
export type WorkspaceLayoutPreset = {
|
||||
version: typeof WORKSPACE_LAYOUT_PRESET_VERSION;
|
||||
layout: SidePanelLayout;
|
||||
};
|
||||
|
||||
const SIDE_PANEL_TOOLS: ReadonlySet<SidePanelTool> = new Set([
|
||||
'sftp',
|
||||
'scripts',
|
||||
'history',
|
||||
'theme',
|
||||
'ai',
|
||||
'system',
|
||||
'notes',
|
||||
]);
|
||||
|
||||
function isSidePanelTool(value: unknown): value is SidePanelTool {
|
||||
return typeof value === 'string' && SIDE_PANEL_TOOLS.has(value as SidePanelTool);
|
||||
}
|
||||
|
||||
function sanitizeSidePanelLayoutNode(
|
||||
raw: unknown,
|
||||
seenIds: Set<string>,
|
||||
seenTools: Set<SidePanelTool>,
|
||||
): SidePanelLayoutNode | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const node = raw as Record<string, unknown>;
|
||||
if (typeof node.id !== 'string' || node.id.length === 0 || seenIds.has(node.id)) return null;
|
||||
seenIds.add(node.id);
|
||||
|
||||
if (node.type === 'pane') {
|
||||
if (!isSidePanelTool(node.tool) || seenTools.has(node.tool)) return null;
|
||||
seenTools.add(node.tool);
|
||||
return { id: node.id, type: 'pane', tool: node.tool };
|
||||
}
|
||||
|
||||
if (node.type !== 'split') return null;
|
||||
if (node.direction !== 'horizontal' && node.direction !== 'vertical') return null;
|
||||
if (!Array.isArray(node.children) || node.children.length < 2) return null;
|
||||
if (!Array.isArray(node.sizes) || node.sizes.length !== node.children.length) return null;
|
||||
|
||||
const children: SidePanelLayoutNode[] = [];
|
||||
const sizes: number[] = [];
|
||||
for (let index = 0; index < node.children.length; index += 1) {
|
||||
const child = sanitizeSidePanelLayoutNode(node.children[index], seenIds, seenTools);
|
||||
if (!child) return null;
|
||||
const size = node.sizes[index];
|
||||
if (typeof size !== 'number' || !Number.isFinite(size) || size <= 0) return null;
|
||||
children.push(child);
|
||||
sizes.push(size);
|
||||
}
|
||||
return { id: node.id, type: 'split', direction: node.direction, children, sizes };
|
||||
}
|
||||
|
||||
/** Validate persisted/unknown data into a usable preset, or null when unusable. */
|
||||
export function sanitizeWorkspaceLayoutPreset(raw: unknown): WorkspaceLayoutPreset | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const value = raw as Record<string, unknown>;
|
||||
if (value.version !== WORKSPACE_LAYOUT_PRESET_VERSION) return null;
|
||||
|
||||
const layoutRaw = value.layout;
|
||||
if (!layoutRaw || typeof layoutRaw !== 'object') return null;
|
||||
const seenIds = new Set<string>();
|
||||
const seenTools = new Set<SidePanelTool>();
|
||||
const root = sanitizeSidePanelLayoutNode(
|
||||
(layoutRaw as Record<string, unknown>).root,
|
||||
seenIds,
|
||||
seenTools,
|
||||
);
|
||||
if (!root) return null;
|
||||
|
||||
const panes = collectSidePanelPanes(root);
|
||||
if (panes.length === 0 || panes.length > MAX_SIDE_PANEL_PANES) return null;
|
||||
const focusedPaneId = (layoutRaw as Record<string, unknown>).focusedPaneId;
|
||||
if (typeof focusedPaneId !== 'string' || !panes.some((pane) => pane.id === focusedPaneId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { version: WORKSPACE_LAYOUT_PRESET_VERSION, layout: { root, focusedPaneId } };
|
||||
}
|
||||
|
||||
/** Build a preset from a live layout, reusing the same validation as reads. */
|
||||
export function createWorkspaceLayoutPreset(
|
||||
layout: SidePanelLayout,
|
||||
): WorkspaceLayoutPreset | null {
|
||||
return sanitizeWorkspaceLayoutPreset({
|
||||
version: WORKSPACE_LAYOUT_PRESET_VERSION,
|
||||
layout,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a preset layout with fresh pane/split ids so the same preset can be
|
||||
* applied to many workspaces without sharing node identities. Focus follows
|
||||
* the focused pane's ordinal position in the source layout.
|
||||
*/
|
||||
export function rekeySidePanelLayout(
|
||||
layout: SidePanelLayout,
|
||||
generateId: () => string,
|
||||
): SidePanelLayout {
|
||||
const remap = (node: SidePanelLayoutNode): SidePanelLayoutNode => {
|
||||
if (node.type === 'pane') return { ...node, id: generateId() };
|
||||
return {
|
||||
...node,
|
||||
id: generateId(),
|
||||
children: node.children.map(remap),
|
||||
};
|
||||
};
|
||||
|
||||
const root = remap(layout.root);
|
||||
const sourcePanes = collectSidePanelPanes(layout.root);
|
||||
const sourceFocused = getFocusedSidePanelPane(layout);
|
||||
const focusedIndex = Math.max(
|
||||
0,
|
||||
sourcePanes.findIndex((pane) => pane.id === sourceFocused.id),
|
||||
);
|
||||
const focusedPaneId = collectSidePanelPanes(root)[focusedIndex]?.id ?? layout.focusedPaneId;
|
||||
return { root, focusedPaneId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop panes whose tool cannot work for the target session (pruning splits
|
||||
* like a manual pane close). Returns null when nothing remains.
|
||||
*/
|
||||
export function withoutUnavailableSidePanelTools(
|
||||
layout: SidePanelLayout,
|
||||
isToolAvailable: (tool: SidePanelTool) => boolean,
|
||||
): SidePanelLayout | null {
|
||||
let current = layout;
|
||||
for (const pane of collectSidePanelPanes(layout.root)) {
|
||||
if (isToolAvailable(pane.tool)) continue;
|
||||
const next = closeSidePanelPane(current, pane.id);
|
||||
if (!next) return null;
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user