Compare commits

...
Author SHA1 Message Date
bincxz f7ad0207c6 fix(terminal): use selected vault identity without exposing password 2026-09-27 15:31:29 +08:00
bincxz 90e0de1e6b Merge main into PR #3477 and preserve SFTP identity routing 2026-09-25 17:38:26 +08:00
bincxz 27a54d9780 fix: keep focused SFTP identity in sync for toggle 2026-09-25 17:36:53 +08:00
85bddba538 feat(ai): add bottom dock position for AI assistant side panel (#3482)
* fix(#3481): automated AI fix

* fix: address Codex review on PR #3482

* fix: address Codex review on PR #3482

* fix: address Codex review on PR #3482

* fix: address Codex review on PR #3482

* fix: address Codex review on PR #3482

* fix: remeasure bottom dock when compose bar toggles

* fix: preserve terminal height when bottom dock cannot fit

* fix: preserve dock preference through temporary shrinkage

* fix: update bottom dock when compose bar toggles

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-25 17:35:49 +08:00
Netcatty-Botandnetcatty-bot 66e87a95f1 fix(ai-cli): self-heal tool-CLI discovery file while TCP bridge is alive (#3502)
* fix(#3501): automated AI fix

* fix: address Codex review on PR #3502

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-25 17:33:57 +08:00
7cce9c9dbb fix(terminal): center titlebar window control buttons in the title bar (#3478)
* fix(#3476): automated AI fix

* fix: align window controls with full title bar

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-25 17:28:51 +08:00
bincxz 6bf0d2ff15 Use effective identity for preset SFTP panel 2026-09-25 17:26:52 +08:00
bincxz 56b185a80b Pass SFTP identity source setter into layer effects 2026-09-25 17:23:26 +08:00
954b60acf2 feat(vault): keep SFTP in the sidebar with a persistent vault rail (#3506)
* fix(#3504): automated AI fix

* fix: address Codex review on PR #3506

* fix: address Codex review on PR #3506

* fix: address Codex review on PR #3506

* fix: address Codex review on PR #3506

* fix: keep sidebar SFTP visible above inactive terminal

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-25 17:15:43 +08:00
73284582f5 fix(ai): keep Claude/Codex/Grok model pickers on live CLI catalogs instead of build-time presets (#3499)
* fix(#3496): automated AI fix

* fix: address Codex review on PR #3499

* fix: address Codex review on PR #3499

* fix: keep live model catalogs scoped and surface discovery failures

* fix: isolate Claude catalog profiles and support xhigh effort

* fix: handle missing external model catalog state

* fix: isolate Claude model catalogs by credentials

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-25 17:15:03 +08:00
33853398d4 fix(keychain): detect ECDSA key type in imported OpenSSH and PKCS#8 keys (#3490)
* fix(#3489): automated AI fix

* fix(keychain): read OpenSSH algorithm field precisely

* fix: preserve imported key size after vault reload

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-25 16:51:13 +08:00
bincxz c62d375d2a Share reauthenticated identity with all terminal SFTP entry points 2026-09-25 16:50:29 +08:00
bincxz 80263c4647 Keep selected SSH identity for terminal SFTP 2026-09-25 16:35:03 +08:00
bincxz 20debb5c5a fix: require usernames for saved SSH identity retries 2026-09-25 16:11:43 +08:00
bincxz d70386f74c style: keep SSH identity picker source ASCII-only 2026-09-25 16:06:18 +08:00
bincxz bfa1a9c635 fix: keep SSH identity picker state accurate 2026-09-25 16:01:21 +08:00
陈大猫 9497cd65fc fix(sftp): focus the safe conflict action on open (#3531) 2026-09-25 15:37:48 +08:00
95a0186d90 fix(vault): fall back to GB18030 for non-UTF-8 CSV imports (#3517)
* fix(#3515): automated AI fix

* fix(vault): limit legacy CSV encoding fallback to intended imports

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-25 15:27:24 +08:00
626e0ce5bd fix(quick-connect): accept multi-@ jump chains like JumpServer (#3525)
* fix(#3523): automated AI fix

* fix(#3523): parse JumpServer login on final SSH host

* fix(#3523): preserve login with SSH HostName override

* fix(#3523): guard JumpServer credential presets

* fix(#3523): retain SSH user with HostName alias

* fix(#3523): distinguish JumpServer login from SSH user option

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-24 17:49:46 +08:00
71d378c9b8 fix(sftp): verify current source before side-panel download (#3524)
* fix(#3521): automated AI fix

* fix: address Codex review on PR #3524

* fix: address Codex review on PR #3524

* fix(sftp): require fresh source metadata before downloads

* fix(sftp): refresh SCP symlink targets before download

* test(sftp): include download regression in default suite

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-24 17:48:22 +08:00
netcatty-bot 892777fbf2 fix: address Codex review on PR #3477 2026-09-22 03:03:27 +00:00
netcatty-bot 09a9b5ece8 fix(#3475): automated AI fix 2026-09-22 02:29:46 +00:00
陈大猫 0261931f95 fix(terminal): keep broadcast going on vim ex command-line (#3467)
* fix(terminal): keep broadcast going on vim ex command-line

Broadcast pauses on fail-closed colon prompts so passwords are not
copied to peers. Vim command mode's last line is a bare `:`, which
matched that heuristic. Exempt lines that start with `:` so `:w` and
friends stay in sync; `Password:` is unchanged.

* fix(terminal): only treat a bare colon as vim command-mode

Codex: exempting every `:`-prefixed line would broadcast into custom
prompts such as `sudo -p ':'`. The hang is the first `:` key; later
`:w` no longer matches the trailing-colon heuristic.

* fix(terminal): exempt vim colon only on the alternate screen

A bare `:` on the primary screen can be `sudo -p ':'`. Vim command mode
uses DEC 1049, so require alternateScreen before treating `:` as ex.

* test(terminal): allow alternateScreen in prompt-security options
2026-09-21 16:09:50 +08:00
陈大猫 62e9c9ed12 fix(mcp): drop mustache braces from tool descriptions (#3465)
* fix(mcp): drop mustache braces from tool descriptions

Hosts that interpolate {{name}} in tools/list text treat the literal
{{variables}} snippet wording as a missing template variable and abort
the turn. Use plain language instead and regenerate the committed specs.

* fix(mcp): describe snippet placeholders without mustache tokens

Keep agents informed that snippets use two curly braces on each side,
without emitting a literal {{name}} token that template hosts interpolate.
2026-09-21 15:56:24 +08:00
1b4e826e15 feat(terminal): save and auto-apply a default side panel layout on connect (#3456)
* fix(#3455): automated AI fix

* fix: address Codex review on PR #3456

* fix: address Codex review on PR #3456

* fix: address Codex review on PR #3456

* fix: address Codex review on PR #3456

* fix: address Codex review on PR #3456

* fix: address Codex review on PR #3456

* fix: address Codex review on PR #3456

* fix(terminal): let explicit SFTP auto-open beat the saved layout

A JumpServer SFTP deep link or host.autoOpenSftpPanel must still open
file transfer when the saved default layout omitted SFTP.

* fix(terminal): skip saved layout only when SFTP auto-open is available

Telnet/plugin hosts can carry autoOpenSidePanel=sftp even though the
protocol cannot serve SFTP. Those sessions should still get the saved
default with the SFTP pane pruned.

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-21 15:45:59 +08:00
陈大猫 f469513579 fix(ai): parse live-shell probe sentinels after bash PS2 (#3466)
Interactive bash prints PS2 on the probe's backslash continuations, so
strict startsWith checks miss `_Q`/`_P:` and silent PTY execs hang until
the command timeout. Strip common prompt prefixes in the parser; keep
the existing short-line probe for OpenWrt.
2026-09-21 15:34:47 +08:00
125 changed files with 3758 additions and 273 deletions

No files matched your search

+6 -2
View File
@@ -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;
+31 -7
View File
@@ -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>
);
};
+4
View File
@@ -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,
+3 -2
View File
@@ -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) => {
+5 -1
View File
@@ -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,
+5
View File
@@ -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',
+3
View File
@@ -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',
+1
View File
@@ -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.',
+2
View File
@@ -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',
+3
View File
@@ -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',
+1
View File
@@ -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.',
+2
View File
@@ -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': 'Изменить размер области',
+3
View File
@@ -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': 'Добавить прокси',
+1
View File
@@ -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-хоста сохранены учётные данные, которые нельзя расшифровать на этом устройстве. Откройте настройки хоста и введите их заново.',
+5
View File
@@ -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': '左右分屏',
+3
View File
@@ -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': '添加代理',
+1
View File
@@ -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': '某个跳板机的已保存凭据无法在当前设备解密,请到主机设置中重新填写。',
+5
View File
@@ -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': '左右分割',
+3
View File
@@ -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': '新增代理',
+1
View File
@@ -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': '某個跳板機的已儲存憑證無法在目前裝置解密,請到主機設定中重新填寫。',
+3
View File
@@ -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;
+14 -5
View File
@@ -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));
}
+22 -3
View File
@@ -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,
]),
};
+1
View File
@@ -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 };
}
+45
View File
@@ -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);
}
+7
View File
@@ -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);
}
+39 -21
View File
@@ -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}
+28 -1
View File
@@ -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', () => {
+15 -3
View File
@@ -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'
+10 -13
View File
@@ -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,
}));
}
};
+5 -4
View File
@@ -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>
+2
View File
@@ -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}
+5
View File
@@ -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,
});
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+6 -2
View File
@@ -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"
>
+4 -1
View File
@@ -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/,
);
});
+3 -2
View File
@@ -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')}
+7 -5
View File
@@ -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,
});
}
};
+2 -4
View File
@@ -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 }]);
});
}
+11 -7
View File
@@ -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')}
+50 -11
View File
@@ -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);
}
}
});
+91 -4
View File
@@ -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);
});
+4 -1
View File
@@ -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;
+1 -1
View File
@@ -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,
+15 -3
View File
@@ -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);
+1 -1
View File
@@ -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>
+95 -20
View File
@@ -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"
+35
View File
@@ -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",
);
});
+58
View File
@@ -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);
});
+37
View File
@@ -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;
};
+85
View File
@@ -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" });
});
+161
View File
@@ -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' };
};
+121
View File
@@ -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
View File
@@ -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
+22
View File
@@ -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",
+6
View File
@@ -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;
+7
View File
@@ -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",
+45
View File
@@ -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);
});
+35
View File
@@ -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;
}
+14
View File
@@ -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);
});
+20
View File
@@ -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];
}
+2
View File
@@ -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
+3
View File
@@ -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);
+12
View File
@@ -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],
+8
View File
@@ -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.
+176
View File
@@ -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);
});
+153
View File
@@ -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