mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-01 19:32:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c849bf6a73 | ||
|
|
0d9c4a6e02 | ||
|
|
acd5700284 | ||
|
|
05b33b5521 | ||
|
|
786e5b48ef | ||
|
|
6414c9e177 | ||
|
|
ea6fe18539 | ||
|
|
13fa68a720 | ||
|
|
ea3642cb56 | ||
|
|
857429f6dc | ||
|
|
9ac4c46df9 | ||
|
|
4adbfb8817 | ||
|
|
bd12cac427 | ||
|
|
68ca65dce8 | ||
|
|
f395b7c9e9 | ||
|
|
32f0e5abeb | ||
|
|
83e0e98795 | ||
|
|
f5edd32366 | ||
|
|
735c047a09 | ||
|
|
cd8371e3fc | ||
|
|
eee9be01f1 | ||
|
|
f59518a2ff | ||
|
|
6acc2883fd | ||
|
|
b7e8f9a04a | ||
|
|
93abe8446a |
No files matched your search
@@ -248,6 +248,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
addShellHistoryEntry,
|
||||
removeShellHistoryEntry,
|
||||
addConnectionLog,
|
||||
updateConnectionLog,
|
||||
toggleConnectionLogSaved,
|
||||
@@ -1737,6 +1738,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const appVaultDomain = useMemo(() => ({
|
||||
addShellHistoryEntry,
|
||||
removeShellHistoryEntry,
|
||||
commitPluginImporterData,
|
||||
commitVaultImportTransaction,
|
||||
connectionLogs,
|
||||
@@ -1782,7 +1784,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
updateSnippetPackages,
|
||||
updateSnippets,
|
||||
vaultFocusRequest,
|
||||
}), [addShellHistoryEntry, commitPluginImporterData, commitVaultImportTransaction, connectionLogs, convertKnownHostToHost, customGroups, deepLinkHostDraft, effectiveKnownHosts, groupConfigs, handleAddKnownHost, handleDeleteHost, handleOpenHostFromVaultNote, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, hosts, identities, importOrReuseKey, keys, managedSources, navigateToSection, noteGroups, notes, proxyProfiles, readPersistedHosts, readPersistedManagedSources, setDeepLinkHostDraft, setNavigateToSection, setVaultFocusRequest, snippetPackages, snippets, unmanageSource, updateCustomGroups, updateGroupConfigs, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, vaultFocusRequest]);
|
||||
}), [addShellHistoryEntry, commitPluginImporterData, commitVaultImportTransaction, connectionLogs, convertKnownHostToHost, customGroups, deepLinkHostDraft, effectiveKnownHosts, groupConfigs, handleAddKnownHost, handleDeleteHost, handleOpenHostFromVaultNote, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, hosts, identities, importOrReuseKey, keys, managedSources, navigateToSection, noteGroups, notes, proxyProfiles, readPersistedHosts, readPersistedManagedSources, removeShellHistoryEntry, setDeepLinkHostDraft, setNavigateToSection, setVaultFocusRequest, snippetPackages, snippets, unmanageSource, updateCustomGroups, updateGroupConfigs, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, vaultFocusRequest]);
|
||||
|
||||
const appTerminalDomain = useMemo(() => ({
|
||||
addSessionToWorkspace,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
## [Unreleased] - 2026-03-11
|
||||
|
||||
### 功能
|
||||
- 终端新增"粘贴时自动上传剪贴板图片"选项:剪贴板含图片时,在远程会话中粘贴(快捷键/右键/中键)自动通过 SFTP 上传图片到远端 `.netcatty-paste-images/` 目录并输入远端路径,否则保持原有文本粘贴行为(设置 > 终端 > 行为,默认关闭)
|
||||
- 修复自动更新 IPC 事件仅发送到单个窗口的问题,改为广播所有窗口(主窗口 + 设置窗口均可收到)
|
||||
- 统一手动检查更新与自动更新的状态机,消除三套并行状态
|
||||
- 手动"检查更新"通过 GitHub API 检测版本,发现更新后异步触发 electron-updater 下载
|
||||
|
||||
@@ -92,6 +92,7 @@ interface AppHostEditorLayerProps {
|
||||
onCreateGroup: (groupPath: string) => void;
|
||||
onImportOrReuseKey: (draft: Partial<SSHKey>) => SSHKey;
|
||||
onUpdateSnippets: (snippets: Snippet[]) => void;
|
||||
onUpdateHosts?: (hosts: Host[] | ((prev: Host[]) => Host[])) => void;
|
||||
}
|
||||
|
||||
export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
|
||||
@@ -117,6 +118,7 @@ export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
|
||||
onCreateGroup,
|
||||
onImportOrReuseKey,
|
||||
onUpdateSnippets,
|
||||
onUpdateHosts,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const derivedSurfaceVisible = useWorkSurfaceVisible({
|
||||
@@ -189,6 +191,7 @@ export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
|
||||
groupConfigs={groupConfigs}
|
||||
snippets={snippets}
|
||||
onSnippetsChange={onUpdateSnippets}
|
||||
onHostsChange={onUpdateHosts}
|
||||
onImportKey={onImportOrReuseKey}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { toast } from '../../components/ui/toast';
|
||||
import { AppHostTreeLayer } from './AppHostTreeLayer';
|
||||
import { AppHostEditorLayer } from './AppHostEditorLayer';
|
||||
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
|
||||
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
|
||||
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { buildAppThemeCssVars } from '../state/settingsStateDefaults';
|
||||
import { useMainWindowInputFocusRecovery } from '../state/useMainWindowInputFocusRecovery';
|
||||
@@ -113,7 +114,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
|
||||
|
||||
const {
|
||||
accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
accentMode, addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
|
||||
connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent,
|
||||
customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
|
||||
@@ -182,6 +183,21 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onUpdateHosts: updateHosts,
|
||||
onSaved: handleWorkSurfaceHostSaved,
|
||||
});
|
||||
const openWorkSurfaceHostEdit = workSurfaceHostEditor.openEdit;
|
||||
const handleEditHostFromOverlay = useCallback((host: (typeof hosts)[number]) => {
|
||||
if (shouldOpenHostEditOnWorkSurface(activeTabStore.getActiveTabId())) {
|
||||
openWorkSurfaceHostEdit(host);
|
||||
return;
|
||||
}
|
||||
setDeepLinkHostDraft(host);
|
||||
setNavigateToSection('hosts');
|
||||
setActiveTabId('vault');
|
||||
}, [
|
||||
openWorkSurfaceHostEdit,
|
||||
setActiveTabId,
|
||||
setDeepLinkHostDraft,
|
||||
setNavigateToSection,
|
||||
]);
|
||||
const handleCreateWorkSurfaceHostGroup = useCallback((groupPath: string) => {
|
||||
updateCustomGroups(Array.from(new Set([...customGroups, groupPath])));
|
||||
}, [customGroups, updateCustomGroups]);
|
||||
@@ -272,6 +288,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onRenameSession={startSessionRename}
|
||||
onCopySession={copySessionWithCurrentShell}
|
||||
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
|
||||
onEditHost={handleEditHostFromOverlay}
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
onCopyWorkspace={copyWorkspaceWithCurrentShell}
|
||||
onCloseWorkspace={closeWorkspace}
|
||||
@@ -345,6 +362,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onCreateGroup={handleCreateWorkSurfaceHostGroup}
|
||||
onImportOrReuseKey={importOrReuseKey}
|
||||
onUpdateSnippets={updateSnippets}
|
||||
onUpdateHosts={updateHosts}
|
||||
/>
|
||||
|
||||
<VaultViewContainer appThemeStyle={appThemeStyle}>
|
||||
@@ -485,6 +503,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onUpdateHost={handleUpdateHostFromTerminal}
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
onCommandExecuted={handleTerminalCommandExecuted}
|
||||
onDeleteShellHistoryEntry={removeShellHistoryEntry}
|
||||
onTerminalDataCapture={handleTerminalDataCapture}
|
||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||
@@ -665,6 +684,11 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
showSftpTab={settings.showSftpTab}
|
||||
onQueryChange={setQuickSearch}
|
||||
onSelect={handleHostConnectWithProtocolCheck}
|
||||
onEditHost={(host) => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
handleEditHostFromOverlay(host);
|
||||
}}
|
||||
onSelectTab={(tabId) => {
|
||||
setActiveTabId(tabId);
|
||||
setIsQuickSwitcherOpen(false);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
reorderWorkTabIds,
|
||||
resolveWorkTabActiveHostId,
|
||||
resolveWorkTabHostTreeTheme,
|
||||
shouldOpenHostEditOnWorkSurface,
|
||||
} from './workTabSurface';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
@@ -77,6 +78,15 @@ test('root pages are not work tab surfaces', () => {
|
||||
assert.equal(isRootPageTabId('session-1'), false);
|
||||
});
|
||||
|
||||
test('host edit overlay prefers work-surface editor except on vault/sftp/plugin tabs', () => {
|
||||
assert.equal(shouldOpenHostEditOnWorkSurface('session-1'), true);
|
||||
assert.equal(shouldOpenHostEditOnWorkSurface('workspace-1'), true);
|
||||
assert.equal(shouldOpenHostEditOnWorkSurface('editor:file-1'), true);
|
||||
assert.equal(shouldOpenHostEditOnWorkSurface('vault'), false);
|
||||
assert.equal(shouldOpenHostEditOnWorkSurface('sftp'), false);
|
||||
assert.equal(shouldOpenHostEditOnWorkSurface('plugin-view:demo'), false);
|
||||
});
|
||||
|
||||
test('shared host tree is visible for editor, log, session, and workspace tabs', () => {
|
||||
const sessionIds = new Set(['session-1']);
|
||||
const workspaceIds = new Set(['workspace-1']);
|
||||
|
||||
@@ -23,6 +23,14 @@ export function isRootPageTabId(activeTabId: string): boolean {
|
||||
return activeTabId === 'vault' || activeTabId === 'sftp';
|
||||
}
|
||||
|
||||
/**
|
||||
* Host edit from overlays (Quick Switcher): use the terminal work-surface
|
||||
* HostDetailsPanel when a work tab is active; otherwise deep-link into Vault.
|
||||
*/
|
||||
export function shouldOpenHostEditOnWorkSurface(activeTabId: string): boolean {
|
||||
return !isRootPageTabId(activeTabId) && !isPluginViewTabId(activeTabId);
|
||||
}
|
||||
|
||||
export function buildOrderedWorkTabIds(
|
||||
tabOrder: readonly string[],
|
||||
allTabIds: readonly string[],
|
||||
|
||||
@@ -574,6 +574,9 @@ Highlight the focused split pane:
|
||||
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage': 'Auto-upload clipboard images on paste',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage.desc':
|
||||
'When the clipboard holds an image, pasting in a remote session uploads it via SFTP to .netcatty-paste-images/ under the remote working directory and inserts the remote path instead of pasting text.',
|
||||
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter text',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'Send configured text instead of normal Enter when pressing Shift+Enter in the terminal.',
|
||||
@@ -680,6 +683,10 @@ Highlight the focused split pane:
|
||||
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
|
||||
'settings.terminal.hostInfoBar.show': 'Show host information bar',
|
||||
'settings.terminal.hostInfoBar.show.desc': 'Show the host address and server information above the terminal. Action buttons remain available when hidden.',
|
||||
'settings.terminal.hostInfoBar.titleMode': 'Host information title',
|
||||
'settings.terminal.hostInfoBar.titleMode.desc': 'Choose whether the bar shows the connection address (user@host) or the vault host label.',
|
||||
'settings.terminal.hostInfoBar.titleMode.address': 'Connection address',
|
||||
'settings.terminal.hostInfoBar.titleMode.label': 'Host label',
|
||||
'settings.terminal.section.systemManager': 'System Manager',
|
||||
'settings.terminal.systemManager.processRefreshInterval': 'Process list refresh',
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'How often to refresh the process list in the system manager side panel.',
|
||||
|
||||
@@ -34,6 +34,7 @@ export const enTerminalMessages: Messages = {
|
||||
'history.action.paste': 'Paste to terminal',
|
||||
'history.action.run': 'Run in terminal',
|
||||
'history.action.saveAsSnippet': 'Save as snippet',
|
||||
'history.action.delete': 'Delete from history',
|
||||
'terminal.toolbar.library': 'Library',
|
||||
'terminal.toolbar.noSnippets': 'No snippets available',
|
||||
'terminal.toolbar.terminalSettings': 'Terminal settings',
|
||||
|
||||
@@ -535,6 +535,9 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.behavior.bracketedPaste': 'Режим bracketed paste',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'Оборачивать вставляемый текст escape-последовательностями, чтобы оболочка отличала вставку от обычного ввода. Отключите, если видите артефакты вида ^[[200~.',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage': 'Авто-загрузка изображений буфера обмена при вставке',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage.desc':
|
||||
'Когда в буфере обмена есть изображение, вставка в удалённой сессии загружает его по SFTP в каталог .netcatty-paste-images/ в текущем удалённом каталоге и вводит удалённый путь вместо вставки текста.',
|
||||
'settings.terminal.behavior.shiftEnterNewline': 'Текст для Shift+Enter',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'Отправлять настроенный текст вместо обычного Enter при нажатии Shift+Enter в терминале.',
|
||||
@@ -637,6 +640,10 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.section.serverStats': 'Статистика сервера (Linux)',
|
||||
'settings.terminal.hostInfoBar.show': 'Показывать сведения о хосте',
|
||||
'settings.terminal.hostInfoBar.show.desc': 'Показывать адрес хоста и сведения о сервере над терминалом. Кнопки действий остаются доступными после скрытия.',
|
||||
'settings.terminal.hostInfoBar.titleMode': 'Заголовок сведений о хосте',
|
||||
'settings.terminal.hostInfoBar.titleMode.desc': 'Показывать в панели адрес подключения (user@host) или метку хоста из хранилища.',
|
||||
'settings.terminal.hostInfoBar.titleMode.address': 'Адрес подключения',
|
||||
'settings.terminal.hostInfoBar.titleMode.label': 'Метка хоста',
|
||||
'settings.terminal.section.systemManager': 'Системный менеджер',
|
||||
'settings.terminal.systemManager.processRefreshInterval': 'Обновление списка процессов',
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
|
||||
|
||||
@@ -55,6 +55,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'history.action.paste': 'Вставить в терминал',
|
||||
'history.action.run': 'Выполнить в терминале',
|
||||
'history.action.saveAsSnippet': 'Сохранить как сниппет',
|
||||
'history.action.delete': 'Удалить из истории',
|
||||
'terminal.toolbar.library': 'Библиотека',
|
||||
'terminal.toolbar.noSnippets': 'Нет доступных сниппетов',
|
||||
'terminal.toolbar.terminalSettings': 'Настройки терминала',
|
||||
|
||||
@@ -52,6 +52,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'history.action.paste': '粘贴到终端',
|
||||
'history.action.run': '在终端执行',
|
||||
'history.action.saveAsSnippet': '保存为代码片段',
|
||||
'history.action.delete': '从历史记录中删除',
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': '复制文件路径',
|
||||
'sftp.context.openWith': '打开方式...',
|
||||
@@ -310,6 +311,9 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage': '粘贴时自动上传剪贴板图片',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage.desc':
|
||||
'当剪贴板包含图片时,在远程会话中粘贴会通过 SFTP 将图片上传到远端当前目录的 .netcatty-paste-images/ 文件夹,并在终端输入远端路径,而不是粘贴文本。',
|
||||
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 发送文本',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'在终端中按 Shift+Enter 时发送配置的文本,而不是普通回车。',
|
||||
@@ -410,6 +414,10 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.section.serverStats': '服务器状态(Linux)',
|
||||
'settings.terminal.hostInfoBar.show': '显示主机信息栏',
|
||||
'settings.terminal.hostInfoBar.show.desc': '在终端上方显示主机地址和服务器信息。隐藏后仍可使用操作按钮。',
|
||||
'settings.terminal.hostInfoBar.titleMode': '主机信息标题',
|
||||
'settings.terminal.hostInfoBar.titleMode.desc': '选择信息栏显示连接地址(user@host)还是主机名称(Vault 标签)。',
|
||||
'settings.terminal.hostInfoBar.titleMode.address': '连接地址',
|
||||
'settings.terminal.hostInfoBar.titleMode.label': '主机名称',
|
||||
'settings.terminal.serverStats.show': '显示服务器状态',
|
||||
'settings.terminal.serverStats.show.desc': '在终端状态栏显示 CPU、内存和磁盘使用情况(仅限 Linux 服务器)。',
|
||||
'settings.terminal.serverStats.refreshInterval': '刷新间隔',
|
||||
|
||||
@@ -52,6 +52,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'history.action.paste': '貼上到終端',
|
||||
'history.action.run': '在終端執行',
|
||||
'history.action.saveAsSnippet': '儲存為程式碼片段',
|
||||
'history.action.delete': '從歷史記錄中刪除',
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': '複製檔案路徑',
|
||||
'sftp.context.openWith': '開啟方式...',
|
||||
@@ -310,6 +311,9 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.bracketedPaste': '括號貼上模式',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'貼上文字時使用轉義序列包裹,以便終端區分貼上和鍵入。如果出現 ^[[200~ 字樣請關閉此選項。',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage': '貼上時自動上傳剪貼簿圖片',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage.desc':
|
||||
'當剪貼簿包含圖片時,在遠端工作階段中貼上會透過 SFTP 將圖片上傳到遠端目前目錄的 .netcatty-paste-images/ 資料夾,並在終端輸入遠端路徑,而不是貼上文字。',
|
||||
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 傳送文字',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'在終端中按 Shift+Enter 時傳送設定的文字,而不是普通 Enter。',
|
||||
@@ -410,6 +414,10 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.section.serverStats': '伺服器狀態(Linux)',
|
||||
'settings.terminal.hostInfoBar.show': '顯示主機資訊列',
|
||||
'settings.terminal.hostInfoBar.show.desc': '在終端上方顯示主機地址和伺服器資訊。隱藏後仍可使用操作按鈕。',
|
||||
'settings.terminal.hostInfoBar.titleMode': '主機資訊標題',
|
||||
'settings.terminal.hostInfoBar.titleMode.desc': '選擇資訊列顯示連線地址(user@host)還是主機名稱(Vault 標籤)。',
|
||||
'settings.terminal.hostInfoBar.titleMode.address': '連線地址',
|
||||
'settings.terminal.hostInfoBar.titleMode.label': '主機名稱',
|
||||
'settings.terminal.serverStats.show': '顯示伺服器狀態',
|
||||
'settings.terminal.serverStats.show.desc': '在終端狀態列顯示 CPU、記憶體和磁碟使用情況(僅限 Linux 伺服器)。',
|
||||
'settings.terminal.serverStats.refreshInterval': '重新整理間隔',
|
||||
|
||||
@@ -103,4 +103,35 @@ describe('storePluginSyncSecretsThenConnect', () => {
|
||||
);
|
||||
assert.deepEqual(deletedKeys, [['sync-credential']]);
|
||||
});
|
||||
|
||||
it('keeps overwritten secrets when reconnect connect rejects', async () => {
|
||||
const deletedKeys: string[][] = [];
|
||||
const restoredKeys: string[][] = [];
|
||||
await assert.rejects(
|
||||
() => storePluginSyncSecretsThenConnect({
|
||||
providerId: 'plugin:sync.example',
|
||||
secrets: [{ secretKey: 'sync-credential', value: 'bad-password' }],
|
||||
putSecret: async ({ key }) => ({
|
||||
kind: 'secret',
|
||||
id: 'ref-overwritten',
|
||||
key,
|
||||
created: false,
|
||||
}),
|
||||
deleteSecrets: async ({ keys }) => {
|
||||
deletedKeys.push([...keys]);
|
||||
return { deleted: keys.length };
|
||||
},
|
||||
restoreSecrets: async ({ keys }) => {
|
||||
restoredKeys.push([...keys]);
|
||||
return { restored: keys.length };
|
||||
},
|
||||
connect: async () => {
|
||||
throw new Error('auth failed');
|
||||
},
|
||||
}),
|
||||
/auth failed/,
|
||||
);
|
||||
assert.deepEqual(deletedKeys, []);
|
||||
assert.deepEqual(restoredKeys, [['sync-credential']]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* Persist plugin sync secrets then connect; roll back just-created secrets if
|
||||
* connect (or a later put) fails so bad passwords are not left in OS storage.
|
||||
* Overwritten keys are restored from the host-side overwrite stash so a
|
||||
* rejected reconnect does not leave a broken SecretRef / rejected credential.
|
||||
*/
|
||||
|
||||
export type PluginSyncSecretRef = { kind: 'secret'; id: string; key: string };
|
||||
@@ -10,6 +12,11 @@ export interface PluginSyncConnectSecretInput {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface PluginSyncPutSecretResult extends PluginSyncSecretRef {
|
||||
/** False when the durable (plugin,key) row already existed and was overwritten. */
|
||||
created?: boolean;
|
||||
}
|
||||
|
||||
export interface StorePluginSyncSecretsThenConnectParams {
|
||||
providerId: string;
|
||||
secrets: readonly PluginSyncConnectSecretInput[];
|
||||
@@ -19,11 +26,20 @@ export interface StorePluginSyncSecretsThenConnectParams {
|
||||
providerId: string;
|
||||
key: string;
|
||||
value: string;
|
||||
}) => Promise<PluginSyncSecretRef>;
|
||||
}) => Promise<PluginSyncPutSecretResult>;
|
||||
deleteSecrets: (params: {
|
||||
providerId: string;
|
||||
keys: string[];
|
||||
}) => Promise<unknown>;
|
||||
/**
|
||||
* Restore host-stashed plaintext for overwritten keys (`discard: false`),
|
||||
* or drop the stash after a successful connect (`discard: true`).
|
||||
*/
|
||||
restoreSecrets?: (params: {
|
||||
providerId: string;
|
||||
keys: string[];
|
||||
discard?: boolean;
|
||||
}) => Promise<unknown>;
|
||||
connect: (credential: PluginSyncSecretRef | undefined) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -31,6 +47,7 @@ export async function storePluginSyncSecretsThenConnect(
|
||||
params: StorePluginSyncSecretsThenConnectParams,
|
||||
): Promise<void> {
|
||||
const createdKeys: string[] = [];
|
||||
const overwrittenKeys: string[] = [];
|
||||
let credential: PluginSyncSecretRef | undefined = params.existingCredential;
|
||||
|
||||
try {
|
||||
@@ -42,13 +59,28 @@ export async function storePluginSyncSecretsThenConnect(
|
||||
key: secret.secretKey,
|
||||
value: secret.value,
|
||||
});
|
||||
createdKeys.push(secret.secretKey);
|
||||
if (ref.created === false) {
|
||||
overwrittenKeys.push(secret.secretKey);
|
||||
} else {
|
||||
createdKeys.push(secret.secretKey);
|
||||
}
|
||||
// SyncConnectPayload.credential carries the primary (first) secret;
|
||||
// additional secrets remain addressable via secrets.get(key).
|
||||
if (!credential) credential = ref;
|
||||
}
|
||||
}
|
||||
await params.connect(credential);
|
||||
if (overwrittenKeys.length > 0 && params.restoreSecrets) {
|
||||
try {
|
||||
await params.restoreSecrets({
|
||||
providerId: params.providerId,
|
||||
keys: [...overwrittenKeys],
|
||||
discard: true,
|
||||
});
|
||||
} catch {
|
||||
/* best-effort stash cleanup */
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (createdKeys.length > 0) {
|
||||
try {
|
||||
@@ -60,6 +92,16 @@ export async function storePluginSyncSecretsThenConnect(
|
||||
/* best-effort; surface the original connect/put error */
|
||||
}
|
||||
}
|
||||
if (overwrittenKeys.length > 0 && params.restoreSecrets) {
|
||||
try {
|
||||
await params.restoreSecrets({
|
||||
providerId: params.providerId,
|
||||
keys: [...overwrittenKeys],
|
||||
});
|
||||
} catch {
|
||||
/* best-effort; surface the original connect/put error */
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ const systemLight: AppearanceRenderSnapshot = {
|
||||
darkUiThemeId: "midnight",
|
||||
accentMode: "theme",
|
||||
customAccent: "208 100% 50%",
|
||||
customAccentVersion: 0,
|
||||
};
|
||||
|
||||
test("an OS color event cannot persist a stale System choice over a newer Dark choice", () => {
|
||||
@@ -97,6 +98,7 @@ test("a non-theme appearance IPC value wins over stale local storage for that ke
|
||||
darkUiThemeId: "midnight",
|
||||
accentMode: "theme",
|
||||
customAccent: "208 100% 50%",
|
||||
customAccentVersion: 0,
|
||||
};
|
||||
// Storage still lags for every non-theme field (the race the review covers).
|
||||
const staleStored: StoredAppearanceValues = {
|
||||
@@ -151,6 +153,22 @@ test("a non-theme appearance IPC value wins over stale local storage for that ke
|
||||
}, customAccentSelection);
|
||||
assert.equal(nextCustomAccent.customAccent, "221.2 83.2% 53.3%");
|
||||
|
||||
// Stale lower-version accent echoes must not clobber a newer local drag sample.
|
||||
const afterDrag = {
|
||||
...current,
|
||||
customAccent: "199 89% 48%",
|
||||
customAccentVersion: 2,
|
||||
};
|
||||
const staleAccentEcho = resolveAppearanceSyncState(afterDrag, {
|
||||
...staleStored,
|
||||
customAccent: "0 84% 60%",
|
||||
}, {
|
||||
key: STORAGE_KEY_COLOR,
|
||||
value: { color: "0 84% 60%", version: 1 },
|
||||
});
|
||||
assert.equal(staleAccentEcho.customAccent, "199 89% 48%");
|
||||
assert.equal(staleAccentEcho.customAccentVersion, 2);
|
||||
|
||||
// Without an announced key, full rehydrate still reads storage.
|
||||
const noIncoming = resolveAppearanceSyncState(current, {
|
||||
...staleStored,
|
||||
@@ -168,6 +186,7 @@ test("sequential keyed appearance IPC updates compose without stale-storage clob
|
||||
darkUiThemeId: "midnight",
|
||||
accentMode: "theme",
|
||||
customAccent: "208 100% 50%",
|
||||
customAccentVersion: 0,
|
||||
};
|
||||
const staleStored: StoredAppearanceValues = { ...initial };
|
||||
|
||||
@@ -196,6 +215,7 @@ test("System on a light OS changes to Dark in every open follow-app terminal", (
|
||||
darkUiThemeId: systemLight.darkUiThemeId,
|
||||
accentMode: systemLight.accentMode,
|
||||
customAccent: systemLight.customAccent,
|
||||
customAccentVersion: systemLight.customAccentVersion,
|
||||
};
|
||||
const terminalAppearance = (appearance: AppearanceState, resolvedTheme: "light" | "dark") => (
|
||||
resolveGlobalTerminalAppearance({
|
||||
@@ -292,7 +312,7 @@ test("the race guards are wired into the real settings paths", () => {
|
||||
const lightNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId)", writeIndex);
|
||||
const darkNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId)", writeIndex);
|
||||
const accentModeNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode)", writeIndex);
|
||||
const colorNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_COLOR, customAccent)", writeIndex);
|
||||
const colorNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_COLOR, customAccentRecord)", writeIndex);
|
||||
|
||||
assert.ok(guardIndex >= 0, "the settings effect must compare persisted appearance fields");
|
||||
assert.ok(returnIndex > guardIndex && returnIndex < writeIndex, "the stale render must stop before storage is written");
|
||||
@@ -301,6 +321,8 @@ test("the race guards are wired into the real settings paths", () => {
|
||||
assert.ok(darkNotifyIndex > writeIndex, "dark UI theme changes must be announced over IPC with the new value");
|
||||
assert.ok(accentModeNotifyIndex > writeIndex, "accent mode changes must be announced over IPC with the new value");
|
||||
assert.ok(colorNotifyIndex > writeIndex, "custom accent changes must be announced over IPC with the new value");
|
||||
assert.match(stateSource, /shouldBroadcastCustomAccentChange/);
|
||||
assert.match(stateSource, /serializeCustomAccentRecord\(customAccentRecord\)/);
|
||||
// Source still only notifies fields that actually changed (keyed, not a single theme-only broadcast).
|
||||
assert.match(
|
||||
stateSource,
|
||||
|
||||
@@ -6,10 +6,14 @@ import {
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
isValidHslToken,
|
||||
isValidTheme,
|
||||
isValidUiThemeId,
|
||||
} from './settingsStateDefaults';
|
||||
import {
|
||||
parseCustomAccentRecord,
|
||||
shouldApplyCustomAccentRecord,
|
||||
type CustomAccentRecord,
|
||||
} from './customAccentSync';
|
||||
|
||||
export type AppearanceState = {
|
||||
theme: "light" | "dark" | "system";
|
||||
@@ -17,6 +21,8 @@ export type AppearanceState = {
|
||||
darkUiThemeId: string;
|
||||
accentMode: "theme" | "custom";
|
||||
customAccent: string;
|
||||
/** Sync revision for custom accent; gates stale IPC/storage echoes during color-picker drag. */
|
||||
customAccentVersion: number;
|
||||
};
|
||||
|
||||
export type AppearanceRenderSnapshot = {
|
||||
@@ -50,7 +56,8 @@ export function hasPersistedAppearanceChanged(
|
||||
|| previous.lightUiThemeId !== current.lightUiThemeId
|
||||
|| previous.darkUiThemeId !== current.darkUiThemeId
|
||||
|| previous.accentMode !== current.accentMode
|
||||
|| previous.customAccent !== current.customAccent;
|
||||
|| previous.customAccent !== current.customAccent
|
||||
|| previous.customAccentVersion !== current.customAccentVersion;
|
||||
}
|
||||
|
||||
export function resolveIncomingAppearanceValue<T>(
|
||||
@@ -106,13 +113,20 @@ export function resolveAppearanceSyncState(
|
||||
current.accentMode,
|
||||
(value): value is AppearanceState['accentMode'] => value === 'theme' || value === 'custom',
|
||||
);
|
||||
const customAccent = resolveIncomingAppearanceValue(
|
||||
incoming,
|
||||
STORAGE_KEY_COLOR,
|
||||
stored.customAccent,
|
||||
current.customAccent,
|
||||
(value): value is string => typeof value === 'string' && isValidHslToken(value),
|
||||
);
|
||||
const currentAccentRecord: CustomAccentRecord = {
|
||||
color: current.customAccent,
|
||||
version: current.customAccentVersion,
|
||||
};
|
||||
const incomingAccentRaw = incoming?.key === STORAGE_KEY_COLOR ? incoming.value : undefined;
|
||||
const resolvedAccentRaw = incomingAccentRaw !== undefined
|
||||
? incomingAccentRaw
|
||||
: (incoming ? currentAccentRecord : stored.customAccent);
|
||||
const resolvedAccentRecord = incomingAccentRaw !== undefined || !incoming
|
||||
? parseCustomAccentRecord(resolvedAccentRaw)
|
||||
: currentAccentRecord;
|
||||
const nextAccentRecord = shouldApplyCustomAccentRecord(currentAccentRecord, resolvedAccentRecord)
|
||||
? resolvedAccentRecord
|
||||
: currentAccentRecord;
|
||||
|
||||
return {
|
||||
theme: isValidTheme(theme) ? theme : current.theme,
|
||||
@@ -123,9 +137,8 @@ export function resolveAppearanceSyncState(
|
||||
? darkUiThemeId
|
||||
: current.darkUiThemeId,
|
||||
accentMode: accentMode === 'theme' || accentMode === 'custom' ? accentMode : current.accentMode,
|
||||
customAccent: typeof customAccent === 'string' && isValidHslToken(customAccent)
|
||||
? customAccent.trim()
|
||||
: current.customAccent,
|
||||
customAccent: nextAccentRecord.color,
|
||||
customAccentVersion: nextAccentRecord.version,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -165,11 +178,24 @@ export function resolveAppearanceStorageEvent(
|
||||
};
|
||||
}
|
||||
if (key === STORAGE_KEY_COLOR) {
|
||||
if (newValue == null) {
|
||||
return { handled: true, next: current };
|
||||
}
|
||||
const incoming = parseCustomAccentRecord(newValue);
|
||||
const currentRecord: CustomAccentRecord = {
|
||||
color: current.customAccent,
|
||||
version: current.customAccentVersion,
|
||||
};
|
||||
if (!shouldApplyCustomAccentRecord(currentRecord, incoming)) {
|
||||
return { handled: true, next: current };
|
||||
}
|
||||
return {
|
||||
handled: true,
|
||||
next: newValue && isValidHslToken(newValue)
|
||||
? { ...current, customAccent: newValue.trim() }
|
||||
: current,
|
||||
next: {
|
||||
...current,
|
||||
customAccent: incoming.color,
|
||||
customAccentVersion: incoming.version,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { handled: false, next: current };
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
parseCustomAccentRecord,
|
||||
serializeCustomAccentRecord,
|
||||
shouldApplyCustomAccentRecord,
|
||||
shouldBroadcastCustomAccentChange,
|
||||
type CustomAccentMutationSource,
|
||||
type CustomAccentRecord,
|
||||
} from './customAccentSync.ts';
|
||||
|
||||
/**
|
||||
* Minimal model of the settings ↔ main custom-accent sync loop that caused
|
||||
* #2743. Models both IPC rebroadcast and stale localStorage overwrites while
|
||||
* the native color picker fires rapid onChange events.
|
||||
*/
|
||||
function simulateAccentDrag(options: {
|
||||
shouldBroadcast: (
|
||||
source: CustomAccentMutationSource,
|
||||
persistMounted: boolean,
|
||||
) => { shouldBroadcast: boolean; nextSource: CustomAccentMutationSource };
|
||||
shouldApply: (current: CustomAccentRecord, incoming: CustomAccentRecord) => boolean;
|
||||
versioned: boolean;
|
||||
}): { settingsValues: string[]; mainValues: string[]; storageWrites: string[] } {
|
||||
let settings: CustomAccentRecord = { color: '221.2 83.2% 53.3%', version: 0 };
|
||||
let main: CustomAccentRecord = { color: '221.2 83.2% 53.3%', version: 0 };
|
||||
let settingsSource: CustomAccentMutationSource = 'local';
|
||||
let mainSource: CustomAccentMutationSource = 'local';
|
||||
let storage = options.versioned
|
||||
? serializeCustomAccentRecord(settings)
|
||||
: settings.color;
|
||||
const settingsValues: string[] = [];
|
||||
const mainValues: string[] = [];
|
||||
const storageWrites: string[] = [];
|
||||
|
||||
const pendingIpc: Array<{ to: 'settings' | 'main'; record: CustomAccentRecord }> = [];
|
||||
|
||||
const writeStorage = (record: CustomAccentRecord) => {
|
||||
const next = options.versioned
|
||||
? serializeCustomAccentRecord(record)
|
||||
: record.color;
|
||||
if (next === storage) return;
|
||||
storage = next;
|
||||
storageWrites.push(next);
|
||||
};
|
||||
|
||||
const applyLocal = (window: 'settings' | 'main', color: string) => {
|
||||
const bump = (prev: CustomAccentRecord): CustomAccentRecord => (
|
||||
options.versioned
|
||||
? { color, version: prev.version + 1 }
|
||||
: { color, version: 0 }
|
||||
);
|
||||
|
||||
if (window === 'settings') {
|
||||
settingsSource = 'local';
|
||||
settings = bump(settings);
|
||||
settingsValues.push(settings.color);
|
||||
writeStorage(settings);
|
||||
const decision = options.shouldBroadcast(settingsSource, true);
|
||||
settingsSource = decision.nextSource;
|
||||
if (decision.shouldBroadcast) pendingIpc.push({ to: 'main', record: { ...settings } });
|
||||
return;
|
||||
}
|
||||
|
||||
mainSource = 'local';
|
||||
main = bump(main);
|
||||
mainValues.push(main.color);
|
||||
writeStorage(main);
|
||||
const decision = options.shouldBroadcast(mainSource, true);
|
||||
mainSource = decision.nextSource;
|
||||
if (decision.shouldBroadcast) pendingIpc.push({ to: 'settings', record: { ...main } });
|
||||
};
|
||||
|
||||
const applyIncoming = (window: 'settings' | 'main', record: CustomAccentRecord) => {
|
||||
if (window === 'settings') {
|
||||
if (!options.shouldApply(settings, record)) return;
|
||||
settingsSource = 'incoming';
|
||||
settings = { ...record };
|
||||
settingsValues.push(settings.color);
|
||||
writeStorage(settings);
|
||||
const decision = options.shouldBroadcast(settingsSource, true);
|
||||
settingsSource = decision.nextSource;
|
||||
if (decision.shouldBroadcast) pendingIpc.push({ to: 'main', record: { ...settings } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.shouldApply(main, record)) return;
|
||||
mainSource = 'incoming';
|
||||
main = { ...record };
|
||||
mainValues.push(main.color);
|
||||
writeStorage(main);
|
||||
const decision = options.shouldBroadcast(mainSource, true);
|
||||
mainSource = decision.nextSource;
|
||||
if (decision.shouldBroadcast) pendingIpc.push({ to: 'settings', record: { ...main } });
|
||||
};
|
||||
|
||||
const flushIpc = () => {
|
||||
while (pendingIpc.length > 0) {
|
||||
const next = pendingIpc.shift()!;
|
||||
applyIncoming(next.to, next.record);
|
||||
if (settingsValues.length > 40) break;
|
||||
}
|
||||
};
|
||||
|
||||
const deliverStorageTo = (window: 'settings' | 'main') => {
|
||||
const record = options.versioned
|
||||
? parseCustomAccentRecord(JSON.parse(storage))
|
||||
: parseCustomAccentRecord(storage);
|
||||
applyIncoming(window, record);
|
||||
};
|
||||
|
||||
applyLocal('settings', '0 84% 60%');
|
||||
deliverStorageTo('main');
|
||||
applyLocal('settings', '199 89% 48%');
|
||||
// Delayed IPC for the older color arrives after the newer local drag sample.
|
||||
const delayed = pendingIpc.shift();
|
||||
flushIpc();
|
||||
if (delayed) applyIncoming(delayed.to, delayed.record);
|
||||
deliverStorageTo('settings');
|
||||
flushIpc();
|
||||
|
||||
return { settingsValues, mainValues, storageWrites };
|
||||
}
|
||||
|
||||
test('parseCustomAccentRecord accepts legacy HSL tokens and versioned JSON', () => {
|
||||
assert.deepEqual(parseCustomAccentRecord('199 89% 48%'), { color: '199 89% 48%', version: 0 });
|
||||
assert.deepEqual(
|
||||
parseCustomAccentRecord({ color: '0 84% 60%', version: 3 }),
|
||||
{ color: '0 84% 60%', version: 3 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseCustomAccentRecord('{"color":"262.1 83.3% 57.8%","version":9}'),
|
||||
{ color: '262.1 83.3% 57.8%', version: 9 },
|
||||
);
|
||||
assert.equal(parseCustomAccentRecord('bad').color, '221.2 83.2% 53.3%');
|
||||
});
|
||||
|
||||
test('shouldApplyCustomAccentRecord ignores stale revisions', () => {
|
||||
const current = { color: '199 89% 48%', version: 2 };
|
||||
assert.equal(shouldApplyCustomAccentRecord(current, { color: '0 84% 60%', version: 1 }), false);
|
||||
assert.equal(shouldApplyCustomAccentRecord(current, { color: '330 81% 60%', version: 3 }), true);
|
||||
assert.equal(shouldApplyCustomAccentRecord(current, { color: '199 89% 48%', version: 2 }), false);
|
||||
});
|
||||
|
||||
test('shouldBroadcastCustomAccentChange suppresses incoming rebroadcasts', () => {
|
||||
assert.deepEqual(
|
||||
shouldBroadcastCustomAccentChange('incoming', true),
|
||||
{ shouldBroadcast: false, nextSource: 'local' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
shouldBroadcastCustomAccentChange('local', true),
|
||||
{ shouldBroadcast: true, nextSource: 'local' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
shouldBroadcastCustomAccentChange('local', false),
|
||||
{ shouldBroadcast: false, nextSource: 'local' },
|
||||
);
|
||||
});
|
||||
|
||||
test('legacy unversioned always-broadcast accent sync oscillates during a fast drag', () => {
|
||||
const alwaysBroadcast = (
|
||||
_source: CustomAccentMutationSource,
|
||||
persistMounted: boolean,
|
||||
) => ({
|
||||
shouldBroadcast: persistMounted,
|
||||
nextSource: 'local' as const,
|
||||
});
|
||||
const alwaysApply = () => true;
|
||||
|
||||
const { settingsValues } = simulateAccentDrag({
|
||||
shouldBroadcast: alwaysBroadcast,
|
||||
shouldApply: alwaysApply,
|
||||
versioned: false,
|
||||
});
|
||||
const unique = new Set(settingsValues);
|
||||
assert.ok(
|
||||
unique.has('0 84% 60%') && unique.has('199 89% 48%') && settingsValues.length > 2,
|
||||
`expected oscillation between drag samples, got ${settingsValues.join(' | ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('versioned accent sync ignores stale peer echoes during a fast drag', () => {
|
||||
const { settingsValues, mainValues } = simulateAccentDrag({
|
||||
shouldBroadcast: shouldBroadcastCustomAccentChange,
|
||||
shouldApply: shouldApplyCustomAccentRecord,
|
||||
versioned: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(settingsValues, ['0 84% 60%', '199 89% 48%']);
|
||||
assert.ok(mainValues.includes('199 89% 48%'));
|
||||
assert.equal(
|
||||
mainValues.includes('0 84% 60%') && mainValues[mainValues.length - 1] === '0 84% 60%',
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('serializeCustomAccentRecord round-trips through parse', () => {
|
||||
const raw = serializeCustomAccentRecord({ color: '262.1 83.3% 57.8%', version: 9 });
|
||||
assert.deepEqual(parseCustomAccentRecord(JSON.parse(raw)), {
|
||||
color: '262.1 83.3% 57.8%',
|
||||
version: 9,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { DEFAULT_CUSTOM_ACCENT, isValidHslToken } from './settingsStateDefaults';
|
||||
|
||||
export type CustomAccentMutationSource = 'local' | 'incoming';
|
||||
|
||||
export type CustomAccentRecord = {
|
||||
color: string;
|
||||
version: number;
|
||||
};
|
||||
|
||||
const FALLBACK_RECORD: CustomAccentRecord = {
|
||||
color: DEFAULT_CUSTOM_ACCENT,
|
||||
version: 0,
|
||||
};
|
||||
|
||||
function normalizeAccentColor(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const trimmed = raw.trim();
|
||||
return isValidHslToken(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse persisted / IPC custom-accent payloads.
|
||||
* Accepts legacy plain HSL tokens ("221.2 83.2% 53.3%") and versioned records.
|
||||
*/
|
||||
export function parseCustomAccentRecord(raw: unknown): CustomAccentRecord {
|
||||
if (typeof raw === 'string') {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.startsWith('{')) {
|
||||
try {
|
||||
return parseCustomAccentRecord(JSON.parse(trimmed));
|
||||
} catch {
|
||||
// fall through to plain HSL
|
||||
}
|
||||
}
|
||||
const color = normalizeAccentColor(trimmed);
|
||||
if (color) return { color, version: 0 };
|
||||
}
|
||||
|
||||
if (raw && typeof raw === 'object') {
|
||||
const record = raw as { color?: unknown; version?: unknown };
|
||||
const color = normalizeAccentColor(record.color) ?? FALLBACK_RECORD.color;
|
||||
const version = Number(record.version);
|
||||
return {
|
||||
color,
|
||||
version: Number.isFinite(version) && version > 0 ? Math.floor(version) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
return { ...FALLBACK_RECORD };
|
||||
}
|
||||
|
||||
export function serializeCustomAccentRecord(record: CustomAccentRecord): string {
|
||||
const color = normalizeAccentColor(record.color) ?? FALLBACK_RECORD.color;
|
||||
return JSON.stringify({
|
||||
color,
|
||||
version: Math.max(0, Math.floor(record.version) || 0),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Incoming peer updates must not clobber a newer local/drag revision.
|
||||
* Equal versions are treated as already-applied (no state thrash).
|
||||
*/
|
||||
export function shouldApplyCustomAccentRecord(
|
||||
current: CustomAccentRecord,
|
||||
incoming: CustomAccentRecord,
|
||||
): boolean {
|
||||
if (incoming.version > current.version) return true;
|
||||
if (incoming.version < current.version) return false;
|
||||
// Same version: only apply when the color itself differs and both are
|
||||
// legacy/unversioned (version 0), so first-load plain strings still sync.
|
||||
if (incoming.version === 0 && current.version === 0) {
|
||||
return incoming.color !== current.color;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a custom-accent state change should be rebroadcast to peer
|
||||
* windows. Incoming IPC/storage updates must not notify again — otherwise a
|
||||
* fast native color-picker drag in the settings window ping-pongs with the
|
||||
* main window and the accent CSS variables oscillate (see #2743, same class
|
||||
* as window-opacity #2018).
|
||||
*/
|
||||
export function shouldBroadcastCustomAccentChange(
|
||||
mutationSource: CustomAccentMutationSource,
|
||||
persistMounted: boolean,
|
||||
): { shouldBroadcast: boolean; nextSource: CustomAccentMutationSource } {
|
||||
if (mutationSource === 'incoming') {
|
||||
return { shouldBroadcast: false, nextSource: 'local' };
|
||||
}
|
||||
return { shouldBroadcast: persistMounted, nextSource: 'local' };
|
||||
}
|
||||
@@ -66,6 +66,7 @@ interface UseSettingsStorageSyncParams {
|
||||
darkUiThemeId: string;
|
||||
accentMode: 'theme' | 'custom';
|
||||
customAccent: string;
|
||||
customAccentVersion: number;
|
||||
customCSS: string;
|
||||
uiFontFamilyId: string;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
@@ -109,7 +110,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setLightUiThemeId: Dispatch<SetStateAction<string>>;
|
||||
setDarkUiThemeId: Dispatch<SetStateAction<string>>;
|
||||
setAccentMode: Dispatch<SetStateAction<'theme' | 'custom'>>;
|
||||
setCustomAccent: Dispatch<SetStateAction<string>>;
|
||||
applyIncomingCustomAccent: (raw: unknown) => void;
|
||||
setCustomCSS: Dispatch<SetStateAction<string>>;
|
||||
setUiFontFamilyId: Dispatch<SetStateAction<string>>;
|
||||
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
|
||||
@@ -160,7 +161,7 @@ interface UseSettingsStorageSyncParams {
|
||||
|
||||
export function useSettingsStorageSync({
|
||||
enabled = true,
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
@@ -168,7 +169,7 @@ export function useSettingsStorageSync({
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
@@ -184,7 +185,7 @@ export function useSettingsStorageSync({
|
||||
// can compare without capturing 25+ state variables in its closure / dep array.
|
||||
// This avoids constant listener detach/reattach on every state change.
|
||||
const settingsSnapshotRef = useRef({
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
@@ -194,7 +195,7 @@ export function useSettingsStorageSync({
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
});
|
||||
settingsSnapshotRef.current = {
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
@@ -215,7 +216,15 @@ export function useSettingsStorageSync({
|
||||
if (appearance.next.lightUiThemeId !== s.lightUiThemeId) setLightUiThemeId(appearance.next.lightUiThemeId);
|
||||
if (appearance.next.darkUiThemeId !== s.darkUiThemeId) setDarkUiThemeId(appearance.next.darkUiThemeId);
|
||||
if (appearance.next.accentMode !== s.accentMode) setAccentMode(appearance.next.accentMode);
|
||||
if (appearance.next.customAccent !== s.customAccent) setCustomAccent(appearance.next.customAccent);
|
||||
if (
|
||||
appearance.next.customAccent !== s.customAccent
|
||||
|| appearance.next.customAccentVersion !== s.customAccentVersion
|
||||
) {
|
||||
applyIncomingCustomAccent({
|
||||
color: appearance.next.customAccent,
|
||||
version: appearance.next.customAccentVersion,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === STORAGE_KEY_CUSTOM_CSS && e.newValue !== null) {
|
||||
@@ -505,7 +514,7 @@ export function useSettingsStorageSync({
|
||||
mergeIncomingTerminalSettings,
|
||||
setAccentMode,
|
||||
setAutoUpdateEnabled,
|
||||
setCustomAccent,
|
||||
applyIncomingCustomAccent,
|
||||
setCustomCSS,
|
||||
setDarkUiThemeId,
|
||||
setEditorWordWrapState,
|
||||
|
||||
@@ -50,6 +50,26 @@ test("parks browse only when the interactive surface hides and not already parke
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("keeps a hidden SFTP owner live while an external editor temp file is open", () => {
|
||||
const interactive = isBrowseSessionInteractive({
|
||||
surfaceVisible: false,
|
||||
hasOwnedEditorTab: false,
|
||||
hasActiveExternalEdit: true,
|
||||
});
|
||||
|
||||
assert.equal(interactive, true);
|
||||
assert.equal(shouldParkBrowseSessions({
|
||||
interactive: false,
|
||||
browseParked: false,
|
||||
activeExternalEditCount: 1,
|
||||
}), false);
|
||||
assert.equal(shouldParkBrowseSessions({
|
||||
interactive: false,
|
||||
browseParked: false,
|
||||
activeExternalEditCount: 0,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("restores browse when the surface becomes interactive again after park", () => {
|
||||
assert.equal(shouldRestoreBrowseSessions({ interactive: true, browseParked: true }), true);
|
||||
assert.equal(shouldRestoreBrowseSessions({ interactive: true, browseParked: false }), false);
|
||||
|
||||
@@ -9,8 +9,12 @@
|
||||
export function isBrowseSessionInteractive(params: {
|
||||
surfaceVisible: boolean;
|
||||
hasOwnedEditorTab: boolean;
|
||||
/** External editor temp files (Notepad++ etc.) still need the browse session. */
|
||||
hasActiveExternalEdit?: boolean;
|
||||
}): boolean {
|
||||
return params.surfaceVisible || params.hasOwnedEditorTab;
|
||||
return params.surfaceVisible
|
||||
|| params.hasOwnedEditorTab
|
||||
|| !!params.hasActiveExternalEdit;
|
||||
}
|
||||
|
||||
export function listRemoteBrowseConnectionIds(
|
||||
@@ -49,8 +53,14 @@ export function shouldParkBrowseSessions(params: {
|
||||
browseParked: boolean;
|
||||
/** Defer park while unfinished transfers may still use browse sessions pre-lease. */
|
||||
activeTransfersCount?: number;
|
||||
/**
|
||||
* Defer park while an external editor still holds a downloaded temp file.
|
||||
* Parking calls closeSftp, which deletes those temps and breaks save/sync.
|
||||
*/
|
||||
activeExternalEditCount?: number;
|
||||
}): boolean {
|
||||
if (params.activeTransfersCount && params.activeTransfersCount > 0) return false;
|
||||
if (params.activeExternalEditCount && params.activeExternalEditCount > 0) return false;
|
||||
return !params.interactive && !params.browseParked;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createExternalEditTempRetention } from "./externalEditTempRetention";
|
||||
|
||||
test("remembers distinct temps per sftp session and forgets one session without touching others", () => {
|
||||
const retention = createExternalEditTempRetention();
|
||||
|
||||
assert.equal(retention.remember("sftp-a", "/tmp/a.txt"), true);
|
||||
assert.equal(retention.remember("sftp-a", "/tmp/a.txt"), false);
|
||||
assert.equal(retention.remember("sftp-b", "/tmp/b.txt"), true);
|
||||
assert.equal(retention.size, 2);
|
||||
|
||||
assert.equal(retention.forgetSftp("sftp-a"), true);
|
||||
assert.equal(retention.size, 1);
|
||||
assert.equal(retention.forgetSftp("sftp-a"), false);
|
||||
assert.equal(retention.forgetPath("/tmp/b.txt"), true);
|
||||
assert.equal(retention.size, 0);
|
||||
});
|
||||
|
||||
test("clear drops every retainer after session-wide cleanup", () => {
|
||||
const retention = createExternalEditTempRetention();
|
||||
retention.remember("sftp-1", "/tmp/one.txt");
|
||||
retention.remember("sftp-2", "/tmp/two.txt");
|
||||
|
||||
assert.equal(retention.clear(), true);
|
||||
assert.equal(retention.size, 0);
|
||||
assert.equal(retention.clear(), false);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Renderer-side retainers for remote temps opened in external editors.
|
||||
* closeSftp deletes those files; forget matching retainers so park/auto-connect
|
||||
* checks do not keep treating a deleted temp as active work.
|
||||
*/
|
||||
export type ExternalEditTempRetention = {
|
||||
remember(sftpId: string, localPath: string): boolean;
|
||||
forgetPath(localPath: string): boolean;
|
||||
forgetSftp(sftpId: string): boolean;
|
||||
clear(): boolean;
|
||||
readonly size: number;
|
||||
};
|
||||
|
||||
export function createExternalEditTempRetention(): ExternalEditTempRetention {
|
||||
const bySftp = new Map<string, Set<string>>();
|
||||
|
||||
const recount = (): number => {
|
||||
let total = 0;
|
||||
for (const paths of bySftp.values()) total += paths.size;
|
||||
return total;
|
||||
};
|
||||
|
||||
return {
|
||||
remember(sftpId, localPath) {
|
||||
if (!sftpId || !localPath) return false;
|
||||
let paths = bySftp.get(sftpId);
|
||||
if (!paths) {
|
||||
paths = new Set();
|
||||
bySftp.set(sftpId, paths);
|
||||
}
|
||||
if (paths.has(localPath)) return false;
|
||||
paths.add(localPath);
|
||||
return true;
|
||||
},
|
||||
forgetPath(localPath) {
|
||||
if (!localPath) return false;
|
||||
let changed = false;
|
||||
for (const [sftpId, paths] of bySftp) {
|
||||
if (!paths.delete(localPath)) continue;
|
||||
changed = true;
|
||||
if (paths.size === 0) bySftp.delete(sftpId);
|
||||
}
|
||||
return changed;
|
||||
},
|
||||
forgetSftp(sftpId) {
|
||||
if (!sftpId || !bySftp.has(sftpId)) return false;
|
||||
bySftp.delete(sftpId);
|
||||
return true;
|
||||
},
|
||||
clear() {
|
||||
if (bySftp.size === 0) return false;
|
||||
bySftp.clear();
|
||||
return true;
|
||||
},
|
||||
get size() {
|
||||
return recount();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -138,9 +138,13 @@ test("release invalidates pending watch starts while a new generation remains us
|
||||
|
||||
test("backend session cleanup removes a stopped watch from renderer ownership", async () => {
|
||||
const stopped: Array<{ watchId: string; cleanupTempFile: boolean }> = [];
|
||||
let onBackendStopped: ((payload: { watchId: string }) => void) | null = null;
|
||||
let onBackendStopped: ((payload: {
|
||||
watchId: string;
|
||||
localPath?: string;
|
||||
}) => void) | null = null;
|
||||
let lifecycle: ExternalFileWatchLifecycle | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const forgottenPaths: string[] = [];
|
||||
|
||||
function Probe() {
|
||||
lifecycle = useExternalFileWatchLifecycle(
|
||||
@@ -151,6 +155,7 @@ test("backend session cleanup removes a stopped watch from renderer ownership",
|
||||
onBackendStopped = callback;
|
||||
return () => { onBackendStopped = null; };
|
||||
},
|
||||
(localPath) => { forgottenPaths.push(localPath); },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -162,9 +167,13 @@ test("backend session cleanup removes a stopped watch from renderer ownership",
|
||||
assert.equal(lifecycle!.activeCountRef.current, 1);
|
||||
|
||||
await act(async () => {
|
||||
onBackendStopped?.({ watchId: "watch-closed-with-session" });
|
||||
onBackendStopped?.({
|
||||
watchId: "watch-closed-with-session",
|
||||
localPath: "/tmp/externally-deleted.txt",
|
||||
});
|
||||
});
|
||||
assert.equal(lifecycle!.activeCountRef.current, 0);
|
||||
assert.deepEqual(forgottenPaths, ["/tmp/externally-deleted.txt"]);
|
||||
|
||||
await act(async () => renderer!.unmount());
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
@@ -19,7 +19,12 @@ export type StopExternalFileWatch = (
|
||||
) => void | Promise<unknown>;
|
||||
|
||||
export type SubscribeExternalFileWatchStopped = (
|
||||
callback: (payload: { watchId: string }) => void,
|
||||
callback: (payload: {
|
||||
watchId: string;
|
||||
localPath?: string;
|
||||
remotePath?: string;
|
||||
sftpId?: string;
|
||||
}) => void,
|
||||
) => (() => void) | void;
|
||||
|
||||
export interface ExternalFileWatchLifecycle {
|
||||
@@ -32,16 +37,19 @@ export interface ExternalFileWatchLifecycle {
|
||||
export function useExternalFileWatchLifecycle(
|
||||
stopWatch: StopExternalFileWatch,
|
||||
subscribeStopped?: SubscribeExternalFileWatchStopped,
|
||||
onStoppedLocalPath?: (localPath: string) => void,
|
||||
): ExternalFileWatchLifecycle {
|
||||
const watchIdsRef = useRef<Set<string>>(new Set());
|
||||
const activeCountRef = useRef(0);
|
||||
const stopWatchRef = useRef(stopWatch);
|
||||
const subscribeStoppedRef = useRef(subscribeStopped);
|
||||
const onStoppedLocalPathRef = useRef(onStoppedLocalPath);
|
||||
const disposedRef = useRef(false);
|
||||
const generationRef = useRef(0);
|
||||
const invalidatedCleanupTempFilesRef = useRef(false);
|
||||
stopWatchRef.current = stopWatch;
|
||||
subscribeStoppedRef.current = subscribeStopped;
|
||||
onStoppedLocalPathRef.current = onStoppedLocalPath;
|
||||
|
||||
const captureGeneration = useCallback(() => generationRef.current, []);
|
||||
|
||||
@@ -86,9 +94,10 @@ export function useExternalFileWatchLifecycle(
|
||||
};
|
||||
}, [releaseAll]);
|
||||
|
||||
useEffect(() => subscribeStoppedRef.current?.(({ watchId }) => {
|
||||
useEffect(() => subscribeStoppedRef.current?.(({ watchId, localPath }) => {
|
||||
if (!watchId || !watchIdsRef.current.delete(watchId)) return;
|
||||
activeCountRef.current = watchIdsRef.current.size;
|
||||
if (localPath) onStoppedLocalPathRef.current?.(localPath);
|
||||
}), []);
|
||||
|
||||
return { activeCountRef, captureGeneration, remember, releaseAll };
|
||||
|
||||
@@ -157,6 +157,23 @@ test("release metadata closes the backend before a failed connection is retained
|
||||
assert.equal(cacheKeys.size, 0);
|
||||
});
|
||||
|
||||
test("releaseSftpConnectionMetadata notifies after a remote session closes", async () => {
|
||||
const sessions = new Map([["connection-1", "sftp-1"]]);
|
||||
const cacheKeys = new Map([["connection-1", "cache-1"]]);
|
||||
const notified: string[] = [];
|
||||
|
||||
await releaseSftpConnectionMetadata({
|
||||
connectionId: "connection-1",
|
||||
sftpSessions: sessions,
|
||||
connectionCacheKeys: cacheKeys,
|
||||
clearCacheForConnection: () => {},
|
||||
closeSftp: async () => {},
|
||||
onRemoteSessionClosed: (sftpId) => { notified.push(sftpId); },
|
||||
});
|
||||
|
||||
assert.deepEqual(notified, ["sftp-1"]);
|
||||
});
|
||||
|
||||
test("resolvePinnedReconnectSide follows a tab moved to the other side", () => {
|
||||
assert.equal(
|
||||
resolvePinnedReconnectSide("left", "tab-1", [], [{ id: "tab-1" }]),
|
||||
|
||||
@@ -41,6 +41,8 @@ interface UseSftpConnectionsParams {
|
||||
clearCacheForConnection: (connectionId: string) => void;
|
||||
createEmptyPane: (id?: string, showHiddenFiles?: boolean) => SftpPane;
|
||||
autoConnectLocalOnMount?: boolean;
|
||||
/** Fired after a browse SFTP id is closed so renderer retainers can drop. */
|
||||
onRemoteSessionClosed?: (sftpId: string) => void;
|
||||
}
|
||||
|
||||
export interface SftpConnectOptions {
|
||||
@@ -82,6 +84,7 @@ export async function releaseSftpConnectionMetadata(params: {
|
||||
connectionCacheKeys: Map<string, string>;
|
||||
clearCacheForConnection: (connectionId: string) => void;
|
||||
closeSftp: (sftpId: string) => Promise<unknown>;
|
||||
onRemoteSessionClosed?: (sftpId: string) => void;
|
||||
}): Promise<void> {
|
||||
const sftpId = takeSftpConnectionMetadataForClose(params);
|
||||
if (params.isLocal || !sftpId) return;
|
||||
@@ -90,6 +93,7 @@ export async function releaseSftpConnectionMetadata(params: {
|
||||
} catch {
|
||||
// Best-effort: backend owner cleanup remains the final safety net.
|
||||
}
|
||||
params.onRemoteSessionClosed?.(sftpId);
|
||||
}
|
||||
|
||||
export function createSftpConnectionId(
|
||||
@@ -240,7 +244,13 @@ export const useSftpConnections = ({
|
||||
clearCacheForConnection,
|
||||
createEmptyPane,
|
||||
autoConnectLocalOnMount = true,
|
||||
onRemoteSessionClosed,
|
||||
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
|
||||
const onRemoteSessionClosedRef = useRef(onRemoteSessionClosed);
|
||||
onRemoteSessionClosedRef.current = onRemoteSessionClosed;
|
||||
const notifyRemoteSessionClosed = useCallback((sftpId: string | undefined) => {
|
||||
if (sftpId) onRemoteSessionClosedRef.current?.(sftpId);
|
||||
}, []);
|
||||
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, knownHosts, terminalSettings });
|
||||
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
|
||||
const [hostKeyVerification, setHostKeyVerification] = useState<SftpHostKeyVerificationState | null>(null);
|
||||
@@ -450,6 +460,7 @@ export const useSftpConnections = ({
|
||||
connectionCacheKeys: connectionCacheKeyMapRef.current,
|
||||
clearCacheForConnection,
|
||||
closeSftp: async (sftpId) => netcattyBridge.get()?.closeSftp(sftpId),
|
||||
onRemoteSessionClosed: (sftpId) => notifyRemoteSessionClosed(sftpId),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -491,6 +502,7 @@ export const useSftpConnections = ({
|
||||
} catch {
|
||||
// Ignore errors when closing stale SFTP sessions
|
||||
}
|
||||
notifyRemoteSessionClosed(oldSftpId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -966,6 +978,7 @@ export const useSftpConnections = ({
|
||||
} catch {
|
||||
// Ignore errors when closing SFTP session during disconnect
|
||||
}
|
||||
notifyRemoteSessionClosed(sftpId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
cleanupFailedExternalOpenTemp,
|
||||
useExternalFileWatchLifecycle,
|
||||
} from "./externalFileWatchLifecycle";
|
||||
import { createExternalEditTempRetention } from "./externalEditTempRetention";
|
||||
import {
|
||||
cancelExternalUploadRuntime,
|
||||
getExternalUploadController,
|
||||
@@ -163,23 +164,49 @@ export const useSftpExternalOperations = (
|
||||
},
|
||||
}), [registerUploadController]);
|
||||
|
||||
// Temp files downloaded for external editors (even without auto-sync watches).
|
||||
// Parking / disconnect / reconnect call closeSftp, which deletes these paths —
|
||||
// forget matching retainers so hasActiveWork does not stick after cleanup.
|
||||
const externalEditTempsRef = useRef(createExternalEditTempRetention());
|
||||
const [activeExternalEditCount, setActiveExternalEditCount] = useState(0);
|
||||
const rememberExternalEditTemp = useCallback((sftpId: string, localPath: string) => {
|
||||
if (!externalEditTempsRef.current.remember(sftpId, localPath)) return;
|
||||
setActiveExternalEditCount(externalEditTempsRef.current.size);
|
||||
}, []);
|
||||
const forgetExternalEditTemp = useCallback((localPath: string) => {
|
||||
if (!externalEditTempsRef.current.forgetPath(localPath)) return;
|
||||
setActiveExternalEditCount(externalEditTempsRef.current.size);
|
||||
}, []);
|
||||
const forgetExternalEditTempsForSftp = useCallback((sftpId: string) => {
|
||||
if (!externalEditTempsRef.current.forgetSftp(sftpId)) return;
|
||||
setActiveExternalEditCount(externalEditTempsRef.current.size);
|
||||
}, []);
|
||||
|
||||
// Track every renderer-owned watch id so duplicate opens stay deduplicated
|
||||
// and panel/window teardown releases the worker-side polling resources.
|
||||
const stopExternalFileWatch = useCallback(async (watchId: string, cleanupTempFile: boolean) => {
|
||||
await netcattyBridge.get()?.stopFileWatch?.(watchId, cleanupTempFile);
|
||||
}, []);
|
||||
const subscribeExternalFileWatchStopped = useCallback((
|
||||
callback: (payload: { watchId: string }) => void,
|
||||
callback: (payload: { watchId: string; localPath?: string }) => void,
|
||||
) => netcattyBridge.get()?.onFileWatchStopped?.(callback), []);
|
||||
const {
|
||||
activeCountRef: activeFileWatchCountRef,
|
||||
captureGeneration: captureExternalFileWatchGeneration,
|
||||
remember: rememberExternalFileWatch,
|
||||
releaseAll: releaseExternalFileWatches,
|
||||
releaseAll: releaseExternalFileWatchesBase,
|
||||
} = useExternalFileWatchLifecycle(
|
||||
stopExternalFileWatch,
|
||||
subscribeExternalFileWatchStopped,
|
||||
// Force-stop (temp deleted / session cleanup) includes localPath — drop retainer.
|
||||
forgetExternalEditTemp,
|
||||
);
|
||||
const releaseExternalFileWatches = useCallback(async (cleanupTempFiles = false) => {
|
||||
await releaseExternalFileWatchesBase(cleanupTempFiles);
|
||||
if (externalEditTempsRef.current.clear()) {
|
||||
setActiveExternalEditCount(0);
|
||||
}
|
||||
}, [releaseExternalFileWatchesBase]);
|
||||
const [uploadConflicts, setUploadConflicts] = useState<FileConflict[]>([]);
|
||||
const uploadConflictResolversRef = useRef<Map<string, UploadConflictResolver>>(new Map());
|
||||
/** Maps conflict id → owning UploadController so cancel A never stops B's prompts. */
|
||||
@@ -483,6 +510,7 @@ export const useSftpExternalOperations = (
|
||||
if (bridge.registerTempFile) {
|
||||
try {
|
||||
await bridge.registerTempFile(sftpId, localTempPath);
|
||||
rememberExternalEditTemp(sftpId, localTempPath);
|
||||
} catch (err) {
|
||||
console.warn("[SFTP] Failed to register temp file for cleanup:", err);
|
||||
}
|
||||
@@ -490,7 +518,7 @@ export const useSftpExternalOperations = (
|
||||
|
||||
return { localTempPath, sftpId, externalTransferId };
|
||||
},
|
||||
[getActivePane, isTransferCancelled, ownerId, sftpSessionsRef],
|
||||
[getActivePane, isTransferCancelled, ownerId, rememberExternalEditTemp, sftpSessionsRef],
|
||||
);
|
||||
|
||||
const downloadToTempAndOpen = useCallback(
|
||||
@@ -525,6 +553,7 @@ export const useSftpExternalOperations = (
|
||||
await bridge.openWithApplication(localTempPath, appPath);
|
||||
} catch (err) {
|
||||
await cleanupFailedExternalOpenTemp(bridge, sftpId, localTempPath).catch(() => {});
|
||||
forgetExternalEditTemp(localTempPath);
|
||||
if (externalTransferId) {
|
||||
sftpTransferCenterStore.patchTask(externalTransferId, {
|
||||
status: "failed" as TransferStatus,
|
||||
@@ -555,7 +584,13 @@ export const useSftpExternalOperations = (
|
||||
|
||||
return { localTempPath, watchId };
|
||||
},
|
||||
[captureExternalFileWatchGeneration, downloadToTemp, getActivePane, rememberExternalFileWatch],
|
||||
[
|
||||
captureExternalFileWatchGeneration,
|
||||
downloadToTemp,
|
||||
forgetExternalEditTemp,
|
||||
getActivePane,
|
||||
rememberExternalFileWatch,
|
||||
],
|
||||
);
|
||||
|
||||
const openWithSystemDefault = useCallback(
|
||||
@@ -590,12 +625,14 @@ export const useSftpExternalOperations = (
|
||||
} catch (error) {
|
||||
if (!pane.connection.isLocal) {
|
||||
await cleanupFailedExternalOpenTemp(bridgeMethods, sftpId, localTempPath).catch(() => {});
|
||||
forgetExternalEditTemp(localTempPath);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!result.success) {
|
||||
if (!pane.connection.isLocal) {
|
||||
await cleanupFailedExternalOpenTemp(bridgeMethods, sftpId, localTempPath).catch(() => {});
|
||||
forgetExternalEditTemp(localTempPath);
|
||||
}
|
||||
if (externalTransferId) {
|
||||
sftpTransferCenterStore.patchTask(externalTransferId, {
|
||||
@@ -627,7 +664,13 @@ export const useSftpExternalOperations = (
|
||||
notify.error(err instanceof Error ? err.message : String(err), "SFTP");
|
||||
}
|
||||
},
|
||||
[captureExternalFileWatchGeneration, downloadToTemp, getActivePane, rememberExternalFileWatch],
|
||||
[
|
||||
captureExternalFileWatchGeneration,
|
||||
downloadToTemp,
|
||||
forgetExternalEditTemp,
|
||||
getActivePane,
|
||||
rememberExternalFileWatch,
|
||||
],
|
||||
);
|
||||
|
||||
// Create upload callbacks that translate to TransferTask updates
|
||||
@@ -1491,6 +1534,8 @@ export const useSftpExternalOperations = (
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
activeFileWatchCountRef,
|
||||
activeExternalEditCount,
|
||||
forgetExternalEditTempsForSftp,
|
||||
releaseExternalFileWatches,
|
||||
uploadConflicts,
|
||||
resolveUploadConflict,
|
||||
|
||||
@@ -68,6 +68,10 @@ export interface SftpExternalOperationsResult {
|
||||
) => Promise<{ localTempPath: string; watchId?: string }>;
|
||||
openWithSystemDefault: (side: "left" | "right", remotePath: string, fileName: string, options?: { enableWatch?: boolean }) => Promise<void>;
|
||||
activeFileWatchCountRef: React.MutableRefObject<number>;
|
||||
/** Remote temps opened in an external editor; keeps browse sessions from parking. */
|
||||
activeExternalEditCount: number;
|
||||
/** Drop retainers after closeSftp deletes that session's registered temps. */
|
||||
forgetExternalEditTempsForSftp: (sftpId: string) => void;
|
||||
releaseExternalFileWatches: (cleanupTempFiles?: boolean) => Promise<void>;
|
||||
uploadExternalFiles: (
|
||||
side: "left" | "right",
|
||||
|
||||
@@ -88,7 +88,6 @@ import { resolveSftpTransferConcurrency } from './sftp/transferConcurrency';
|
||||
import { resolveSshTransportIdleTtlMs } from '../../infrastructure/config/sshTransportIdleTtl';
|
||||
import {
|
||||
DEFAULT_ACCENT_MODE,
|
||||
DEFAULT_CUSTOM_ACCENT,
|
||||
DEFAULT_DARK_UI_THEME,
|
||||
DEFAULT_EDITOR_WORD_WRAP,
|
||||
DEFAULT_HOTKEY_SCHEME,
|
||||
@@ -154,6 +153,14 @@ import {
|
||||
type WindowOpacityMutationSource,
|
||||
type WindowOpacityRecord,
|
||||
} from './windowOpacitySync';
|
||||
import {
|
||||
parseCustomAccentRecord,
|
||||
serializeCustomAccentRecord,
|
||||
shouldApplyCustomAccentRecord,
|
||||
shouldBroadcastCustomAccentChange,
|
||||
type CustomAccentMutationSource,
|
||||
type CustomAccentRecord,
|
||||
} from './customAccentSync';
|
||||
import {
|
||||
createLocalTerminalFontSizeRecord,
|
||||
createTerminalFontSizeSyncOrigin,
|
||||
@@ -193,15 +200,47 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = readStoredString(STORAGE_KEY_UI_THEME_DARK);
|
||||
return stored && isValidUiThemeId('dark', stored) ? stored : DEFAULT_DARK_UI_THEME;
|
||||
});
|
||||
const [customAccent, setCustomAccent] = useState<string>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_COLOR);
|
||||
return stored && isValidHslToken(stored) ? stored.trim() : DEFAULT_CUSTOM_ACCENT;
|
||||
const [customAccentRecord, setCustomAccentRecord] = useState<CustomAccentRecord>(() => {
|
||||
return parseCustomAccentRecord(readStoredString(STORAGE_KEY_COLOR));
|
||||
});
|
||||
const customAccent = customAccentRecord.color;
|
||||
const customAccentMutationSourceRef = useRef<CustomAccentMutationSource>('local');
|
||||
const setCustomAccent = useCallback((nextValue: SetStateAction<string>) => {
|
||||
customAccentMutationSourceRef.current = 'local';
|
||||
setCustomAccentRecord((prev) => {
|
||||
const candidate = typeof nextValue === 'function'
|
||||
? (nextValue as (prevState: string) => string)(prev.color)
|
||||
: nextValue;
|
||||
const next = parseCustomAccentRecord(candidate);
|
||||
if (next.color === prev.color) return prev;
|
||||
return { color: next.color, version: prev.version + 1 };
|
||||
});
|
||||
}, []);
|
||||
const applyIncomingCustomAccent = useCallback((raw: unknown) => {
|
||||
const incoming = parseCustomAccentRecord(raw);
|
||||
setCustomAccentRecord((prev) => {
|
||||
if (!shouldApplyCustomAccentRecord(prev, incoming)) {
|
||||
return prev;
|
||||
}
|
||||
customAccentMutationSourceRef.current = 'incoming';
|
||||
return incoming;
|
||||
});
|
||||
}, []);
|
||||
const [accentMode, setAccentMode] = useState<'theme' | 'custom'>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_ACCENT_MODE);
|
||||
if (stored === 'theme' || stored === 'custom') return stored;
|
||||
const legacyColor = readStoredString(STORAGE_KEY_COLOR);
|
||||
return legacyColor && isValidHslToken(legacyColor) ? 'custom' : DEFAULT_ACCENT_MODE;
|
||||
const raw = readStoredString(STORAGE_KEY_COLOR);
|
||||
if (!raw) return DEFAULT_ACCENT_MODE;
|
||||
if (isValidHslToken(raw.trim())) return 'custom';
|
||||
if (raw.trim().startsWith('{')) {
|
||||
try {
|
||||
const obj = JSON.parse(raw.trim()) as { color?: unknown };
|
||||
return typeof obj.color === 'string' && isValidHslToken(obj.color) ? 'custom' : DEFAULT_ACCENT_MODE;
|
||||
} catch {
|
||||
return DEFAULT_ACCENT_MODE;
|
||||
}
|
||||
}
|
||||
return DEFAULT_ACCENT_MODE;
|
||||
});
|
||||
const [uiFontFamilyId, setUiFontFamilyId] = useState<string>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_UI_FONT_FAMILY);
|
||||
@@ -556,6 +595,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
darkUiThemeId,
|
||||
accentMode,
|
||||
customAccent,
|
||||
customAccentVersion: customAccentRecord.version,
|
||||
});
|
||||
appearanceStateRef.current = {
|
||||
theme,
|
||||
@@ -563,6 +603,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
darkUiThemeId,
|
||||
accentMode,
|
||||
customAccent,
|
||||
customAccentVersion: customAccentRecord.version,
|
||||
};
|
||||
|
||||
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
|
||||
@@ -732,6 +773,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
darkUiThemeId: nextDarkId,
|
||||
accentMode: nextAccentMode,
|
||||
customAccent: nextAccent,
|
||||
customAccentVersion: nextAccentVersion,
|
||||
} = nextAppearance;
|
||||
|
||||
// Fix 2: Skip expensive DOM operations if nothing actually changed
|
||||
@@ -740,18 +782,27 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
nextLightId === current.lightUiThemeId &&
|
||||
nextDarkId === current.darkUiThemeId &&
|
||||
nextAccentMode === current.accentMode &&
|
||||
nextAccent === current.customAccent
|
||||
nextAccent === current.customAccent &&
|
||||
nextAccentVersion === current.customAccentVersion
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Publish synchronously so a later keyed IPC in the same turn composes on top.
|
||||
appearanceStateRef.current = nextAppearance;
|
||||
if (
|
||||
nextAccent !== current.customAccent
|
||||
|| nextAccentVersion !== current.customAccentVersion
|
||||
) {
|
||||
// Mark incoming so the persist effect does not rebroadcast to the peer that
|
||||
// originated this update (color-picker drag ping-pong; see #2743).
|
||||
customAccentMutationSourceRef.current = 'incoming';
|
||||
setCustomAccentRecord({ color: nextAccent, version: nextAccentVersion });
|
||||
}
|
||||
setTheme(nextTheme);
|
||||
setLightUiThemeId(nextLightId);
|
||||
setDarkUiThemeId(nextDarkId);
|
||||
setAccentMode(nextAccentMode);
|
||||
setCustomAccent(nextAccent);
|
||||
|
||||
const effective = nextTheme === 'system' ? getSystemPreference() : nextTheme;
|
||||
const tokens = getUiThemeById(effective, effective === 'dark' ? nextDarkId : nextLightId).tokens;
|
||||
@@ -892,6 +943,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
darkUiThemeId,
|
||||
accentMode,
|
||||
customAccent,
|
||||
customAccentVersion: customAccentRecord.version,
|
||||
};
|
||||
// Capture previous snapshot before overwrite so we can notify only the
|
||||
// appearance fields that actually changed on this render.
|
||||
@@ -913,7 +965,19 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, accentMode);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_COLOR, customAccent);
|
||||
// Never let a stale effect overwrite a newer revision already on disk.
|
||||
const storedAccent = parseCustomAccentRecord(
|
||||
localStorageAdapter.readString(STORAGE_KEY_COLOR),
|
||||
);
|
||||
if (
|
||||
shouldApplyCustomAccentRecord(storedAccent, customAccentRecord)
|
||||
|| storedAccent.version === customAccentRecord.version
|
||||
) {
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_COLOR,
|
||||
serializeCustomAccentRecord(customAccentRecord),
|
||||
);
|
||||
}
|
||||
// Fix 1: Skip IPC broadcast on initial mount (values already match localStorage)
|
||||
if (!persistMountedRef.current) return;
|
||||
// Emit a keyed IPC notification for each changed appearance field so the
|
||||
@@ -932,10 +996,21 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
if (!previousAppearance || previousAppearance.accentMode !== accentMode) {
|
||||
notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode);
|
||||
}
|
||||
if (!previousAppearance || previousAppearance.customAccent !== customAccent) {
|
||||
notifySettingsChanged(STORAGE_KEY_COLOR, customAccent);
|
||||
if (
|
||||
!previousAppearance
|
||||
|| previousAppearance.customAccent !== customAccent
|
||||
|| previousAppearance.customAccentVersion !== customAccentRecord.version
|
||||
) {
|
||||
const decision = shouldBroadcastCustomAccentChange(
|
||||
customAccentMutationSourceRef.current,
|
||||
true,
|
||||
);
|
||||
customAccentMutationSourceRef.current = decision.nextSource;
|
||||
if (decision.shouldBroadcast) {
|
||||
notifySettingsChanged(STORAGE_KEY_COLOR, customAccentRecord);
|
||||
}
|
||||
}
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, notifySettingsChanged]);
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentRecord, notifySettingsChanged]);
|
||||
|
||||
// Listen for OS color scheme changes to keep systemPreference in sync
|
||||
useEffect(() => {
|
||||
@@ -1035,6 +1110,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
useSettingsStorageSync({
|
||||
enabled: enableSettingsSync,
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customAccentVersion: customAccentRecord.version,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
@@ -1042,7 +1118,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
|
||||
applyIncomingCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
|
||||
|
||||
@@ -55,6 +55,7 @@ export async function releaseSftpTabConnection(params: {
|
||||
connectionCacheKeys: Map<string, string>;
|
||||
clearCacheForConnection: (connectionId: string) => void;
|
||||
closeSftp: (sftpId: string) => Promise<unknown>;
|
||||
onRemoteSessionClosed?: (sftpId: string) => void;
|
||||
}): Promise<void> {
|
||||
await releaseSftpConnectionMetadata(params);
|
||||
}
|
||||
@@ -66,6 +67,7 @@ export const useSftpState = (
|
||||
options?: SftpStateOptions
|
||||
) => {
|
||||
const transferOwnerIdRef = useRef(options?.transferOwnerId ?? crypto.randomUUID());
|
||||
const forgetExternalEditTempsForSftpRef = useRef<(sftpId: string) => void>(() => {});
|
||||
const createPane = useCallback(
|
||||
(id?: string, showHiddenFiles = options?.defaultShowHiddenFiles ?? false) =>
|
||||
createEmptyPane(id, showHiddenFiles),
|
||||
@@ -241,6 +243,7 @@ export const useSftpState = (
|
||||
connectionCacheKeys: connectionCacheKeyMapRef.current,
|
||||
clearCacheForConnection,
|
||||
closeSftp: async (sftpId) => netcattyBridge.get()?.closeSftp(sftpId),
|
||||
onRemoteSessionClosed: (sftpId) => forgetExternalEditTempsForSftpRef.current(sftpId),
|
||||
});
|
||||
}
|
||||
closeTab(side, tabId);
|
||||
@@ -253,6 +256,7 @@ export const useSftpState = (
|
||||
connectionCacheKeys: connectionCacheKeyMapRef.current,
|
||||
clearCacheForConnection,
|
||||
closeSftp: async (sftpId) => netcattyBridge.get()?.closeSftp(sftpId),
|
||||
onRemoteSessionClosed: (sftpId) => forgetExternalEditTempsForSftpRef.current(sftpId),
|
||||
});
|
||||
}, [clearCacheForConnection]);
|
||||
|
||||
@@ -444,6 +448,7 @@ export const useSftpState = (
|
||||
clearCacheForConnection,
|
||||
createEmptyPane: createPane,
|
||||
autoConnectLocalOnMount: options?.autoConnectLocalOnMount,
|
||||
onRemoteSessionClosed: (sftpId) => forgetExternalEditTempsForSftpRef.current(sftpId),
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -567,6 +572,8 @@ export const useSftpState = (
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
activeFileWatchCountRef,
|
||||
activeExternalEditCount,
|
||||
forgetExternalEditTempsForSftp,
|
||||
releaseExternalFileWatches,
|
||||
uploadConflicts,
|
||||
resolveUploadConflict,
|
||||
@@ -627,6 +634,7 @@ export const useSftpState = (
|
||||
useCompressedUpload: options?.useCompressedUpload,
|
||||
isTransferCancelled,
|
||||
});
|
||||
forgetExternalEditTempsForSftpRef.current = forgetExternalEditTempsForSftp;
|
||||
|
||||
const conflicts = useMemo(
|
||||
() => [...transferConflicts, ...uploadConflicts],
|
||||
@@ -666,6 +674,7 @@ export const useSftpState = (
|
||||
interactive,
|
||||
browseParked: browseParkedRef.current,
|
||||
activeTransfersCount: Math.max(activeTransfersCount, centerActive ? 1 : 0),
|
||||
activeExternalEditCount,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
@@ -684,6 +693,7 @@ export const useSftpState = (
|
||||
} catch {
|
||||
// best-effort — session may already be gone
|
||||
}
|
||||
forgetExternalEditTempsForSftpRef.current(sftpId);
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -737,6 +747,7 @@ export const useSftpState = (
|
||||
}, [
|
||||
interactive,
|
||||
activeTransfersCount,
|
||||
activeExternalEditCount,
|
||||
clearCacheForConnection,
|
||||
connect,
|
||||
getActivePane,
|
||||
@@ -911,6 +922,7 @@ export const useSftpState = (
|
||||
rightTabs,
|
||||
transfers,
|
||||
activeTransfersCount,
|
||||
activeExternalEditCount,
|
||||
conflicts,
|
||||
hostKeyVerification,
|
||||
|
||||
@@ -932,6 +944,7 @@ export const useSftpState = (
|
||||
rightTabs,
|
||||
transfers,
|
||||
activeTransfersCount,
|
||||
activeExternalEditCount,
|
||||
conflicts,
|
||||
hostKeyVerification,
|
||||
stableMethods,
|
||||
|
||||
@@ -51,7 +51,11 @@ import {
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter, LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import { mergeGlobalHistoryOnAppend, sanitizeGlobalHistoryEntries } from "../../domain/globalHistory";
|
||||
import {
|
||||
mergeGlobalHistoryOnAppend,
|
||||
removeGlobalHistoryEntry,
|
||||
sanitizeGlobalHistoryEntries,
|
||||
} from "../../domain/globalHistory";
|
||||
import {
|
||||
buildTerminalDataMapFromLogs,
|
||||
mergeConnectionLogsFromStorage,
|
||||
@@ -844,6 +848,16 @@ export const useVaultState = () => {
|
||||
publishShellHistorySnapshot([]);
|
||||
}, []);
|
||||
|
||||
const removeShellHistoryEntry = useCallback((entryId: string) => {
|
||||
setShellHistory((prev) => {
|
||||
const updated = removeGlobalHistoryEntry(prev, entryId);
|
||||
if (updated === prev) return prev;
|
||||
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, updated);
|
||||
publishShellHistorySnapshot(updated);
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Connection logs management
|
||||
const addConnectionLog = useCallback(
|
||||
(log: Omit<ConnectionLog, "id">) => {
|
||||
@@ -1506,6 +1520,10 @@ export const useVaultState = () => {
|
||||
latestHosts.length,
|
||||
destination,
|
||||
storedGroups,
|
||||
{
|
||||
identities: latestIdentities.length,
|
||||
keys: latestKeys.length,
|
||||
},
|
||||
);
|
||||
const nextHosts = normalizeVaultOrder(committed.hosts.map((host) => sanitizeHost(host)));
|
||||
const nextKeys = normalizeVaultOrder(committed.keys);
|
||||
@@ -1619,6 +1637,7 @@ export const useVaultState = () => {
|
||||
updateGroupConfigs,
|
||||
addShellHistoryEntry,
|
||||
clearShellHistory,
|
||||
removeShellHistoryEntry,
|
||||
addConnectionLog,
|
||||
updateConnectionLog,
|
||||
toggleConnectionLogSaved,
|
||||
|
||||
@@ -134,15 +134,20 @@ export function useWorkSurfaceHostEditor({
|
||||
|
||||
const save = useCallback((draft: Host) => {
|
||||
if (!target) return;
|
||||
const nextHosts = saveWorkSurfaceHostDraft(hosts, target, draft);
|
||||
if (!nextHosts) {
|
||||
let saved = false;
|
||||
onUpdateHosts((prevHosts) => {
|
||||
const nextHosts = saveWorkSurfaceHostDraft(prevHosts, target, draft);
|
||||
if (!nextHosts) return prevHosts;
|
||||
saved = true;
|
||||
return nextHosts;
|
||||
});
|
||||
if (!saved) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
onUpdateHosts(nextHosts);
|
||||
onSaved?.(target.mode);
|
||||
close();
|
||||
}, [close, hosts, onSaved, onUpdateHosts, target]);
|
||||
}, [close, onSaved, onUpdateHosts, target]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldCloseDeletedWorkSurfaceHost(hosts, target)) {
|
||||
|
||||
@@ -819,7 +819,7 @@ test("buildLocalVaultPayload includes last-known plugin sidecars for protective
|
||||
}
|
||||
});
|
||||
|
||||
test("buildLocalVaultPayloadAsync keeps last-known when live collect is empty", async () => {
|
||||
test("buildLocalVaultPayloadAsync prefers live empty over last-known for backups", async () => {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
@@ -847,8 +847,7 @@ test("buildLocalVaultPayloadAsync keeps last-known when live collect is empty",
|
||||
try {
|
||||
const { buildLocalVaultPayloadAsync } = await import("./syncPayload.ts");
|
||||
const payload = await buildLocalVaultPayloadAsync(vault([]));
|
||||
assert.equal(payload.pluginSidecars?.entries?.length, 1);
|
||||
assert.equal(payload.pluginSidecars?.entries?.[0].value, "dark");
|
||||
assert.equal(payload.pluginSidecars?.entries?.length, 0);
|
||||
} finally {
|
||||
localStorage.removeItem("netcatty_plugin_sidecars_last_known_v1");
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
|
||||
+22
-13
@@ -43,6 +43,10 @@ import {
|
||||
parseTerminalFontSizeRecord,
|
||||
serializeTerminalFontSizeRecord,
|
||||
} from './state/terminalFontSizeSync';
|
||||
import {
|
||||
parseCustomAccentRecord,
|
||||
serializeCustomAccentRecord,
|
||||
} from './state/customAccentSync';
|
||||
import {
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
@@ -238,9 +242,10 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'rightClickBehavior', 'showContextMenuOverFullscreenApps', 'middleClickBehavior', 'copyOnSelect', 'normalizeTextOnCopy', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'autoUploadClipboardImageOnPaste',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'dynamicTabTitleMode',
|
||||
'autoCloseOnExit',
|
||||
'showHostInfoBar', 'showServerStats',
|
||||
'showHostInfoBar', 'hostInfoBarTitleMode', 'showServerStats',
|
||||
'serverStatsRefreshInterval',
|
||||
'systemManagerProcessRefreshInterval', 'systemManagerTmuxRefreshInterval',
|
||||
'systemManagerDockerListRefreshInterval', 'systemManagerDockerStatsRefreshInterval',
|
||||
@@ -407,8 +412,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (darkUi) settings.darkUiThemeId = darkUi;
|
||||
const accentMode = localStorageAdapter.readString(STORAGE_KEY_ACCENT_MODE);
|
||||
if (accentMode === 'theme' || accentMode === 'custom') settings.accentMode = accentMode;
|
||||
const accent = localStorageAdapter.readString(STORAGE_KEY_COLOR);
|
||||
if (accent) settings.customAccent = accent;
|
||||
const accentRaw = localStorageAdapter.readString(STORAGE_KEY_COLOR);
|
||||
if (accentRaw) settings.customAccent = parseCustomAccentRecord(accentRaw).color;
|
||||
const uiFont = localStorageAdapter.readString(STORAGE_KEY_UI_FONT_FAMILY);
|
||||
if (uiFont) settings.uiFontFamilyId = uiFont;
|
||||
const lang = localStorageAdapter.readString(STORAGE_KEY_UI_LANGUAGE);
|
||||
@@ -614,7 +619,17 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
if (settings.lightUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, settings.lightUiThemeId);
|
||||
if (settings.darkUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, settings.darkUiThemeId);
|
||||
if (settings.accentMode != null) localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, settings.accentMode);
|
||||
if (settings.customAccent != null) localStorageAdapter.writeString(STORAGE_KEY_COLOR, settings.customAccent);
|
||||
if (settings.customAccent != null) {
|
||||
const existing = parseCustomAccentRecord(localStorageAdapter.readString(STORAGE_KEY_COLOR));
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_COLOR,
|
||||
serializeCustomAccentRecord({
|
||||
color: parseCustomAccentRecord(settings.customAccent).color,
|
||||
// Bump so peer windows' version gates accept the synced value.
|
||||
version: Math.max(existing.version, 0) + 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (settings.uiFontFamilyId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, settings.uiFontFamilyId);
|
||||
if (settings.uiLanguage != null) localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, settings.uiLanguage);
|
||||
if (settings.customCSS != null) localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, settings.customCSS);
|
||||
@@ -1034,15 +1049,9 @@ export async function buildLocalVaultPayloadAsync(
|
||||
try {
|
||||
const live = await collectPluginSyncSidecarsFromHost();
|
||||
if (live) {
|
||||
// Live collect may return authoritative-empty while still deferring the
|
||||
// last-known cache wipe until a successful sync commit. Protective
|
||||
// backups must keep the non-empty last-known already attached to `base`.
|
||||
const liveEmpty = !Array.isArray(live.entries) || live.entries.length === 0;
|
||||
const baseHasEntries = Array.isArray(base.pluginSidecars?.entries)
|
||||
&& base.pluginSidecars.entries.length > 0;
|
||||
if (liveEmpty && baseHasEntries) {
|
||||
return base;
|
||||
}
|
||||
// Uploads may still defer wiping last-known until a successful sync
|
||||
// commit, but protective/version-change backups must snapshot the live
|
||||
// authoritative-empty bundle so restore cannot resurrect cleared plugin data.
|
||||
return withPluginSyncSidecars(base, live);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -18,11 +18,16 @@ import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
Terminal as TerminalIcon,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { toGlobalHistoryDisplayEntries } from '../domain/globalHistory';
|
||||
import {
|
||||
shouldRemoveAutocompleteHistoryEntry,
|
||||
toGlobalHistoryDisplayEntries,
|
||||
} from '../domain/globalHistory';
|
||||
import type { Host, RemoteHistoryEntry, ShellHistoryEntry } from '../domain/models';
|
||||
import { removeCommandHistoryEntry } from './terminal/autocomplete/commandHistoryStore';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { RemoteHistoryHostState } from '../application/state/useRemoteHistoryState';
|
||||
import {
|
||||
@@ -40,6 +45,7 @@ export interface HistorySidePanelProps {
|
||||
state: RemoteHistoryHostState;
|
||||
globalEntries: ShellHistoryEntry[];
|
||||
onFetch: (sessionId: string, hostId: string) => void;
|
||||
onDeleteGlobalEntry?: (entryId: string) => void;
|
||||
/** Paste into the terminal without executing (no trailing Enter). */
|
||||
onPasteToTerminal: (command: string) => void;
|
||||
/** Write to the terminal and execute (append Enter). */
|
||||
@@ -60,6 +66,7 @@ const DETAIL_ACTIONS_HEIGHT = 24;
|
||||
interface HistoryPanelEntry {
|
||||
id: string;
|
||||
command: string;
|
||||
hostId?: string;
|
||||
timestamp?: number;
|
||||
hostLabel?: string;
|
||||
}
|
||||
@@ -107,6 +114,7 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
state,
|
||||
globalEntries,
|
||||
onFetch,
|
||||
onDeleteGlobalEntry,
|
||||
onPasteToTerminal,
|
||||
onRunInTerminal,
|
||||
isVisible = true,
|
||||
@@ -185,6 +193,21 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleDeleteGlobalEntry = useCallback((entryId: string) => {
|
||||
if (scope !== 'global' || !onDeleteGlobalEntry) return;
|
||||
const entry = sourceEntries.find((candidate) => candidate.id === entryId);
|
||||
if (!entry) return;
|
||||
// Always remove the global row; only touch autocomplete when host is known.
|
||||
if (
|
||||
entry.hostId
|
||||
&& shouldRemoveAutocompleteHistoryEntry(globalEntries, entryId)
|
||||
) {
|
||||
removeCommandHistoryEntry(entry.command, entry.hostId);
|
||||
}
|
||||
onDeleteGlobalEntry(entryId);
|
||||
setSelectedEntryId(null);
|
||||
}, [globalEntries, onDeleteGlobalEntry, scope, sourceEntries]);
|
||||
|
||||
const handleRowClick = useCallback((entryId: string) => {
|
||||
setSelectedEntryId((current) => {
|
||||
const next = current === entryId ? null : entryId;
|
||||
@@ -216,6 +239,7 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
paste: t('history.action.paste'),
|
||||
run: t('history.action.run'),
|
||||
save: t('history.action.saveAsSnippet'),
|
||||
delete: t('history.action.delete'),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
@@ -358,6 +382,11 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
onRun={() => onRunInTerminal(row.entry.command)}
|
||||
onPaste={() => onPasteToTerminal(row.entry.command)}
|
||||
onSave={() => handleSaveAsSnippet(row.entry)}
|
||||
onDelete={
|
||||
scope === 'global' && onDeleteGlobalEntry
|
||||
? () => handleDeleteGlobalEntry(row.entry.id)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -371,6 +400,11 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
onRun={() => onRunInTerminal(row.entry.command)}
|
||||
onPaste={() => onPasteToTerminal(row.entry.command)}
|
||||
onSave={() => handleSaveAsSnippet(row.entry)}
|
||||
onDelete={
|
||||
scope === 'global' && onDeleteGlobalEntry
|
||||
? () => handleDeleteGlobalEntry(row.entry.id)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
@@ -416,14 +450,15 @@ const EmptyState: React.FC<{ message: string }> = ({ message }) => (
|
||||
|
||||
interface HistoryDetailStripProps {
|
||||
entry: HistoryPanelEntry;
|
||||
labels: { paste: string; run: string; save: string };
|
||||
labels: { paste: string; run: string; save: string; delete: string };
|
||||
onRun: () => void;
|
||||
onPaste: () => void;
|
||||
onSave: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
const HistoryDetailStrip: React.FC<HistoryDetailStripProps> = memo(
|
||||
({ entry, labels, onRun, onPaste, onSave }) => (
|
||||
({ entry, labels, onRun, onPaste, onSave, onDelete }) => (
|
||||
<div
|
||||
className="border-b border-border/40 bg-muted/20 px-3 py-1.5"
|
||||
data-section="history-detail"
|
||||
@@ -456,6 +491,11 @@ const HistoryDetailStrip: React.FC<HistoryDetailStripProps> = memo(
|
||||
<IconButton title={labels.save} onClick={onSave}>
|
||||
<FileCode size={12} />
|
||||
</IconButton>
|
||||
{onDelete ? (
|
||||
<IconButton title={labels.delete} onClick={onDelete}>
|
||||
<Trash2 size={12} />
|
||||
</IconButton>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
@@ -466,15 +506,16 @@ interface HistoryRowProps {
|
||||
entry: HistoryPanelEntry;
|
||||
isSelected: boolean;
|
||||
showHostLabel: boolean;
|
||||
labels: { paste: string; run: string; save: string };
|
||||
labels: { paste: string; run: string; save: string; delete: string };
|
||||
onSelect: () => void;
|
||||
onRun: () => void;
|
||||
onPaste: () => void;
|
||||
onSave: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
const HistoryRow: React.FC<HistoryRowProps> = memo(
|
||||
({ entry, isSelected, showHostLabel, labels, onSelect, onRun, onPaste, onSave }) => {
|
||||
({ entry, isSelected, showHostLabel, labels, onSelect, onRun, onPaste, onSave, onDelete }) => {
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
@@ -531,6 +572,11 @@ const HistoryRow: React.FC<HistoryRowProps> = memo(
|
||||
<IconButton title={labels.save} onClick={onSave}>
|
||||
<FileCode size={12} />
|
||||
</IconButton>
|
||||
{onDelete ? (
|
||||
<IconButton title={labels.delete} onClick={onDelete}>
|
||||
<Trash2 size={12} />
|
||||
</IconButton>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+106
-30
@@ -9,7 +9,7 @@ import {
|
||||
Tag,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import React, { useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useApplicationBackend, type SshAgentStatus } from "../application/state/useApplicationBackend";
|
||||
import { applyGroupDefaults, resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/groupConfig";
|
||||
@@ -70,9 +70,7 @@ import {
|
||||
} from "./host-details";
|
||||
import { HostNotesEditor } from "./host/HostNotesEditor";
|
||||
import { HostDetailsScriptsSection } from "./host/HostDetailsScriptsSection";
|
||||
import { ensureHostConnectScriptIds, getHostConnectScriptIds, prepareSnippetForHostConnectQueue } from "@/domain/hostConnectScripts.ts";
|
||||
import { isScriptSnippet } from "@/domain/snippetScript.ts";
|
||||
import { unlinkHostFromScripts } from "@/domain/snippetTargets.ts";
|
||||
import { ensureHostConnectScriptIds, getEditableHostConnectScriptIds, syncSnippetsForHostConnectQueueSave } from "@/domain/hostConnectScripts.ts";
|
||||
import {
|
||||
isPluginHostProtocol,
|
||||
sanitizePluginConnection,
|
||||
@@ -112,6 +110,7 @@ interface HostDetailsPanelProps {
|
||||
onImportKey?: (draft: Partial<SSHKey>) => SSHKey;
|
||||
snippets?: Snippet[];
|
||||
onSnippetsChange?: (snippets: Snippet[]) => void;
|
||||
onHostsChange?: (hosts: Host[] | ((prev: Host[]) => Host[])) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -139,6 +138,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
onImportKey,
|
||||
snippets = [],
|
||||
onSnippetsChange,
|
||||
onHostsChange,
|
||||
className,
|
||||
resizable,
|
||||
persistWidthStorageKey,
|
||||
@@ -151,6 +151,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
resizeAriaLabel,
|
||||
};
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
const baselineSnippetsRef = useRef(snippets);
|
||||
const baselineHydratedRef = useRef(snippets.length > 0);
|
||||
const openingQueueIdsRef = useRef<string[]>([]);
|
||||
const [form, setForm] = useState<Host>(
|
||||
() =>
|
||||
(initialData ? normalizePrimaryTelnetState(initialData) : null) ||
|
||||
@@ -238,6 +241,36 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
|
||||
const initialHostId = initialData?.id;
|
||||
|
||||
useEffect(() => {
|
||||
baselineSnippetsRef.current = snippets;
|
||||
baselineHydratedRef.current = snippets.length > 0;
|
||||
if (!initialData) {
|
||||
openingQueueIdsRef.current = [];
|
||||
} else {
|
||||
const normalized = normalizePrimaryTelnetState(initialData);
|
||||
const ensured = snippets.length > 0
|
||||
? ensureHostConnectScriptIds(normalized, snippets)
|
||||
: normalized;
|
||||
openingQueueIdsRef.current = getEditableHostConnectScriptIds(ensured, snippets);
|
||||
}
|
||||
// Snapshot only when opening/reopening a host editor, not on live snippet edits.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialHostId]);
|
||||
|
||||
useEffect(() => {
|
||||
// If the editor opened before snippets hydrated, capture the first non-empty
|
||||
// catalog as baseline so concurrent-edit guards still have a reference.
|
||||
if (baselineHydratedRef.current) return;
|
||||
if (snippets.length === 0) return;
|
||||
baselineSnippetsRef.current = snippets;
|
||||
baselineHydratedRef.current = true;
|
||||
if (initialData) {
|
||||
const normalized = normalizePrimaryTelnetState(initialData);
|
||||
const ensured = ensureHostConnectScriptIds(normalized, snippets);
|
||||
openingQueueIdsRef.current = getEditableHostConnectScriptIds(ensured, snippets);
|
||||
}
|
||||
}, [initialData, snippets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialData) return;
|
||||
const normalized = normalizePrimaryTelnetState(initialData);
|
||||
@@ -593,37 +626,80 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
if ((cleaned.protocol && cleaned.protocol !== "ssh") || cleaned.moshEnabled || cleaned.etEnabled) {
|
||||
delete cleaned.x11Forwarding;
|
||||
}
|
||||
if (onSnippetsChange && initialData) {
|
||||
if (onSnippetsChange || onHostsChange) {
|
||||
const hostId = cleaned.id;
|
||||
const savedQueueIds = initialData.connectScriptIds ?? getHostConnectScriptIds(initialData, snippets);
|
||||
const finalQueueIds = cleaned.connectScriptIds ?? getHostConnectScriptIds(cleaned, snippets);
|
||||
const savedSet = new Set(savedQueueIds);
|
||||
const finalSet = new Set(finalQueueIds);
|
||||
let nextSnippets = snippets;
|
||||
let changed = false;
|
||||
|
||||
for (const scriptId of finalQueueIds) {
|
||||
if (!savedSet.has(scriptId)) {
|
||||
nextSnippets = nextSnippets.map((item) => (
|
||||
item.id === scriptId && isScriptSnippet(item)
|
||||
? prepareSnippetForHostConnectQueue(item, hostId)
|
||||
: item
|
||||
));
|
||||
changed = true;
|
||||
if (snippets.length === 0 && !baselineHydratedRef.current) {
|
||||
// Vault may publish hosts before snippets hydrate. Do not wipe unresolved
|
||||
// connectScriptIds while the script catalog is still incomplete.
|
||||
} else {
|
||||
const savedQueueIds = openingQueueIdsRef.current;
|
||||
const finalQueueIds = getEditableHostConnectScriptIds(cleaned, snippets);
|
||||
const synced = syncSnippetsForHostConnectQueueSave(
|
||||
snippets,
|
||||
hostId,
|
||||
savedQueueIds,
|
||||
finalQueueIds,
|
||||
{
|
||||
baselineSnippets: baselineSnippetsRef.current,
|
||||
hosts: allHosts,
|
||||
},
|
||||
);
|
||||
cleaned = {
|
||||
...cleaned,
|
||||
connectScriptIds: synced.connectScriptIds.length > 0
|
||||
? synced.connectScriptIds
|
||||
: undefined,
|
||||
// sanitizeHost reconstitutes connectScriptIds from loginScriptId when
|
||||
// the explicit queue is absent; clear the legacy field when emptied.
|
||||
...(synced.connectScriptIds.length === 0
|
||||
? { loginScriptId: undefined }
|
||||
: {}),
|
||||
};
|
||||
if (synced.changed && onSnippetsChange) {
|
||||
onSnippetsChange(synced.snippets);
|
||||
}
|
||||
}
|
||||
for (const scriptId of savedQueueIds) {
|
||||
if (!finalSet.has(scriptId)) {
|
||||
const unlinked = unlinkHostFromScripts(nextSnippets, hostId, scriptId);
|
||||
if (unlinked !== nextSnippets) {
|
||||
nextSnippets = unlinked;
|
||||
changed = true;
|
||||
if (onHostsChange && synced.hosts.length > 0) {
|
||||
const peerChanged = synced.hosts.some((host) => {
|
||||
if (host.id === cleaned.id) return false;
|
||||
const original = allHosts.find((entry) => entry.id === host.id);
|
||||
return original !== undefined && original !== host;
|
||||
});
|
||||
if (peerChanged) {
|
||||
// Append only the script IDs this save newly queued onto each peer.
|
||||
// Leave the edited host alone so onSave can three-way merge it.
|
||||
onHostsChange((prevHosts) => {
|
||||
const peerAdds = new Map<string, string[]>();
|
||||
for (const syncedHost of synced.hosts) {
|
||||
if (syncedHost.id === cleaned.id) continue;
|
||||
const snapshot = allHosts.find((entry) => entry.id === syncedHost.id);
|
||||
if (!snapshot || snapshot === syncedHost) continue;
|
||||
const snapshotIds = snapshot.connectScriptIds ?? [];
|
||||
const addedIds = (syncedHost.connectScriptIds ?? []).filter(
|
||||
(id) => !snapshotIds.includes(id),
|
||||
);
|
||||
if (addedIds.length > 0) peerAdds.set(syncedHost.id, addedIds);
|
||||
}
|
||||
return prevHosts.map((host) => {
|
||||
if (host.id === cleaned.id) return host;
|
||||
const addedIds = peerAdds.get(host.id);
|
||||
if (!addedIds || addedIds.length === 0) return host;
|
||||
const currentIds = host.connectScriptIds ?? [];
|
||||
const mergedIds = [...currentIds];
|
||||
for (const id of addedIds) {
|
||||
if (!mergedIds.includes(id)) mergedIds.push(id);
|
||||
}
|
||||
if (
|
||||
mergedIds.length === currentIds.length
|
||||
&& mergedIds.every((id, index) => id === currentIds[index])
|
||||
) {
|
||||
return host;
|
||||
}
|
||||
return { ...host, connectScriptIds: mergedIds };
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
onSnippetsChange(nextSnippets);
|
||||
}
|
||||
}
|
||||
onSave(stripBuiltInConnectionFieldsForPluginHost(cleaned));
|
||||
};
|
||||
|
||||
@@ -19,6 +19,14 @@ test('keeps the new workspace action outside the scrollable results', () => {
|
||||
assert.match(source, /VariableSizeVirtualList/);
|
||||
});
|
||||
|
||||
test('host rows expose optional edit-host context menu', () => {
|
||||
const source = readFileSync(new URL('./QuickSwitcher.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /onEditHost\?: \(host: Host\) => void/);
|
||||
assert.match(source, /terminal\.layer\.hostTree\.editHost/);
|
||||
assert.match(source, /ContextMenuTrigger asChild/);
|
||||
assert.match(source, /#netcatty-context-menu-root/);
|
||||
});
|
||||
|
||||
test('pointer hover never rewrites the keyboard selection', () => {
|
||||
const quickSwitcherSource = readFileSync(new URL('./QuickSwitcher.tsx', import.meta.url), 'utf8');
|
||||
const workspacePickerSource = readFileSync(new URL('./workspace/AddToWorkspaceDialog.tsx', import.meta.url), 'utf8');
|
||||
|
||||
@@ -101,6 +101,7 @@ export function buildPluginPaletteItems(
|
||||
});
|
||||
}
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "./ui/context-menu";
|
||||
import { Input } from "./ui/input";
|
||||
import {
|
||||
VariableSizeVirtualList,
|
||||
@@ -137,6 +138,7 @@ interface QuickSwitcherProps {
|
||||
workspaces: Workspace[];
|
||||
onQueryChange: (value: string) => void;
|
||||
onSelect: (host: Host) => void;
|
||||
onEditHost?: (host: Host) => void;
|
||||
onSelectTab: (tabId: string) => void;
|
||||
onClose: () => void;
|
||||
onCreateLocalTerminal?: (shell?: { command: string; args?: string[]; name?: string; icon?: string }) => void;
|
||||
@@ -154,6 +156,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
workspaces,
|
||||
onQueryChange,
|
||||
onSelect,
|
||||
onEditHost,
|
||||
onSelectTab,
|
||||
onClose,
|
||||
onCreateLocalTerminal,
|
||||
@@ -227,12 +230,18 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (containerRef.current?.contains(target)) return;
|
||||
// ContextMenuContent portals to #netcatty-context-menu-root; treat those
|
||||
// clicks as inside so Connect / Edit host can run before unmount.
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
target instanceof Element &&
|
||||
target.closest("#netcatty-context-menu-root")
|
||||
) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
@@ -527,7 +536,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
|
||||
if (item.type === "host") {
|
||||
const host = item.data as Host;
|
||||
return (
|
||||
const hostRow = (
|
||||
<div
|
||||
className={`flex h-full min-h-0 items-center justify-between overflow-hidden px-4 py-2.5 cursor-pointer transition-colors ${getQuickSwitcherRowStateClass(isSelected, isKeyboardNavigating)}`}
|
||||
onClick={() => onSelect(host)}
|
||||
@@ -545,6 +554,20 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (!onEditHost) return hostRow;
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{hostRow}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => onSelect(host)}>
|
||||
{t('vault.hosts.connect')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onEditHost(host)}>
|
||||
{t('terminal.layer.hostTree.editHost')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "tab") {
|
||||
|
||||
@@ -10,7 +10,16 @@
|
||||
* Used in TerminalLayer to provide SFTP alongside terminal sessions.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
|
||||
import React, {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MutableRefObject,
|
||||
} from "react";
|
||||
import { SftpSidePanelDeferredMount } from "./SftpSidePanelDeferredMount";
|
||||
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from "./terminalLayer/terminalSidePanelChrome";
|
||||
import { formatHostPort } from "../domain/host";
|
||||
@@ -103,6 +112,8 @@ interface SftpSidePanelProps {
|
||||
onInitialLocationApplied?: (location: { hostId: string; path: string }) => void;
|
||||
onCurrentPathChange?: (location: { hostId: string; connectionKey: string; path: string }) => void;
|
||||
onActiveTransfersChange?: (count: number) => void;
|
||||
/** External-editor temps that must keep this owner mounted after panel close. */
|
||||
onActiveExternalEditsChange?: (count: number) => void;
|
||||
showWorkspaceHostHeader?: boolean;
|
||||
isVisible?: boolean;
|
||||
renderOverlays?: boolean;
|
||||
@@ -150,6 +161,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
onInitialLocationApplied,
|
||||
onCurrentPathChange,
|
||||
onActiveTransfersChange,
|
||||
onActiveExternalEditsChange,
|
||||
showWorkspaceHostHeader = false,
|
||||
isVisible = true,
|
||||
renderOverlays = true,
|
||||
@@ -215,6 +227,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
const ownedEditorSessionIdsRef = useRef<ReadonlySet<string>>(new Set());
|
||||
const ownedEditorSftpTabIdsRef = useRef<ReadonlySet<string>>(new Set());
|
||||
const activeExternalEditCountRef = useRef(0);
|
||||
// Re-render on tab open/close/session remap only — not on every editor keystroke.
|
||||
useEditorTabPresenceRevision();
|
||||
const hasOwnedEditorTab = editorTabStore.hasOwnedEditorForSftpOwner({
|
||||
@@ -230,9 +243,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
surfaceVisible: isVisible,
|
||||
// A promoted editor still saves through this owner after the side panel
|
||||
// becomes hidden, so its browse channel must stay alive until the editor closes.
|
||||
// External editor temps (Notepad++ etc.) likewise need the session: parking
|
||||
// calls closeSftp which deletes those local files.
|
||||
interactive: isBrowseSessionInteractive({
|
||||
surfaceVisible: isVisible,
|
||||
hasOwnedEditorTab,
|
||||
hasActiveExternalEdit: activeExternalEditCountRef.current > 0,
|
||||
}),
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
defaultShowHiddenFiles: sftpShowHiddenFiles,
|
||||
@@ -257,6 +273,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
]);
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
|
||||
activeExternalEditCountRef.current = sftp.activeExternalEditCount ?? 0;
|
||||
ownedEditorSessionIdsRef.current = new Set(
|
||||
listRemoteBrowseConnectionIds([
|
||||
...sftp.leftTabs.tabs,
|
||||
@@ -400,6 +417,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
onActiveTransfersChange,
|
||||
});
|
||||
|
||||
// Parent retain-on-close only sees transfers unless we also publish external
|
||||
// editor temp activity (closeSftp deletes those files on unmount).
|
||||
const onActiveExternalEditsChangeRef = useRef(onActiveExternalEditsChange);
|
||||
onActiveExternalEditsChangeRef.current = onActiveExternalEditsChange;
|
||||
useLayoutEffect(() => {
|
||||
onActiveExternalEditsChangeRef.current?.(sftp.activeExternalEditCount ?? 0);
|
||||
}, [sftp.activeExternalEditCount]);
|
||||
|
||||
// Register this instance's writeTextFileByConnection with the editor bridge
|
||||
// so editor tabs promoted from SFTP files opened in a terminal side panel
|
||||
// can still route saves through this useSftpState.
|
||||
@@ -474,7 +499,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
const s = sftpRef.current;
|
||||
const hasActiveWork = interactiveWorkActive
|
||||
|| (s.activeFileWatchCountRef?.current ?? 0) > 0;
|
||||
|| (s.activeFileWatchCountRef?.current ?? 0) > 0
|
||||
|| (s.activeExternalEditCount ?? 0) > 0;
|
||||
|
||||
const proto = activeHost.protocol;
|
||||
if (proto === 'serial' || activeHost.id?.startsWith('serial-')) {
|
||||
@@ -1159,7 +1185,8 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
}, [followTerminalCwdHost, onGetTerminalCwd]);
|
||||
|
||||
const hasActiveWork = showTextEditor || !!permissionsState || showFileOpenerDialog
|
||||
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0;
|
||||
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0
|
||||
|| (sftp.activeExternalEditCount ?? 0) > 0;
|
||||
|
||||
const blockedFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
|
||||
const handledFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
|
||||
@@ -1723,6 +1750,7 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
|
||||
prev.onRequestTerminalFocus === next.onRequestTerminalFocus &&
|
||||
prev.onCurrentPathChange === next.onCurrentPathChange &&
|
||||
prev.onActiveTransfersChange === next.onActiveTransfersChange &&
|
||||
prev.onActiveExternalEditsChange === next.onActiveExternalEditsChange &&
|
||||
prev.initialLocation?.hostId === next.initialLocation?.hostId &&
|
||||
prev.initialLocation?.path === next.initialLocation?.path &&
|
||||
// Only the keepalive fields of terminalSettings affect SFTP connection
|
||||
|
||||
@@ -2778,6 +2778,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
clearWipesScrollbackRef.current = terminalSettings?.clearWipesScrollback ?? true;
|
||||
const normalizeTextOnCopyRef = useRef(terminalSettings?.normalizeTextOnCopy ?? true);
|
||||
normalizeTextOnCopyRef.current = terminalSettings?.normalizeTextOnCopy ?? true;
|
||||
const autoUploadClipboardImageOnPasteRef = useRef(terminalSettings?.autoUploadClipboardImageOnPaste ?? false);
|
||||
autoUploadClipboardImageOnPasteRef.current = terminalSettings?.autoUploadClipboardImageOnPaste ?? false;
|
||||
|
||||
const scrollToBottomAfterProgrammaticInput = useCallback((data: string) => {
|
||||
if (!termRef.current) return;
|
||||
@@ -2943,6 +2945,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
passwordPromptActiveRef,
|
||||
isLocalConnection,
|
||||
supportsRemoteImagePaste,
|
||||
autoUploadClipboardImageOnPasteRef,
|
||||
terminalBackend,
|
||||
getRemoteCwd: () => resolveSftpInitialPath({ preferFreshBackend: true }),
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
@@ -3364,6 +3367,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onPasteData: broadcastUserPasteData,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
containerRef,
|
||||
autoUploadClipboardImage:
|
||||
supportsRemoteImagePaste && terminalSettings?.autoUploadClipboardImageOnPaste === true,
|
||||
getRemoteCwd: () => resolveSftpInitialPath({ preferFreshBackend: true }),
|
||||
onClipboardImageUploadResult: handleClipboardImageUploadResult,
|
||||
});
|
||||
|
||||
const handleToggleSessionLog = useCallback(async () => {
|
||||
|
||||
@@ -216,6 +216,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onUpdateHost,
|
||||
onAddKnownHost,
|
||||
onCommandExecuted,
|
||||
onDeleteShellHistoryEntry,
|
||||
onTerminalDataCapture,
|
||||
onCreateWorkspaceFromSessions,
|
||||
onAddSessionToWorkspace,
|
||||
@@ -578,6 +579,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const sftpHostForTabRef = useRef(sftpHostForTab);
|
||||
sftpHostForTabRef.current = sftpHostForTab;
|
||||
const sftpActiveTransfersByTabRef = useRef<Map<string, number>>(new Map());
|
||||
const sftpActiveExternalEditsByTabRef = useRef<Map<string, number>>(new Map());
|
||||
const sftpRetainedAfterCloseTabIdsRef = useRef<Set<string>>(new Set());
|
||||
const sftpOpeningTabIdsRef = useRef<Set<string>>(new Set());
|
||||
const sftpRetainedCleanupTimersRef = useRef<Map<string, number>>(new Map());
|
||||
@@ -612,6 +614,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sftpRetainedCleanupTimersRef.current.delete(tabId);
|
||||
}
|
||||
sftpActiveTransfersByTabRef.current.delete(tabId);
|
||||
sftpActiveExternalEditsByTabRef.current.delete(tabId);
|
||||
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
|
||||
sftpOpeningTabIdsRef.current.delete(tabId);
|
||||
setSftpHostForTab(prev => {
|
||||
@@ -644,20 +647,26 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSftpActiveTransfersChange = useCallback((tabId: string, count: number) => {
|
||||
const activeTransfersCount = resolveTabActiveTransfersCount(tabId, Math.max(0, count));
|
||||
if (activeTransfersCount > 0) {
|
||||
const resolveTabActiveExternalEditCount = useCallback((tabId: string, reportedCount?: number) => {
|
||||
return Math.max(0, reportedCount ?? sftpActiveExternalEditsByTabRef.current.get(tabId) ?? 0);
|
||||
}, []);
|
||||
|
||||
const evaluateSftpPanelRetentionAfterActivity = useCallback((tabId: string, params: {
|
||||
activeTransfersCount: number;
|
||||
activeExternalEditCount: number;
|
||||
}) => {
|
||||
const { activeTransfersCount, activeExternalEditCount } = params;
|
||||
if (activeTransfersCount > 0 || activeExternalEditCount > 0) {
|
||||
const cleanupTimer = sftpRetainedCleanupTimersRef.current.get(tabId);
|
||||
if (cleanupTimer !== undefined) {
|
||||
window.clearTimeout(cleanupTimer);
|
||||
sftpRetainedCleanupTimersRef.current.delete(tabId);
|
||||
}
|
||||
sftpActiveTransfersByTabRef.current.set(tabId, activeTransfersCount);
|
||||
return;
|
||||
}
|
||||
sftpActiveTransfersByTabRef.current.delete(tabId);
|
||||
const lifecycle = {
|
||||
activeTransfersCount,
|
||||
activeExternalEditCount,
|
||||
panelOpen: sidePanelOpenTabsRef.current.has(tabId) || sftpOpeningTabIdsRef.current.has(tabId),
|
||||
retainedAfterClose: sftpRetainedAfterCloseTabIdsRef.current.has(tabId),
|
||||
};
|
||||
@@ -668,15 +677,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
if (
|
||||
shouldScheduleSftpRetainedPanelCleanup({
|
||||
activeTransfersCount: lifecycle.activeTransfersCount,
|
||||
activeExternalEditCount: lifecycle.activeExternalEditCount,
|
||||
retainedAfterClose: lifecycle.retainedAfterClose,
|
||||
})
|
||||
&& !sftpRetainedCleanupTimersRef.current.has(tabId)
|
||||
) {
|
||||
const cleanupTimer = window.setTimeout(() => {
|
||||
sftpRetainedCleanupTimersRef.current.delete(tabId);
|
||||
const currentCount = resolveTabActiveTransfersCount(tabId);
|
||||
const currentTransfers = resolveTabActiveTransfersCount(tabId);
|
||||
const currentExternalEdits = resolveTabActiveExternalEditCount(tabId);
|
||||
if (
|
||||
currentCount <= 0
|
||||
currentTransfers <= 0
|
||||
&& currentExternalEdits <= 0
|
||||
&& !sidePanelOpenTabsRef.current.has(tabId)
|
||||
&& !sftpOpeningTabIdsRef.current.has(tabId)
|
||||
&& sftpRetainedAfterCloseTabIdsRef.current.has(tabId)
|
||||
@@ -686,7 +698,46 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}, SFTP_TRANSFER_HISTORY_RETENTION_MS);
|
||||
sftpRetainedCleanupTimersRef.current.set(tabId, cleanupTimer);
|
||||
}
|
||||
}, [clearSftpPanelState, resolveTabActiveTransfersCount, sidePanelOpenTabsRef]);
|
||||
}, [
|
||||
clearSftpPanelState,
|
||||
resolveTabActiveExternalEditCount,
|
||||
resolveTabActiveTransfersCount,
|
||||
sidePanelOpenTabsRef,
|
||||
]);
|
||||
|
||||
const handleSftpActiveTransfersChange = useCallback((tabId: string, count: number) => {
|
||||
const activeTransfersCount = resolveTabActiveTransfersCount(tabId, Math.max(0, count));
|
||||
if (activeTransfersCount > 0) {
|
||||
sftpActiveTransfersByTabRef.current.set(tabId, activeTransfersCount);
|
||||
} else {
|
||||
sftpActiveTransfersByTabRef.current.delete(tabId);
|
||||
}
|
||||
evaluateSftpPanelRetentionAfterActivity(tabId, {
|
||||
activeTransfersCount,
|
||||
activeExternalEditCount: resolveTabActiveExternalEditCount(tabId),
|
||||
});
|
||||
}, [
|
||||
evaluateSftpPanelRetentionAfterActivity,
|
||||
resolveTabActiveExternalEditCount,
|
||||
resolveTabActiveTransfersCount,
|
||||
]);
|
||||
|
||||
const handleSftpActiveExternalEditsChange = useCallback((tabId: string, count: number) => {
|
||||
const activeExternalEditCount = resolveTabActiveExternalEditCount(tabId, Math.max(0, count));
|
||||
if (activeExternalEditCount > 0) {
|
||||
sftpActiveExternalEditsByTabRef.current.set(tabId, activeExternalEditCount);
|
||||
} else {
|
||||
sftpActiveExternalEditsByTabRef.current.delete(tabId);
|
||||
}
|
||||
evaluateSftpPanelRetentionAfterActivity(tabId, {
|
||||
activeTransfersCount: resolveTabActiveTransfersCount(tabId),
|
||||
activeExternalEditCount,
|
||||
});
|
||||
}, [
|
||||
evaluateSftpPanelRetentionAfterActivity,
|
||||
resolveTabActiveExternalEditCount,
|
||||
resolveTabActiveTransfersCount,
|
||||
]);
|
||||
|
||||
const closeTerminalSidePanelTab = useCallback((tabId: string) => {
|
||||
setSidePanelOpenTabs((previous) => {
|
||||
@@ -696,9 +747,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
});
|
||||
sftpOpeningTabIdsRef.current.delete(tabId);
|
||||
const activeTransfersCount = resolveTabActiveTransfersCount(tabId);
|
||||
if (shouldKeepSftpMountedAfterClose(activeTransfersCount)) {
|
||||
const activeExternalEditCount = resolveTabActiveExternalEditCount(tabId);
|
||||
if (shouldKeepSftpMountedAfterClose({ activeTransfersCount, activeExternalEditCount })) {
|
||||
sftpRetainedAfterCloseTabIdsRef.current.add(tabId);
|
||||
sftpActiveTransfersByTabRef.current.set(tabId, activeTransfersCount);
|
||||
if (activeTransfersCount > 0) {
|
||||
sftpActiveTransfersByTabRef.current.set(tabId, activeTransfersCount);
|
||||
}
|
||||
if (activeExternalEditCount > 0) {
|
||||
sftpActiveExternalEditsByTabRef.current.set(tabId, activeExternalEditCount);
|
||||
}
|
||||
} else {
|
||||
clearSftpPanelState(tabId);
|
||||
}
|
||||
@@ -713,7 +770,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return next;
|
||||
});
|
||||
notesReturnTabRef.current.delete(tabId);
|
||||
}, [clearSftpPanelState, resolveTabActiveTransfersCount, setSidePanelOpenTabs]);
|
||||
}, [
|
||||
clearSftpPanelState,
|
||||
resolveTabActiveExternalEditCount,
|
||||
resolveTabActiveTransfersCount,
|
||||
setSidePanelOpenTabs,
|
||||
]);
|
||||
|
||||
const handleToggleWorkspaceComposeBar = useCallback(() => {
|
||||
setIsComposeBarOpen(prev => !prev);
|
||||
@@ -1099,8 +1161,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const storeActiveTransferTabIds = listTerminalTabIdsWithRetainingTransfers(
|
||||
sftpTransferCenterStore.getSnapshot().tasks,
|
||||
);
|
||||
// Keep owners with unfinished transfers or external-editor temps mounted
|
||||
// even after the terminal tab id drops out of the valid set.
|
||||
const activeTransferTabIds = new Set([
|
||||
...sftpActiveTransfersByTabRef.current.keys(),
|
||||
...sftpActiveExternalEditsByTabRef.current.keys(),
|
||||
...storeActiveTransferTabIds,
|
||||
]);
|
||||
const invalidTabIds = listInvalidSftpPanelTabIds({
|
||||
@@ -1943,6 +2008,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleCloseSession,
|
||||
handleCloseSidePanel,
|
||||
handleCommandExecuted,
|
||||
handleHistoryDelete: onDeleteShellHistoryEntry,
|
||||
handleCommandSubmitted,
|
||||
handleComposeSend,
|
||||
handleHistoryPaste,
|
||||
@@ -1966,6 +2032,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleSessionExit,
|
||||
handleSftpCurrentPathChange,
|
||||
handleSftpActiveTransfersChange,
|
||||
handleSftpActiveExternalEditsChange,
|
||||
handleSftpInitialLocationApplied,
|
||||
persistSidePanelWidth,
|
||||
handleSnippetClickForFocusedSession,
|
||||
|
||||
@@ -131,6 +131,7 @@ interface TopTabsProps {
|
||||
onRenameSession: (sessionId: string) => void;
|
||||
onCopySession: (sessionId: string) => void;
|
||||
onCopySessionToNewWindow: (sessionId: string) => void;
|
||||
onEditHost?: (host: Host) => void;
|
||||
onRenameWorkspace: (workspaceId: string) => void;
|
||||
onCopyWorkspace: (workspaceId: string) => void;
|
||||
onCloseWorkspace: (workspaceId: string) => void;
|
||||
@@ -177,6 +178,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onRenameSession,
|
||||
onCopySession,
|
||||
onCopySessionToNewWindow,
|
||||
onEditHost,
|
||||
onRenameWorkspace,
|
||||
onCopyWorkspace,
|
||||
onCloseWorkspace,
|
||||
@@ -804,6 +806,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onRenameSession={onRenameSession}
|
||||
onCopySession={onCopySession}
|
||||
onCopySessionToNewWindow={onCopySessionToNewWindow}
|
||||
onEditHost={onEditHost}
|
||||
renderBulkCloseItems={renderBulkCloseItems}
|
||||
dynamicTabTitleMode={dynamicTabTitleMode}
|
||||
t={t}
|
||||
@@ -1170,6 +1173,7 @@ export const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean
|
||||
prev.isMacClient === next.isMacClient &&
|
||||
prev.onCopySession === next.onCopySession &&
|
||||
prev.onCopySessionToNewWindow === next.onCopySessionToNewWindow &&
|
||||
prev.onEditHost === next.onEditHost &&
|
||||
prev.onCopyWorkspace === next.onCopyWorkspace &&
|
||||
prev.onOpenSettings === next.onOpenSettings &&
|
||||
prev.externalMcpEnabled === next.externalMcpEnabled &&
|
||||
|
||||
@@ -181,11 +181,12 @@ export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
|
||||
? stored as { kind: 'secret'; id: string; key: string }
|
||||
: undefined;
|
||||
if (credentialPlan.secrets.length > 0) {
|
||||
const { putPluginSyncSecret, deletePluginSyncSecrets } = await import(
|
||||
const { putPluginSyncSecret, deletePluginSyncSecrets, restorePluginSyncSecrets } = await import(
|
||||
'../../infrastructure/services/adapters/pluginSyncIpcHost'
|
||||
);
|
||||
// Put secrets before connect; roll back just-created keys if connect fails
|
||||
// so a rejected password/token is not left readable in plugin_secrets.
|
||||
// Overwrites restore the previous plaintext from the host stash.
|
||||
await storePluginSyncSecretsThenConnect({
|
||||
providerId,
|
||||
secrets: credentialPlan.secrets.map((secret) => ({
|
||||
@@ -194,6 +195,7 @@ export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
|
||||
})),
|
||||
putSecret: putPluginSyncSecret,
|
||||
deleteSecrets: deletePluginSyncSecrets,
|
||||
restoreSecrets: restorePluginSyncSecrets,
|
||||
connect: async (credential) => {
|
||||
await sync.connectPluginProvider(
|
||||
providerId,
|
||||
|
||||
@@ -38,6 +38,7 @@ test("delete confirmation dialogs constrain long names", () => {
|
||||
const confirmDialogSource = readProjectFile("components/ui/confirm-dialog.tsx");
|
||||
const vaultDeleteDialogSource = readProjectFile("components/vault/VaultDeleteConfirmDialog.tsx");
|
||||
const sftpDialogSource = readProjectFile("components/sftp/SftpPaneDialogs.tsx");
|
||||
const sftpClipboardUploadDialogSource = readProjectFile("components/sftp/SftpClipboardUploadDialog.tsx");
|
||||
const hostTreeGroupDeleteSource = readProjectFile("components/host/HostTreeGroupDeleteDialog.tsx");
|
||||
const vaultViewLayoutSource = readProjectFile("components/vault/VaultViewLayout.tsx");
|
||||
|
||||
@@ -51,6 +52,13 @@ test("delete confirmation dialogs constrain long names", () => {
|
||||
assert.match(sftpDialogSource, /<span className="min-w-0 truncate">\{name\}<\/span>/);
|
||||
assert.match(sftpDialogSource, /overflow-hidden sm:max-w-sm/);
|
||||
|
||||
assert.match(sftpClipboardUploadDialogSource, /overflow-hidden sm:max-w-md/);
|
||||
assert.match(sftpClipboardUploadDialogSource, /<span className="min-w-0 truncate" title=\{file\.name\}>/);
|
||||
assert.match(
|
||||
sftpClipboardUploadDialogSource,
|
||||
/min-w-0 rounded-md border border-border\/60 bg-muted\/30 px-3 py-2 text-sm font-mono break-all \[overflow-wrap:anywhere\]/,
|
||||
);
|
||||
|
||||
assert.match(hostTreeGroupDeleteSource, /overflow-hidden sm:max-w-lg/);
|
||||
assert.match(hostTreeGroupDeleteSource, /break-words text-sm text-muted-foreground \[overflow-wrap:anywhere\]/);
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import React, { useCallback, useMemo, useState, type DragEvent } from 'react';
|
||||
import type { Host, Snippet } from '@/domain/models';
|
||||
import {
|
||||
appendHostConnectScript,
|
||||
getEditableHostConnectScriptIds,
|
||||
getGlobalConnectScripts,
|
||||
getHostConnectScriptIds,
|
||||
removeHostConnectScript,
|
||||
reorderHostConnectScript,
|
||||
} from '@/domain/hostConnectScripts.ts';
|
||||
@@ -60,7 +60,7 @@ export const HostDetailsScriptsSection: React.FC<HostDetailsScriptsSectionProps>
|
||||
[snippets],
|
||||
);
|
||||
const queueIds = useMemo(
|
||||
() => getHostConnectScriptIds(host, snippets),
|
||||
() => getEditableHostConnectScriptIds(host, snippets),
|
||||
[host, snippets],
|
||||
);
|
||||
const queuedScripts = useMemo(
|
||||
@@ -71,15 +71,16 @@ export const HostDetailsScriptsSection: React.FC<HostDetailsScriptsSectionProps>
|
||||
);
|
||||
const linkableScripts = useMemo(
|
||||
() => scripts.filter((script) => {
|
||||
if (!script.id) return false;
|
||||
if (script.targetsAllHosts) return false;
|
||||
if (script.id && queueIds.includes(script.id)) return false;
|
||||
if (queueIds.includes(script.id)) return false;
|
||||
return true;
|
||||
}),
|
||||
[queueIds, scripts],
|
||||
);
|
||||
const linkOptions = useMemo(
|
||||
() => linkableScripts.map((script) => ({
|
||||
value: script.id,
|
||||
value: script.id!,
|
||||
label: script.label || t('scripts.running.unnamed'),
|
||||
})),
|
||||
[linkableScripts, t],
|
||||
|
||||
@@ -35,3 +35,9 @@ test("terminal settings expose the host information bar toggle", () => {
|
||||
assert.match(source, /checked=\{terminalSettings\.showHostInfoBar\}/);
|
||||
assert.match(source, /updateTerminalSetting\("showHostInfoBar", v\)/);
|
||||
});
|
||||
|
||||
test("terminal settings expose host information title mode when the bar is shown", () => {
|
||||
assert.match(source, /terminalSettings\.showHostInfoBar && \(/);
|
||||
assert.match(source, /updateTerminalSetting\("hostInfoBarTitleMode"/);
|
||||
assert.match(source, /settings\.terminal\.hostInfoBar\.titleMode/);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { AlertCircle, Import, Minus, Palette, Pencil, Plus, Trash2 } from "lucid
|
||||
import type {
|
||||
AutocompleteHistoryScope,
|
||||
CursorShape,
|
||||
HostInfoBarTitleMode,
|
||||
PasswordPromptAssistMode,
|
||||
TerminalEmulationType,
|
||||
TerminalSettings,
|
||||
@@ -924,6 +925,22 @@ function SettingsTerminalTab(props: {
|
||||
onChange={(v) => updateTerminalSetting("showHostInfoBar", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
{terminalSettings.showHostInfoBar && (
|
||||
<SettingRow
|
||||
label={t("settings.terminal.hostInfoBar.titleMode")}
|
||||
description={t("settings.terminal.hostInfoBar.titleMode.desc")}
|
||||
>
|
||||
<Select
|
||||
value={terminalSettings.hostInfoBarTitleMode ?? "address"}
|
||||
options={[
|
||||
{ value: "address", label: t("settings.terminal.hostInfoBar.titleMode.address") },
|
||||
{ value: "label", label: t("settings.terminal.hostInfoBar.titleMode.label") },
|
||||
]}
|
||||
onChange={(v) => updateTerminalSetting("hostInfoBarTitleMode", v as HostInfoBarTitleMode)}
|
||||
className="w-44"
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
<SettingRow
|
||||
label={t("settings.terminal.serverStats.show")}
|
||||
description={t("settings.terminal.serverStats.show.desc")}
|
||||
|
||||
@@ -127,6 +127,16 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
|
||||
<Toggle checked={!terminalSettings.disableBracketedPaste} onChange={(v) => updateTerminalSetting("disableBracketedPaste", !v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.autoUploadClipboardImage")}
|
||||
description={t("settings.terminal.behavior.autoUploadClipboardImage.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.autoUploadClipboardImageOnPaste ?? false}
|
||||
onChange={(v) => updateTerminalSetting("autoUploadClipboardImageOnPaste", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.shiftEnterNewline")}
|
||||
description={t("settings.terminal.behavior.shiftEnterNewline.desc")}
|
||||
|
||||
@@ -64,23 +64,28 @@ export const SftpClipboardUploadDialog: React.FC<SftpClipboardUploadDialogProps>
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogContent className="max-w-[calc(100vw-2rem)] overflow-hidden sm:max-w-md">
|
||||
<DialogHeader className="min-w-0 pr-6">
|
||||
<DialogTitle>Upload clipboard files?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload {fileCount} item{fileCount === 1 ? "" : "s"} to:
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-sm font-mono break-all">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="min-w-0 rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-sm font-mono break-all [overflow-wrap:anywhere]">
|
||||
{request?.targetPath ?? currentPath}
|
||||
</div>
|
||||
{previewFiles.length > 0 && (
|
||||
<div className="max-h-40 overflow-auto rounded-md border border-border/60">
|
||||
<div className="max-h-40 min-w-0 overflow-auto rounded-md border border-border/60">
|
||||
{previewFiles.map((file) => (
|
||||
<div key={file.path} className="px-3 py-2 text-sm border-b border-border/40 last:border-b-0 truncate">
|
||||
{file.name}
|
||||
<div
|
||||
key={file.path}
|
||||
className="flex min-w-0 items-center border-b border-border/40 px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<span className="min-w-0 truncate" title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
|
||||
@@ -3,6 +3,8 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
formatTerminalHostInfoBarTitle,
|
||||
formatTerminalHostInfoBarTooltip,
|
||||
formatTerminalTitleConnectionAddress,
|
||||
getLineTimestampToggleHostUpdate,
|
||||
resolveNetworkDeviceTipRightInset,
|
||||
@@ -161,6 +163,56 @@ test("terminal title formats the connection address for remote sessions", () =>
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("host info bar title follows address or label mode", () => {
|
||||
assert.equal(
|
||||
formatTerminalHostInfoBarTitle({
|
||||
serverName: "prod-web",
|
||||
connectionAddress: "root@10.1.2.34:2222",
|
||||
mode: "address",
|
||||
}),
|
||||
"root@10.1.2.34:2222",
|
||||
);
|
||||
assert.equal(
|
||||
formatTerminalHostInfoBarTitle({
|
||||
serverName: "prod-web",
|
||||
connectionAddress: "root@10.1.2.34:2222",
|
||||
mode: "label",
|
||||
}),
|
||||
"prod-web",
|
||||
);
|
||||
assert.equal(
|
||||
formatTerminalHostInfoBarTitle({
|
||||
serverName: " prod-web ",
|
||||
connectionAddress: " root@10.1.2.34:2222 ",
|
||||
mode: "label",
|
||||
}),
|
||||
"prod-web",
|
||||
);
|
||||
assert.equal(
|
||||
formatTerminalHostInfoBarTitle({
|
||||
serverName: "",
|
||||
connectionAddress: "root@10.1.2.34:2222",
|
||||
mode: "label",
|
||||
}),
|
||||
"root@10.1.2.34:2222",
|
||||
);
|
||||
assert.equal(
|
||||
formatTerminalHostInfoBarTitle({
|
||||
serverName: "Local Terminal",
|
||||
connectionAddress: null,
|
||||
mode: "address",
|
||||
}),
|
||||
"Local Terminal",
|
||||
);
|
||||
assert.equal(
|
||||
formatTerminalHostInfoBarTooltip({
|
||||
serverName: "prod-web",
|
||||
connectionAddress: "root@10.1.2.34:2222",
|
||||
}),
|
||||
"prod-web · root@10.1.2.34:2222",
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal title row does not render a status dot beside the address", () => {
|
||||
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
|
||||
const titleStart = source.indexOf("data-terminal-detach-drag-handle");
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ChevronsLeft, GripVertical, Network, X as XIcon } from 'lucide-react';
|
||||
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { classifyDistroId } from '../../domain/host';
|
||||
import type { HostInfoBarTitleMode } from '../../domain/models';
|
||||
import { useNetworkDeviceModeSuggestion } from '../../application/state/useNetworkDeviceModeSuggestion';
|
||||
import { isPluginHostProtocol } from '../../domain/pluginConnection';
|
||||
import { OSC7_SETUP_TARGETS } from './osc7Setup';
|
||||
@@ -167,6 +168,40 @@ export function formatTerminalTitleConnectionAddress(host?: TerminalTitleAddress
|
||||
return `${username}${host.hostname}${port}`;
|
||||
}
|
||||
|
||||
/** Host info bar label: vault name or user@host, based on settings. */
|
||||
export function formatTerminalHostInfoBarTitle({
|
||||
serverName,
|
||||
connectionAddress,
|
||||
mode = "address",
|
||||
}: {
|
||||
serverName?: string | null;
|
||||
connectionAddress?: string | null;
|
||||
mode?: HostInfoBarTitleMode;
|
||||
}): string {
|
||||
const name = (serverName || "").trim();
|
||||
const address = (connectionAddress || "").trim();
|
||||
if (mode === "label") {
|
||||
return name || address;
|
||||
}
|
||||
return address || name;
|
||||
}
|
||||
|
||||
/** Hover tooltip can show both name and address without consuming bar width. */
|
||||
export function formatTerminalHostInfoBarTooltip({
|
||||
serverName,
|
||||
connectionAddress,
|
||||
}: {
|
||||
serverName?: string | null;
|
||||
connectionAddress?: string | null;
|
||||
}): string {
|
||||
const name = (serverName || "").trim();
|
||||
const address = (connectionAddress || "").trim();
|
||||
if (name && address && name !== address) {
|
||||
return `${name} · ${address}`;
|
||||
}
|
||||
return name || address;
|
||||
}
|
||||
|
||||
/** Height (px) of the one-line "enable Network Device Mode" tip strip. */
|
||||
export const NETWORK_DEVICE_TIP_HEIGHT = 28;
|
||||
|
||||
@@ -365,6 +400,18 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||
});
|
||||
}, [host.id, onUpdateHost, showLineTimestampGutter]);
|
||||
const titleConnectionAddress = formatTerminalTitleConnectionAddress(host);
|
||||
// Prefer vault host.label over sessionDisplayName so dynamic tab titles
|
||||
// (cwd / coding-cli) do not replace the stable server name in this bar.
|
||||
const hostInfoBarServerName = host.label || sessionDisplayName;
|
||||
const hostInfoBarTitle = formatTerminalHostInfoBarTitle({
|
||||
serverName: hostInfoBarServerName,
|
||||
connectionAddress: titleConnectionAddress,
|
||||
mode: terminalSettings?.hostInfoBarTitleMode ?? "address",
|
||||
});
|
||||
const hostInfoBarTooltip = formatTerminalHostInfoBarTooltip({
|
||||
serverName: hostInfoBarServerName,
|
||||
connectionAddress: titleConnectionAddress,
|
||||
});
|
||||
const hasBlockingReconnectOverlay = Boolean(osc52ReadPromptVisible || osc7SetupOpen || scriptExecutionOverlay || zmodem.active || zmodem.overwriteRequest);
|
||||
const showEnterReconnectHint = shouldReconnectTerminalOnEnterKey({
|
||||
key: "Enter",
|
||||
@@ -530,8 +577,8 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||
data-terminal-detach-drag-handle={inWorkspace && onDetachPointerDown ? "true" : undefined}
|
||||
onPointerDown={onDetachPointerDown}
|
||||
>
|
||||
<span className="whitespace-nowrap truncate min-w-0 max-w-[18rem]" title={titleConnectionAddress || sessionDisplayName || host.label}>
|
||||
{titleConnectionAddress || sessionDisplayName || host.label}
|
||||
<span className="whitespace-nowrap truncate min-w-0 max-w-[18rem]" title={hostInfoBarTooltip || hostInfoBarTitle}>
|
||||
{hostInfoBarTitle}
|
||||
</span>
|
||||
</div>}
|
||||
{host.protocol !== "local" && host.hostname && host.hostname !== "localhost" && (
|
||||
|
||||
@@ -137,6 +137,25 @@ export function recordCommand(
|
||||
saveStore(store);
|
||||
}
|
||||
|
||||
/** Remove one command from autocomplete history for a specific host. */
|
||||
export function removeCommandHistoryEntry(command: string, hostId: string): boolean {
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
const store = loadStore();
|
||||
const nextEntries = store.entries.filter(
|
||||
(entry) => entry.command !== trimmed || entry.hostId !== hostId,
|
||||
);
|
||||
if (nextEntries.length === store.entries.length) return false;
|
||||
|
||||
store.entries = nextEntries;
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
return persistStoreNow(store);
|
||||
}
|
||||
|
||||
/**
|
||||
* Score an entry for ranking at a specific timestamp.
|
||||
* Caches Date.now() at query boundaries to avoid repeated syscalls during sort.
|
||||
|
||||
@@ -2,5 +2,5 @@ export { useTerminalAutocomplete, DEFAULT_AUTOCOMPLETE_SETTINGS } from "./useTer
|
||||
export type { AutocompleteSettings, AutocompleteState, TerminalAutocompleteHandle } from "./useTerminalAutocomplete";
|
||||
export { default as AutocompletePopup } from "./AutocompletePopup";
|
||||
export type { CompletionSuggestion, SuggestionSource } from "./completionEngine";
|
||||
export { recordCommand, clearHistory } from "./commandHistoryStore";
|
||||
export { recordCommand, removeCommandHistoryEntry, clearHistory } from "./commandHistoryStore";
|
||||
export { shellEscape } from "./completionEngine";
|
||||
@@ -239,6 +239,27 @@ test("remote clipboard image upload reports transfer failures without inserting
|
||||
assert.deepEqual(result, { ok: false, reason: "upload-failed" });
|
||||
});
|
||||
|
||||
test("remote clipboard image upload treats clipboard read failures as no image", async () => {
|
||||
const result = await handleRemoteClipboardImageUpload({
|
||||
bridge: {
|
||||
readClipboardImage: async () => {
|
||||
throw new Error("clipboard unavailable");
|
||||
},
|
||||
openSftpForSession: async () => {
|
||||
assert.fail("should not open SFTP without an image");
|
||||
},
|
||||
startStreamTransfer: async (options) => ({ transferId: options.transferId }),
|
||||
},
|
||||
getRemoteCwd: async () => "/home/alice",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("should not paste without an image"),
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { ok: false, reason: "no-image" });
|
||||
});
|
||||
|
||||
test("remote clipboard image upload result maps to user-facing message keys", () => {
|
||||
assert.equal(
|
||||
getRemoteClipboardImageUploadErrorMessageKey({
|
||||
|
||||
@@ -7,7 +7,7 @@ type ClipboardImageFile = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
type RemoteClipboardImageBridge = Pick<
|
||||
export type RemoteClipboardImageBridge = Pick<
|
||||
NetcattyBridge,
|
||||
"readClipboardImage" | "openSftpForSession" | "startStreamTransfer"
|
||||
> & Pick<Partial<NetcattyBridge>, "closeSftp" | "deleteTempFile">;
|
||||
@@ -94,7 +94,14 @@ export async function handleRemoteClipboardImageUpload({
|
||||
return { ok: false, reason: "unsupported" };
|
||||
}
|
||||
|
||||
const image: ClipboardImageFile | null = await bridge.readClipboardImage();
|
||||
let image: ClipboardImageFile | null;
|
||||
try {
|
||||
image = await bridge.readClipboardImage();
|
||||
} catch {
|
||||
// A clipboard read failure is indistinguishable from an empty clipboard —
|
||||
// treat it as "no image" so callers can fall back to a normal paste.
|
||||
return { ok: false, reason: "no-image" };
|
||||
}
|
||||
if (!image?.path || !image.name) return { ok: false, reason: "no-image" };
|
||||
|
||||
let sftpId: string | undefined;
|
||||
|
||||
@@ -102,7 +102,11 @@ Object.defineProperty(globalThis, "window", {
|
||||
});
|
||||
|
||||
const { getCompletions } = await import("./autocomplete/completionEngine.ts");
|
||||
const { clearHistory, recordCommand } = await import("./autocomplete/commandHistoryStore.ts");
|
||||
const {
|
||||
clearHistory,
|
||||
recordCommand,
|
||||
removeCommandHistoryEntry,
|
||||
} = await import("./autocomplete/commandHistoryStore.ts");
|
||||
const {
|
||||
normalizePathTokenForLookup,
|
||||
shouldPreferRemoteShellCwd,
|
||||
@@ -150,6 +154,51 @@ test("getCompletions does not treat generator-only spec args as path contexts",
|
||||
assert.equal(completions.some((entry) => entry.source === "path"), false);
|
||||
});
|
||||
|
||||
test("removeCommandHistoryEntry removes only the matching host's autocomplete record", async () => {
|
||||
recordCommand("bad-command --flag", "host-1");
|
||||
recordCommand("bad-command --flag", "host-2");
|
||||
|
||||
removeCommandHistoryEntry("bad-command --flag", "host-1");
|
||||
|
||||
const completions = await getCompletions("bad-command", {
|
||||
hostId: "host-1",
|
||||
historyScope: "global",
|
||||
protocol: "local",
|
||||
cwd: "/repo",
|
||||
});
|
||||
assert.equal(
|
||||
completions.some((entry) => entry.source === "history" && entry.text === "bad-command --flag"),
|
||||
true,
|
||||
);
|
||||
|
||||
const hostOnlyCompletions = await getCompletions("bad-command", {
|
||||
hostId: "host-1",
|
||||
historyScope: "host",
|
||||
protocol: "local",
|
||||
cwd: "/repo",
|
||||
});
|
||||
assert.equal(
|
||||
hostOnlyCompletions.some((entry) => entry.source === "history" && entry.text === "bad-command --flag"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("removeCommandHistoryEntry trims command text like recordCommand", async () => {
|
||||
recordCommand("padded-cmd", "host-trim");
|
||||
assert.equal(removeCommandHistoryEntry(" padded-cmd ", "host-trim"), true);
|
||||
|
||||
const hostOnlyCompletions = await getCompletions("padded", {
|
||||
hostId: "host-trim",
|
||||
historyScope: "host",
|
||||
protocol: "local",
|
||||
cwd: "/repo",
|
||||
});
|
||||
assert.equal(
|
||||
hostOnlyCompletions.some((entry) => entry.source === "history" && entry.text === "padded-cmd"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("getCompletions uses the remote shell cwd for relative path arguments instead of stale home", async () => {
|
||||
bridgeState.remoteEntriesByPath.set("~", [{ name: "home-only.txt", type: "file" }]);
|
||||
bridgeState.remoteEntriesByPath.set(".", [{ name: "worktree.txt", type: "file" }]);
|
||||
|
||||
@@ -53,6 +53,7 @@ export const useTerminalContextActions = ({
|
||||
passwordPromptActiveRef,
|
||||
isLocalConnection,
|
||||
supportsRemoteImagePaste,
|
||||
autoUploadClipboardImageOnPasteRef,
|
||||
clearWipesScrollbackRef,
|
||||
normalizeTextOnCopyRef,
|
||||
terminalBackend,
|
||||
@@ -70,6 +71,8 @@ export const useTerminalContextActions = ({
|
||||
passwordPromptActiveRef?: RefObject<boolean | undefined>;
|
||||
isLocalConnection: boolean;
|
||||
supportsRemoteImagePaste: boolean;
|
||||
/** When true, paste auto-uploads a clipboard image (remote sessions only). */
|
||||
autoUploadClipboardImageOnPasteRef?: RefObject<boolean | undefined>;
|
||||
clearWipesScrollbackRef?: RefObject<boolean | undefined>;
|
||||
/** When false, copy uses raw getSelection(). Default true when unset. */
|
||||
normalizeTextOnCopyRef?: RefObject<boolean | undefined>;
|
||||
@@ -110,12 +113,18 @@ export const useTerminalContextActions = ({
|
||||
const bridge = netcattyBridge.get();
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge,
|
||||
autoUploadClipboardImage:
|
||||
supportsRemoteImagePaste && autoUploadClipboardImageOnPasteRef?.current === true,
|
||||
clipboardImageBridge: bridge ?? undefined,
|
||||
getRemoteCwd,
|
||||
isLocalConnection,
|
||||
isSensitiveInput: () => passwordPromptActiveRef?.current === true,
|
||||
onClipboardImageUploadResult,
|
||||
readClipboardText: () => navigator.clipboard.readText(),
|
||||
scrollOnPaste: scrollOnPasteRef?.current ?? false,
|
||||
onPasteData: broadcastUserPasteData,
|
||||
sessionId: sessionRef.current,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
terminalBackend,
|
||||
term,
|
||||
});
|
||||
@@ -123,12 +132,17 @@ export const useTerminalContextActions = ({
|
||||
logger.warn("Failed to paste from clipboard", err);
|
||||
}
|
||||
}, [
|
||||
autoUploadClipboardImageOnPasteRef,
|
||||
broadcastUserPasteData,
|
||||
getRemoteCwd,
|
||||
isLocalConnection,
|
||||
onClipboardImageUploadResult,
|
||||
passwordPromptActiveRef,
|
||||
sessionRef,
|
||||
supportsRemoteImagePaste,
|
||||
termRef,
|
||||
scrollOnPasteRef,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
terminalBackend,
|
||||
]);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import type { TerminalSession } from "../../../types";
|
||||
import type { RemoteClipboardImageUploadResult } from "../clipboardImagePaste";
|
||||
import { handleTerminalClipboardPaste } from "../terminalClipboardPaste";
|
||||
|
||||
interface UseTerminalFilePasteOptions {
|
||||
@@ -20,6 +21,10 @@ interface UseTerminalFilePasteOptions {
|
||||
onPasteData?: (data: string) => boolean | void;
|
||||
scrollToBottomAfterProgrammaticInput: (data: string) => void;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
/** Remote sessions only: auto-upload a clipboard image on paste. */
|
||||
autoUploadClipboardImage?: boolean;
|
||||
getRemoteCwd?: () => Promise<string | null | undefined>;
|
||||
onClipboardImageUploadResult?: (result: RemoteClipboardImageUploadResult) => void;
|
||||
}
|
||||
|
||||
export function useTerminalFilePaste({
|
||||
@@ -33,6 +38,9 @@ export function useTerminalFilePaste({
|
||||
onPasteData,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
containerRef,
|
||||
autoUploadClipboardImage = false,
|
||||
getRemoteCwd,
|
||||
onClipboardImageUploadResult,
|
||||
}: UseTerminalFilePasteOptions) {
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -43,7 +51,9 @@ export function useTerminalFilePaste({
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
|
||||
if (!isLocalConnection || !bridge?.readClipboardFiles) return;
|
||||
const wantsImageUpload =
|
||||
autoUploadClipboardImage && !isLocalConnection && !!bridge?.readClipboardImage;
|
||||
if (!wantsImageUpload && (!isLocalConnection || !bridge?.readClipboardFiles)) return;
|
||||
|
||||
// ⚡ Must call preventDefault SYNCHRONOUSLY — the event lifecycle
|
||||
// is synchronous; calling it after an await is too late and the
|
||||
@@ -57,8 +67,12 @@ export function useTerminalFilePaste({
|
||||
if (!term) return;
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge,
|
||||
autoUploadClipboardImage: wantsImageUpload,
|
||||
clipboardImageBridge: bridge ?? undefined,
|
||||
getRemoteCwd,
|
||||
isLocalConnection,
|
||||
isSensitiveInput,
|
||||
onClipboardImageUploadResult,
|
||||
readClipboardText: () => navigator.clipboard.readText(),
|
||||
scrollOnPaste: scrollOnPasteRef?.current ?? false,
|
||||
onPasteData,
|
||||
@@ -78,9 +92,12 @@ export function useTerminalFilePaste({
|
||||
container.removeEventListener("paste", handlePaste, true);
|
||||
};
|
||||
}, [
|
||||
autoUploadClipboardImage,
|
||||
containerRef,
|
||||
getRemoteCwd,
|
||||
isLocalConnection,
|
||||
isSensitiveInput,
|
||||
onClipboardImageUploadResult,
|
||||
onPasteData,
|
||||
scrollOnPasteRef,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
|
||||
@@ -1494,6 +1494,15 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
break;
|
||||
}
|
||||
case "paste": {
|
||||
const autoImagePaste =
|
||||
ctx.terminalSettingsRef.current?.autoUploadClipboardImageOnPaste === true;
|
||||
if (autoImagePaste) {
|
||||
// onPaste (shared with the context menu) auto-uploads a
|
||||
// clipboard image via SFTP for remote sessions and falls
|
||||
// back to a regular text paste when there is no image.
|
||||
void ctx.terminalContextActionsRef?.current?.onPaste?.();
|
||||
break;
|
||||
}
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createRightClickMouseTrackingPressClaim,
|
||||
isMiddleClickContextMenuEvent,
|
||||
isShiftSelectionReplayMouseEvent,
|
||||
markMiddleClickContextMenuEvent,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
resolveMiddleClickBehavior,
|
||||
shouldInterceptMouseTrackingContextMenu,
|
||||
shouldReplayShiftMouseSelectionAsMacOption,
|
||||
shouldStopRightClickMouseTrackingMouseUp,
|
||||
shouldStopShiftRightClickMouseTrackingMouseDown,
|
||||
} from "./middleClickBehavior";
|
||||
|
||||
@@ -299,6 +301,118 @@ test("Shift right-click mousedown capture is limited to connected mouse tracking
|
||||
);
|
||||
});
|
||||
|
||||
test("right-click mouseup reaches mouse-tracking apps when Netcatty did not claim the press", () => {
|
||||
// Herdr / Terminal.app: button-down was delivered, so button-up must be too.
|
||||
// Swallowing mouseup leaves the TUI stuck thinking the right button is held (#2721).
|
||||
const claim = createRightClickMouseTrackingPressClaim();
|
||||
assert.equal(
|
||||
claim.noteMouseDown({
|
||||
event: { button: 2, shiftKey: false } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
rightClickBehavior: "context-menu",
|
||||
forceMenuInAlternateScreen: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldStopRightClickMouseTrackingMouseUp({
|
||||
event: { button: 2, shiftKey: false } as MouseEvent,
|
||||
claimedMatchingMouseDown: claim.consumeMouseUpClaim({ button: 2 } as MouseEvent),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("right-click mouseup is stopped only when Netcatty claimed the matching mousedown", () => {
|
||||
const shiftClaim = createRightClickMouseTrackingPressClaim();
|
||||
assert.equal(
|
||||
shiftClaim.noteMouseDown({
|
||||
event: { button: 2, shiftKey: true } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldStopRightClickMouseTrackingMouseUp({
|
||||
event: { button: 2, shiftKey: true } as MouseEvent,
|
||||
claimedMatchingMouseDown: shiftClaim.consumeMouseUpClaim({ button: 2 } as MouseEvent),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
const forceMenuClaim = createRightClickMouseTrackingPressClaim();
|
||||
assert.equal(
|
||||
forceMenuClaim.noteMouseDown({
|
||||
event: { button: 2, shiftKey: false } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
rightClickBehavior: "context-menu",
|
||||
forceMenuInAlternateScreen: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldStopRightClickMouseTrackingMouseUp({
|
||||
event: { button: 2, shiftKey: false } as MouseEvent,
|
||||
claimedMatchingMouseDown: forceMenuClaim.consumeMouseUpClaim({ button: 2 } as MouseEvent),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldStopRightClickMouseTrackingMouseUp({
|
||||
event: { button: 0, shiftKey: false } as MouseEvent,
|
||||
claimedMatchingMouseDown: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("right-click mouseup pairs with the claimed mousedown, not the release modifiers", () => {
|
||||
// Shift+right press claimed by Netcatty, then Shift released before mouseup:
|
||||
// still swallow the release so xterm never sees a lone button-up.
|
||||
const claimedThenShiftReleased = createRightClickMouseTrackingPressClaim();
|
||||
assert.equal(
|
||||
claimedThenShiftReleased.noteMouseDown({
|
||||
event: { button: 2, shiftKey: true } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldStopRightClickMouseTrackingMouseUp({
|
||||
event: { button: 2, shiftKey: false } as MouseEvent,
|
||||
claimedMatchingMouseDown: claimedThenShiftReleased.consumeMouseUpClaim(
|
||||
{ button: 2, shiftKey: false } as MouseEvent,
|
||||
),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
// App-owned press, then Shift held on release: release must still reach xterm.
|
||||
const appOwnedThenShiftAdded = createRightClickMouseTrackingPressClaim();
|
||||
assert.equal(
|
||||
appOwnedThenShiftAdded.noteMouseDown({
|
||||
event: { button: 2, shiftKey: false } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldStopRightClickMouseTrackingMouseUp({
|
||||
event: { button: 2, shiftKey: true } as MouseEvent,
|
||||
claimedMatchingMouseDown: appOwnedThenShiftAdded.consumeMouseUpClaim(
|
||||
{ button: 2, shiftKey: true } as MouseEvent,
|
||||
),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("middle-click terminal mouse down/up events are captured before xterm sees them", () => {
|
||||
const calls: string[] = [];
|
||||
const middleClickEvent = {
|
||||
|
||||
@@ -131,6 +131,48 @@ export const shouldStopShiftRightClickMouseTrackingMouseDown = ({
|
||||
&& event.button === 2
|
||||
&& (event.shiftKey || forcesMenuOverMouseTracking({ rightClickBehavior, forceMenuInAlternateScreen }));
|
||||
|
||||
// Pair mouseup with mousedown ownership. When Netcatty claims the press
|
||||
// (Shift / fullscreen-apps menu), also swallow the release so xterm never
|
||||
// reports a lone button-up. When the TUI owns the press (Herdr, tmux menus,
|
||||
// vim, ...), the release must reach xterm too - otherwise the app stays stuck
|
||||
// with the right button held and mouse UI dies until restart (#2721).
|
||||
// Ownership is remembered from the actual mousedown claim — never re-derived
|
||||
// from mouseup modifiers (Shift may change between press and release).
|
||||
export interface RightClickMouseTrackingPressClaim {
|
||||
/** Evaluate + record whether this right-button mousedown was claimed. */
|
||||
noteMouseDown: (state: ShiftRightClickMouseDownCaptureState) => boolean;
|
||||
/** Consume the pending claim for a right-button mouseup (clears state). */
|
||||
consumeMouseUpClaim: (event: MouseEvent) => boolean;
|
||||
}
|
||||
|
||||
export const createRightClickMouseTrackingPressClaim = (): RightClickMouseTrackingPressClaim => {
|
||||
let claimedMatchingMouseDown = false;
|
||||
|
||||
return {
|
||||
noteMouseDown(state) {
|
||||
const shouldStop = shouldStopShiftRightClickMouseTrackingMouseDown(state);
|
||||
if (state.event.button === 2) {
|
||||
claimedMatchingMouseDown = shouldStop;
|
||||
}
|
||||
return shouldStop;
|
||||
},
|
||||
consumeMouseUpClaim(event) {
|
||||
if (event.button !== 2) return false;
|
||||
const claimed = claimedMatchingMouseDown;
|
||||
claimedMatchingMouseDown = false;
|
||||
return claimed;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const shouldStopRightClickMouseTrackingMouseUp = ({
|
||||
event,
|
||||
claimedMatchingMouseDown,
|
||||
}: {
|
||||
event: MouseEvent;
|
||||
claimedMatchingMouseDown: boolean;
|
||||
}): boolean => event.button === 2 && claimedMatchingMouseDown;
|
||||
|
||||
export const createMacOptionForcedSelectionMouseEvent = (event: MouseEvent): MouseEvent =>
|
||||
markShiftSelectionReplayMouseEvent(new MouseEvent(event.type, {
|
||||
bubbles: event.bubbles,
|
||||
|
||||
@@ -71,3 +71,188 @@ test("terminal user paste still inserts local clipboard file paths", async () =>
|
||||
assert.deepEqual(scrolled, ["/Users/alice/shot.png /Users/alice/report.txt"]);
|
||||
assert.equal(focused, true);
|
||||
});
|
||||
|
||||
test("terminal user paste auto-uploads a clipboard image in remote sessions", async () => {
|
||||
const writes: Array<{ data: string }> = [];
|
||||
const readTextCalls: string[] = [];
|
||||
const image = {
|
||||
path: "/tmp/netcatty/shot.png",
|
||||
name: "shot.png",
|
||||
mediaType: "image/png",
|
||||
size: 12,
|
||||
};
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge: {
|
||||
readClipboardFiles: async () => [],
|
||||
},
|
||||
autoUploadClipboardImage: true,
|
||||
clipboardImageBridge: {
|
||||
readClipboardImage: async () => image,
|
||||
openSftpForSession: async () => "sftp-1",
|
||||
startStreamTransfer: async (options) => ({ transferId: options.transferId }),
|
||||
closeSftp: async () => {},
|
||||
deleteTempFile: async () => ({ success: true }),
|
||||
},
|
||||
getRemoteCwd: async () => "/home/alice",
|
||||
isLocalConnection: false,
|
||||
readClipboardText: async () => {
|
||||
readTextCalls.push("read");
|
||||
return "hello";
|
||||
},
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: (_sessionId, data) => writes.push({ data }),
|
||||
},
|
||||
term: {
|
||||
paste: () => assert.fail("image upload should insert the remote path, not paste text"),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(writes, [{ data: "/home/alice/.netcatty-paste-images/shot.png" }]);
|
||||
assert.deepEqual(readTextCalls, []);
|
||||
});
|
||||
|
||||
test("terminal user paste falls back to text when the clipboard holds no image", async () => {
|
||||
const pasted: string[] = [];
|
||||
let sftpOpened = false;
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge: {
|
||||
readClipboardFiles: async () => [],
|
||||
},
|
||||
autoUploadClipboardImage: true,
|
||||
clipboardImageBridge: {
|
||||
readClipboardImage: async () => null,
|
||||
openSftpForSession: async () => {
|
||||
sftpOpened = true;
|
||||
return "sftp-1";
|
||||
},
|
||||
startStreamTransfer: async (options) => ({ transferId: options.transferId }),
|
||||
},
|
||||
getRemoteCwd: async () => "/home/alice",
|
||||
isLocalConnection: false,
|
||||
readClipboardText: async () => "hello",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("no image should fall back to text paste"),
|
||||
},
|
||||
term: {
|
||||
paste: (text) => pasted.push(text),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(sftpOpened, false);
|
||||
assert.deepEqual(pasted, ["hello"]);
|
||||
});
|
||||
|
||||
test("terminal user paste reports failed uploads instead of pasting text", async () => {
|
||||
const results: unknown[] = [];
|
||||
const readTextCalls: string[] = [];
|
||||
const image = {
|
||||
path: "/tmp/netcatty/shot.png",
|
||||
name: "shot.png",
|
||||
mediaType: "image/png",
|
||||
size: 12,
|
||||
};
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge: {
|
||||
readClipboardFiles: async () => [],
|
||||
},
|
||||
autoUploadClipboardImage: true,
|
||||
clipboardImageBridge: {
|
||||
readClipboardImage: async () => image,
|
||||
openSftpForSession: async () => "sftp-1",
|
||||
startStreamTransfer: async (options) => ({ transferId: options.transferId, error: "disk full" }),
|
||||
},
|
||||
getRemoteCwd: async () => "/home/alice",
|
||||
isLocalConnection: false,
|
||||
onClipboardImageUploadResult: (result) => results.push(result),
|
||||
readClipboardText: async () => {
|
||||
readTextCalls.push("read");
|
||||
return "hello";
|
||||
},
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("failed upload should not paste anything"),
|
||||
},
|
||||
term: {
|
||||
paste: () => assert.fail("failed upload should not paste text"),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(results, [{ ok: false, reason: "upload-failed" }]);
|
||||
assert.deepEqual(readTextCalls, []);
|
||||
});
|
||||
|
||||
test("terminal user paste reports thrown upload failures instead of pasting text", async () => {
|
||||
const results: unknown[] = [];
|
||||
const readTextCalls: string[] = [];
|
||||
const image = {
|
||||
path: "/tmp/netcatty/shot.png",
|
||||
name: "shot.png",
|
||||
mediaType: "image/png",
|
||||
size: 12,
|
||||
};
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge: {
|
||||
readClipboardFiles: async () => [],
|
||||
},
|
||||
autoUploadClipboardImage: true,
|
||||
clipboardImageBridge: {
|
||||
readClipboardImage: async () => image,
|
||||
openSftpForSession: async () => {
|
||||
throw new Error("SFTP unavailable");
|
||||
},
|
||||
startStreamTransfer: async (options) => ({ transferId: options.transferId }),
|
||||
},
|
||||
getRemoteCwd: async () => "/home/alice",
|
||||
isLocalConnection: false,
|
||||
onClipboardImageUploadResult: (result) => results.push(result),
|
||||
readClipboardText: async () => {
|
||||
readTextCalls.push("read");
|
||||
return "hello";
|
||||
},
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("thrown upload failure must not paste anything"),
|
||||
},
|
||||
term: {
|
||||
paste: () => assert.fail("thrown upload failure must not paste text"),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(results, [{ ok: false, reason: "upload-failed" }]);
|
||||
assert.deepEqual(readTextCalls, []);
|
||||
});
|
||||
|
||||
test("terminal user paste keeps local file path paste even with auto-upload enabled", async () => {
|
||||
const writes: Array<{ data: string }> = [];
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge: {
|
||||
readClipboardFiles: async () => [
|
||||
{ path: "/Users/alice/shot.png", name: "shot.png", isDirectory: false },
|
||||
],
|
||||
},
|
||||
autoUploadClipboardImage: true,
|
||||
isLocalConnection: true,
|
||||
readClipboardText: async () => assert.fail("local file paste should not fall through to text"),
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: (_sessionId, data) => writes.push({ data }),
|
||||
},
|
||||
term: {
|
||||
paste: () => assert.fail("local file paste should write paths directly"),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(writes, [{ data: "/Users/alice/shot.png" }]);
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
|
||||
import {
|
||||
handleRemoteClipboardImageUpload,
|
||||
type RemoteClipboardImageBridge,
|
||||
type RemoteClipboardImageUploadResult,
|
||||
} from "./clipboardImagePaste";
|
||||
import { extractRootPathsFromClipboardFiles } from "./terminalHelpers";
|
||||
import { pasteTextIntoTerminal } from "./runtime/terminalUserPaste";
|
||||
|
||||
@@ -7,8 +12,18 @@ type ClipboardFileBridge = Pick<Partial<NetcattyBridge>, "readClipboardFiles">;
|
||||
|
||||
type TerminalClipboardPasteOptions = {
|
||||
bridge?: ClipboardFileBridge;
|
||||
/**
|
||||
* When true, a clipboard image is uploaded to the remote host (SFTP) and
|
||||
* the remote path is pasted, instead of falling back to a text paste.
|
||||
* Callers should only enable this for connections that support remote
|
||||
* image paste; the upload no-ops unless a session id is provided.
|
||||
*/
|
||||
autoUploadClipboardImage?: boolean;
|
||||
clipboardImageBridge?: RemoteClipboardImageBridge;
|
||||
getRemoteCwd?: () => Promise<string | null | undefined>;
|
||||
isLocalConnection: boolean;
|
||||
isSensitiveInput?: () => boolean;
|
||||
onClipboardImageUploadResult?: (result: RemoteClipboardImageUploadResult) => void;
|
||||
onPasteData?: (data: string) => boolean | void;
|
||||
readClipboardText: () => Promise<string>;
|
||||
scrollOnPaste?: boolean;
|
||||
@@ -22,8 +37,12 @@ type TerminalClipboardPasteOptions = {
|
||||
|
||||
export async function handleTerminalClipboardPaste({
|
||||
bridge,
|
||||
autoUploadClipboardImage = false,
|
||||
clipboardImageBridge,
|
||||
getRemoteCwd,
|
||||
isLocalConnection,
|
||||
isSensitiveInput,
|
||||
onClipboardImageUploadResult,
|
||||
onPasteData,
|
||||
readClipboardText,
|
||||
scrollOnPaste = false,
|
||||
@@ -32,6 +51,38 @@ export async function handleTerminalClipboardPaste({
|
||||
terminalBackend,
|
||||
term,
|
||||
}: TerminalClipboardPasteOptions): Promise<void> {
|
||||
// Image-first: when enabled and a remote session is active, a clipboard
|
||||
// image triggers the SFTP upload flow. "no-image" means the clipboard
|
||||
// holds text/files instead, so we silently continue to the normal paste.
|
||||
if (autoUploadClipboardImage && sessionId && !isLocalConnection) {
|
||||
try {
|
||||
const result = await handleRemoteClipboardImageUpload({
|
||||
bridge: clipboardImageBridge,
|
||||
getRemoteCwd: getRemoteCwd ?? (async () => undefined),
|
||||
isSensitiveInput,
|
||||
sessionId,
|
||||
terminalBackend,
|
||||
term,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
});
|
||||
if (result.ok === true) {
|
||||
onClipboardImageUploadResult?.(result);
|
||||
return;
|
||||
}
|
||||
if (result.reason !== "no-image" && result.reason !== "unsupported") {
|
||||
onClipboardImageUploadResult?.(result);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// The clipboard image was already read successfully above, so any throw
|
||||
// here means the upload itself failed (e.g. SFTP cannot be opened for
|
||||
// the session). Surface the same error as the context-menu action
|
||||
// instead of silently pasting unrelated clipboard content.
|
||||
onClipboardImageUploadResult?.({ ok: false, reason: "upload-failed" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const readClipboardFiles = bridge?.readClipboardFiles;
|
||||
if (isLocalConnection && readClipboardFiles) {
|
||||
try {
|
||||
|
||||
@@ -8,9 +8,10 @@ import { isMacPlatform } from '../../lib/utils';
|
||||
import { resolveXTermScrollback } from '../../infrastructure/config/xtermPerformance';
|
||||
import {
|
||||
createMacOptionForcedSelectionMouseEvent,
|
||||
createRightClickMouseTrackingPressClaim,
|
||||
shouldInterceptMouseTrackingContextMenu,
|
||||
shouldReplayShiftMouseSelectionAsMacOption,
|
||||
shouldStopShiftRightClickMouseTrackingMouseDown,
|
||||
shouldStopRightClickMouseTrackingMouseUp,
|
||||
} from './runtime/middleClickBehavior';
|
||||
import {
|
||||
hasOpenAppDialog,
|
||||
@@ -1553,21 +1554,24 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
}, [sessionId]);
|
||||
|
||||
|
||||
// Prevent xterm.js's built-in rightClickHandler and right-button mouseup
|
||||
// from interfering with tmux/vim popup menus when mouse tracking is active.
|
||||
// On macOS, xterm forces selection with Option, while most terminal users
|
||||
// expect Shift to bypass mouse reporting. Replay Shift+left-click as that
|
||||
// native xterm force-selection gesture before xterm receives the original.
|
||||
// - mousedown (button 2 + Shift): keep Shift+right-click local so the
|
||||
// terminal app does not also receive the right-button press
|
||||
// Prevent xterm.js's built-in rightClickHandler from interfering with
|
||||
// mouse-tracking TUIs. On macOS, xterm forces selection with Option, while
|
||||
// most terminal users expect Shift to bypass mouse reporting. Replay
|
||||
// Shift+left-click as that native xterm force-selection gesture before
|
||||
// xterm receives the original.
|
||||
// - mousedown (button 2 + Shift / fullscreen-apps menu): keep the press
|
||||
// local so the terminal app does not also receive it
|
||||
// - contextmenu: xterm.js calls textarea.select() which steals focus
|
||||
// - mouseup (button 2): tmux interprets the right-button release as a
|
||||
// dismiss action, closing the popup menu immediately after it appears
|
||||
// Both are intercepted at the capture phase before xterm.js's own listeners.
|
||||
// - mouseup (button 2): only stop when we also claimed the matching
|
||||
// mousedown. App-owned right-clicks (Herdr, tmux, vim, ...) must receive
|
||||
// the release or the TUI stays stuck with the button held (#2721).
|
||||
// Intercepted at the capture phase before xterm.js's own listeners.
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const rightClickPressClaim = createRightClickMouseTrackingPressClaim();
|
||||
|
||||
const handleContextMenuCapture = (e: MouseEvent) => {
|
||||
if (!shouldInterceptMouseTrackingContextMenu({
|
||||
event: e,
|
||||
@@ -1601,7 +1605,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
};
|
||||
|
||||
const handleMouseDownCapture = (e: MouseEvent) => {
|
||||
if (shouldStopShiftRightClickMouseTrackingMouseDown({
|
||||
if (rightClickPressClaim.noteMouseDown({
|
||||
event: e,
|
||||
mouseTracking: mouseTrackingRef.current,
|
||||
status: statusRef.current,
|
||||
@@ -1630,7 +1634,10 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
};
|
||||
|
||||
const handleMouseUpCapture = (e: MouseEvent) => {
|
||||
if (e.button === 2 && mouseTrackingRef.current && statusRef.current === 'connected') {
|
||||
if (shouldStopRightClickMouseTrackingMouseUp({
|
||||
event: e,
|
||||
claimedMatchingMouseDown: rightClickPressClaim.consumeMouseUpClaim(e),
|
||||
})) {
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -554,6 +554,7 @@ export interface TerminalLayerProps {
|
||||
onUpdateHost: (host: Host) => void;
|
||||
onAddKnownHost?: (knownHost: KnownHost) => void;
|
||||
onCommandExecuted?: (command: string, hostId: string, hostLabel: string, sessionId: string) => void;
|
||||
onDeleteShellHistoryEntry?: (entryId: string) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onCreateWorkspaceFromSessions: (baseSessionId: string, joiningSessionId: string, hint: Exclude<SplitHint, null>) => void;
|
||||
onAddSessionToWorkspace: (workspaceId: string, sessionId: string, hint: Exclude<SplitHint, null>) => void;
|
||||
|
||||
@@ -431,6 +431,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
FolderTree: s.FolderTree,
|
||||
followAppTerminalTheme: s.followAppTerminalTheme,
|
||||
handleHistoryPaste: s.handleHistoryPaste,
|
||||
handleHistoryDelete: s.handleHistoryDelete,
|
||||
handleHistoryRun: s.handleHistoryRun,
|
||||
handleFocusSidePanelPane: s.handleFocusSidePanelPane,
|
||||
handleSplitSidePanelPane: s.handleSplitSidePanelPane,
|
||||
@@ -474,6 +475,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
handleSessionExit: s.handleSessionExit,
|
||||
handleSftpCurrentPathChange: s.handleSftpCurrentPathChange,
|
||||
handleSftpActiveTransfersChange: s.handleSftpActiveTransfersChange,
|
||||
handleSftpActiveExternalEditsChange: s.handleSftpActiveExternalEditsChange,
|
||||
handleSftpInitialLocationApplied: s.handleSftpInitialLocationApplied,
|
||||
persistSidePanelWidth: s.persistSidePanelWidth,
|
||||
setSidePanelWidth: s.setSidePanelWidth,
|
||||
|
||||
@@ -55,27 +55,54 @@ function task(
|
||||
}
|
||||
|
||||
test("closing the panel keeps SFTP mounted while a transfer is active", () => {
|
||||
assert.equal(shouldKeepSftpMountedAfterClose(1), true);
|
||||
assert.equal(shouldKeepSftpMountedAfterClose(3), true);
|
||||
assert.equal(shouldKeepSftpMountedAfterClose({ activeTransfersCount: 1 }), true);
|
||||
assert.equal(shouldKeepSftpMountedAfterClose({ activeTransfersCount: 3 }), true);
|
||||
});
|
||||
|
||||
test("closing the panel keeps SFTP mounted while an external editor temp is open", () => {
|
||||
assert.equal(shouldKeepSftpMountedAfterClose({
|
||||
activeTransfersCount: 0,
|
||||
activeExternalEditCount: 1,
|
||||
}), true);
|
||||
assert.equal(shouldKeepSftpMountedAfterClose({
|
||||
activeTransfersCount: 0,
|
||||
activeExternalEditCount: 0,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("closing an idle panel still releases its SFTP state", () => {
|
||||
assert.equal(shouldKeepSftpMountedAfterClose(0), false);
|
||||
assert.equal(shouldKeepSftpMountedAfterClose({ activeTransfersCount: 0 }), false);
|
||||
});
|
||||
|
||||
test("a transfer retained by close keeps its history after completion", () => {
|
||||
assert.equal(shouldClearSftpPanelAfterTransferChange({
|
||||
activeTransfersCount: 0,
|
||||
activeExternalEditCount: 0,
|
||||
panelOpen: false,
|
||||
retainedAfterClose: true,
|
||||
}), false);
|
||||
assert.equal(shouldScheduleSftpRetainedPanelCleanup({
|
||||
activeTransfersCount: 0,
|
||||
activeExternalEditCount: 0,
|
||||
retainedAfterClose: true,
|
||||
}), true);
|
||||
assert.ok(SFTP_TRANSFER_HISTORY_RETENTION_MS > 0);
|
||||
});
|
||||
|
||||
test("retained cleanup waits while an external editor temp is still open", () => {
|
||||
assert.equal(shouldClearSftpPanelAfterTransferChange({
|
||||
activeTransfersCount: 0,
|
||||
activeExternalEditCount: 1,
|
||||
panelOpen: false,
|
||||
retainedAfterClose: false,
|
||||
}), false);
|
||||
assert.equal(shouldScheduleSftpRetainedPanelCleanup({
|
||||
activeTransfersCount: 0,
|
||||
activeExternalEditCount: 1,
|
||||
retainedAfterClose: true,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("retained cleanup is scheduled even if close state has not committed yet", () => {
|
||||
assert.equal(shouldScheduleSftpRetainedPanelCleanup({
|
||||
activeTransfersCount: 0,
|
||||
@@ -108,6 +135,7 @@ test("closing a terminal tab keeps its hidden SFTP owner mounted until active tr
|
||||
test("a reopening panel is not cleared before its open state commits", () => {
|
||||
assert.equal(shouldClearSftpPanelAfterTransferChange({
|
||||
activeTransfersCount: 0,
|
||||
activeExternalEditCount: 0,
|
||||
panelOpen: true,
|
||||
retainedAfterClose: false,
|
||||
}), false);
|
||||
@@ -116,6 +144,7 @@ test("a reopening panel is not cleared before its open state commits", () => {
|
||||
test("an unretained hidden idle panel can be released", () => {
|
||||
assert.equal(shouldClearSftpPanelAfterTransferChange({
|
||||
activeTransfersCount: 0,
|
||||
activeExternalEditCount: 0,
|
||||
panelOpen: false,
|
||||
retainedAfterClose: false,
|
||||
}), true);
|
||||
@@ -141,9 +170,13 @@ test("store unfinished tasks retain the owner even when the panel report is stil
|
||||
storeTasks,
|
||||
ownerId,
|
||||
}), 1);
|
||||
assert.equal(shouldKeepSftpMountedAfterClose(
|
||||
resolveSftpActiveTransfersCount({ reportedCount: 0, storeTasks, ownerId }),
|
||||
), true);
|
||||
assert.equal(shouldKeepSftpMountedAfterClose({
|
||||
activeTransfersCount: resolveSftpActiveTransfersCount({
|
||||
reportedCount: 0,
|
||||
storeTasks,
|
||||
ownerId,
|
||||
}),
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("paused and failed top-level tasks still retain the hidden SFTP owner", () => {
|
||||
@@ -183,15 +216,22 @@ test("terminal side panel reports transfer activity and uses store-backed retain
|
||||
assert.match(panelSource, /surfaceVisible:\s*isVisible/);
|
||||
assert.match(panelSource, /useEditorTabPresenceRevision\(\)/);
|
||||
assert.match(panelSource, /hasOwnedEditorTab/);
|
||||
assert.match(panelSource, /hasActiveExternalEdit/);
|
||||
assert.match(slotsSource, /onActiveTransfersChange=\{handleActiveTransfersChange\}/);
|
||||
assert.match(slotsSource, /onActiveExternalEditsChange=\{handleActiveExternalEditsChange\}/);
|
||||
assert.match(panelSource, /onActiveExternalEditsChange/);
|
||||
assert.match(layerSource, /resolveTabActiveTransfersCount/);
|
||||
assert.match(layerSource, /terminalSftpTransferOwnerId/);
|
||||
assert.match(layerSource, /listTerminalTabIdsWithRetainingTransfers/);
|
||||
assert.match(layerSource, /shouldKeepSftpMountedAfterClose\(activeTransfersCount\)/);
|
||||
assert.match(layerSource, /shouldKeepSftpMountedAfterClose\(\{/);
|
||||
assert.match(layerSource, /activeExternalEditCount/);
|
||||
assert.match(layerSource, /sftpActiveExternalEditsByTabRef/);
|
||||
assert.match(layerSource, /sftpRetainedAfterCloseTabIdsRef/);
|
||||
assert.match(layerSource, /sftpRetainedCleanupTimersRef/);
|
||||
// Hidden UI parks browse channels; transfers keep pool / leased sessions.
|
||||
// External editor temps must also block park (closeSftp deletes those files).
|
||||
assert.match(stateSource, /shouldParkBrowseSessions/);
|
||||
assert.match(stateSource, /activeExternalEditCount/);
|
||||
assert.match(stateSource, /takeBrowseSessionsForClose/);
|
||||
assert.match(stateSource, /shouldRestoreBrowseSessions/);
|
||||
});
|
||||
@@ -56,8 +56,13 @@ export function listTerminalTabIdsWithRetainingTransfers(
|
||||
return [...tabIds];
|
||||
}
|
||||
|
||||
export function shouldKeepSftpMountedAfterClose(activeTransfersCount: number): boolean {
|
||||
return activeTransfersCount > 0;
|
||||
export function shouldKeepSftpMountedAfterClose(params: {
|
||||
activeTransfersCount: number;
|
||||
/** External-editor temps still need the browse session (closeSftp deletes them). */
|
||||
activeExternalEditCount?: number;
|
||||
}): boolean {
|
||||
return params.activeTransfersCount > 0
|
||||
|| (params.activeExternalEditCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function shouldCloseSftpSidePanel(params: {
|
||||
@@ -74,19 +79,23 @@ export function shouldCloseSftpSidePanel(params: {
|
||||
|
||||
export function shouldClearSftpPanelAfterTransferChange(params: {
|
||||
activeTransfersCount: number;
|
||||
activeExternalEditCount?: number;
|
||||
panelOpen: boolean;
|
||||
retainedAfterClose: boolean;
|
||||
}): boolean {
|
||||
return params.activeTransfersCount <= 0
|
||||
&& (params.activeExternalEditCount ?? 0) <= 0
|
||||
&& !params.panelOpen
|
||||
&& !params.retainedAfterClose;
|
||||
}
|
||||
|
||||
export function shouldScheduleSftpRetainedPanelCleanup(params: {
|
||||
activeTransfersCount: number;
|
||||
activeExternalEditCount?: number;
|
||||
retainedAfterClose: boolean;
|
||||
}): boolean {
|
||||
return params.activeTransfersCount <= 0
|
||||
&& (params.activeExternalEditCount ?? 0) <= 0
|
||||
&& params.retainedAfterClose;
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ function SidePanelSftpSlotInner({
|
||||
handleSftpInitialLocationApplied,
|
||||
handleSftpCurrentPathChange,
|
||||
handleSftpActiveTransfersChange,
|
||||
handleSftpActiveExternalEditsChange,
|
||||
handlePendingUploadHandled,
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
@@ -157,6 +158,13 @@ function SidePanelSftpSlotInner({
|
||||
[handleSftpActiveTransfersChange, tabId],
|
||||
);
|
||||
|
||||
const handleActiveExternalEditsChange = useCallback(
|
||||
(count: number) => {
|
||||
handleSftpActiveExternalEditsChange(tabId, count);
|
||||
},
|
||||
[handleSftpActiveExternalEditsChange, tabId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={sidePanelHiddenPanelClassName(!isVisible)}>
|
||||
<SftpSidePanel
|
||||
@@ -176,6 +184,7 @@ function SidePanelSftpSlotInner({
|
||||
onInitialLocationApplied={handleInitialLocationApplied}
|
||||
onCurrentPathChange={handleCurrentPathChange}
|
||||
onActiveTransfersChange={handleActiveTransfersChange}
|
||||
onActiveExternalEditsChange={handleActiveExternalEditsChange}
|
||||
showWorkspaceHostHeader={isVisible && !!live.activeWorkspace}
|
||||
isVisible={isVisible}
|
||||
renderOverlays={isVisible}
|
||||
@@ -447,6 +456,7 @@ function SidePanelHistorySlotInner({
|
||||
const {
|
||||
HistorySidePanel,
|
||||
handleHistoryPaste,
|
||||
handleHistoryDelete,
|
||||
handleHistoryRun,
|
||||
} = ctx;
|
||||
|
||||
@@ -460,6 +470,7 @@ function SidePanelHistorySlotInner({
|
||||
state={remoteHistory.getState(live.focusedHost?.id, live.historySessionId)}
|
||||
globalEntries={shellHistory as import('../../domain/models').ShellHistoryEntry[]}
|
||||
onFetch={remoteHistory.fetch}
|
||||
onDeleteGlobalEntry={handleHistoryDelete}
|
||||
onPasteToTerminal={handleHistoryPaste}
|
||||
onRunInTerminal={handleHistoryRun}
|
||||
isVisible
|
||||
|
||||
@@ -232,6 +232,7 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
|
||||
'systemMountedTabIds',
|
||||
'themeMountedTabIds',
|
||||
'handleHistoryPaste',
|
||||
'handleHistoryDelete',
|
||||
'handleHistoryRun',
|
||||
'handleOpenHistory',
|
||||
'HistorySidePanel',
|
||||
@@ -259,6 +260,7 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
|
||||
'sftpPendingUploadsForTab',
|
||||
'handleSftpCurrentPathChange',
|
||||
'handleSftpActiveTransfersChange',
|
||||
'handleSftpActiveExternalEditsChange',
|
||||
'sftpDoubleClickBehavior',
|
||||
'sftpAutoSync',
|
||||
'sftpShowHiddenFiles',
|
||||
|
||||
@@ -63,6 +63,7 @@ export const terminalLayerAreEqual = (
|
||||
prev.onUpdateSessionRestoreCwd === next.onUpdateSessionRestoreCwd &&
|
||||
prev.onUpdateHost === next.onUpdateHost &&
|
||||
prev.onAddKnownHost === next.onAddKnownHost &&
|
||||
prev.onDeleteShellHistoryEntry === next.onDeleteShellHistoryEntry &&
|
||||
prev.onToggleWorkspaceViewMode === next.onToggleWorkspaceViewMode &&
|
||||
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
|
||||
prev.onReorderWorkspaceSessions === next.onReorderWorkspaceSessions &&
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { isSessionReconnectDisabled } from './SessionTabContextMenuContent';
|
||||
|
||||
const source = readFileSync(fileURLToPath(new URL('./SessionTabContextMenuContent.tsx', import.meta.url)), 'utf8');
|
||||
|
||||
test('reconnect is disabled while a session is still connecting', () => {
|
||||
assert.equal(isSessionReconnectDisabled('connecting'), true);
|
||||
assert.equal(isSessionReconnectDisabled('connected'), false);
|
||||
assert.equal(isSessionReconnectDisabled('disconnected'), false);
|
||||
});
|
||||
|
||||
test('session tab menu exposes optional edit-host when a vault host is present', () => {
|
||||
assert.match(source, /editHost\?: Host/);
|
||||
assert.match(source, /onEditHost\?: \(host: Host\) => void/);
|
||||
assert.match(source, /editHost && onEditHost/);
|
||||
assert.match(source, /terminal\.layer\.hostTree\.editHost/);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { TerminalSession } from '../../types';
|
||||
import type { Host, TerminalSession } from '../../types';
|
||||
import { ContextMenuContent, ContextMenuItem } from '../ui/context-menu';
|
||||
|
||||
type TranslateFn = ReturnType<typeof useI18n>['t'];
|
||||
@@ -15,6 +15,9 @@ interface SessionTabContextMenuContentProps {
|
||||
onReconnectSession: (sessionId: string) => void;
|
||||
sessionStatus: TerminalSession['status'];
|
||||
onRenameSession: (sessionId: string) => void;
|
||||
/** Vault host for this session; omit edit when missing (e.g. local shell). */
|
||||
editHost?: Host;
|
||||
onEditHost?: (host: Host) => void;
|
||||
renderBulkCloseItems?: (anchorId: string) => React.ReactNode;
|
||||
t: TranslateFn;
|
||||
}
|
||||
@@ -28,6 +31,8 @@ export function SessionTabContextMenuContent({
|
||||
onReconnectSession,
|
||||
sessionStatus,
|
||||
onRenameSession,
|
||||
editHost,
|
||||
onEditHost,
|
||||
renderBulkCloseItems,
|
||||
t,
|
||||
}: SessionTabContextMenuContentProps) {
|
||||
@@ -42,6 +47,11 @@ export function SessionTabContextMenuContent({
|
||||
<ContextMenuItem onClick={() => onRenameSession(sessionId)}>
|
||||
{t('common.rename')}
|
||||
</ContextMenuItem>
|
||||
{editHost && onEditHost && (
|
||||
<ContextMenuItem onClick={() => onEditHost(editHost)}>
|
||||
{t('terminal.layer.hostTree.editHost')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onCopySession && (
|
||||
<ContextMenuItem onClick={() => onCopySession(sessionId)}>
|
||||
{t('tabs.copyTab')}
|
||||
|
||||
@@ -683,6 +683,7 @@ interface SessionTopTabProps {
|
||||
onRenameSession: (sessionId: string) => void;
|
||||
onCopySession: (sessionId: string) => void;
|
||||
onCopySessionToNewWindow: (sessionId: string) => void;
|
||||
onEditHost?: (host: Host) => void;
|
||||
renderBulkCloseItems: RenderBulkCloseItems;
|
||||
dynamicTabTitleMode?: DynamicTabTitleMode;
|
||||
t: TranslateFn;
|
||||
@@ -706,6 +707,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
onRenameSession,
|
||||
onCopySession,
|
||||
onCopySessionToNewWindow,
|
||||
onEditHost,
|
||||
renderBulkCloseItems,
|
||||
dynamicTabTitleMode,
|
||||
t,
|
||||
@@ -833,6 +835,8 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
onReconnectSession={terminalReconnectRegistry.request}
|
||||
sessionStatus={session.status}
|
||||
onRenameSession={onRenameSession}
|
||||
editHost={host}
|
||||
onEditHost={onEditHost}
|
||||
renderBulkCloseItems={renderBulkCloseItems}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
@@ -1373,6 +1373,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
groupConfigs={groupConfigs}
|
||||
snippets={snippets}
|
||||
onSnippetsChange={onUpdateSnippets}
|
||||
onHostsChange={onUpdateHosts}
|
||||
onImportKey={onImportOrReuseKey}
|
||||
onSave={(host) => {
|
||||
const latestHost = hosts.find(
|
||||
@@ -1383,7 +1384,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
openedHost: editingHost,
|
||||
latestHost,
|
||||
});
|
||||
onUpdateHosts(upsertHostById(hosts, nextHost));
|
||||
onUpdateHosts((prevHosts) => upsertHostById(prevHosts, nextHost));
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
|
||||
@@ -28,6 +28,8 @@ import { sanitizeHost } from "../../domain/host";
|
||||
import {
|
||||
applyVaultImportDestination,
|
||||
applyVaultHostImport,
|
||||
buildVaultHostEndpointKey,
|
||||
buildVaultHostMergeKey,
|
||||
filterVaultImportKeyPassphrasesAgainstExisting,
|
||||
mergeVaultImportIssues,
|
||||
resolveVaultImportKeyPassphraseConflicts,
|
||||
@@ -179,6 +181,9 @@ export function useVaultImportHandlers({
|
||||
result = applyVaultImportDestination(
|
||||
result,
|
||||
options?.destination ?? { mode: "preserve" },
|
||||
// SecureCRT keeps distinct session files that share an endpoint;
|
||||
// only rewrite their group when the user picks an import location.
|
||||
{ collapseDuplicateEndpoints: format !== "securecrt" },
|
||||
);
|
||||
}
|
||||
updateProgress({ stage: "preparing", percent: 70 });
|
||||
@@ -308,15 +313,16 @@ export function useVaultImportHandlers({
|
||||
}
|
||||
}
|
||||
|
||||
const makeKey = (h: Host) =>
|
||||
`${(h.protocol ?? "ssh").toLowerCase()}|${h.hostname.toLowerCase()}|${h.port}|${(h.username ?? "").toLowerCase()}`;
|
||||
|
||||
// Managed ssh_config rematch is endpoint-only; CSV/other imports treat
|
||||
// group as part of session identity so direct vs proxy copies can coexist.
|
||||
const makeKey = isManaged ? buildVaultHostEndpointKey : buildVaultHostMergeKey;
|
||||
|
||||
const existingKeys = new Set(currentHosts.map(makeKey));
|
||||
// Filter out duplicates for both managed and non-managed imports
|
||||
let newHosts = format === "securecrt"
|
||||
? result.hosts
|
||||
: result.hosts.filter((h) => !existingKeys.has(makeKey(h)));
|
||||
|
||||
|
||||
// For managed imports, also update existing hosts to be managed
|
||||
let updatedExistingHosts: Host[] = [];
|
||||
if (isManaged) {
|
||||
|
||||
@@ -3,7 +3,9 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
mergeGlobalHistoryOnAppend,
|
||||
removeGlobalHistoryEntry,
|
||||
sanitizeGlobalHistoryEntries,
|
||||
shouldRemoveAutocompleteHistoryEntry,
|
||||
shouldRecordGlobalHistoryCommand,
|
||||
toGlobalHistoryDisplayEntries,
|
||||
} from './globalHistory.ts';
|
||||
@@ -87,6 +89,32 @@ test('toGlobalHistoryDisplayEntries: maps host labels', () => {
|
||||
baseEntry({ command: 'htop', hostLabel: 'prod' }),
|
||||
]);
|
||||
assert.deepEqual(out, [
|
||||
{ id: 'id-1', command: 'htop', timestamp: 1000, hostLabel: 'prod' },
|
||||
{ id: 'id-1', command: 'htop', hostId: 'host-1', timestamp: 1000, hostLabel: 'prod' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('removeGlobalHistoryEntry: removes only the requested record', () => {
|
||||
const entries = [
|
||||
baseEntry({ id: 'first', command: 'bad-command' }),
|
||||
baseEntry({ id: 'second', command: 'keep-command' }),
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
removeGlobalHistoryEntry(entries, 'first').map((entry) => entry.id),
|
||||
['second'],
|
||||
);
|
||||
assert.equal(removeGlobalHistoryEntry(entries, 'missing'), entries);
|
||||
});
|
||||
|
||||
test('shouldRemoveAutocompleteHistoryEntry: keeps autocomplete while a duplicate row remains', () => {
|
||||
const entries = [
|
||||
baseEntry({ id: 'first', command: 'bad-command' }),
|
||||
baseEntry({ id: 'second', command: 'bad-command' }),
|
||||
baseEntry({ id: 'other-host', command: 'bad-command', hostId: 'host-2' }),
|
||||
];
|
||||
|
||||
assert.equal(shouldRemoveAutocompleteHistoryEntry(entries, 'first'), false);
|
||||
assert.equal(shouldRemoveAutocompleteHistoryEntry(entries, 'second'), false);
|
||||
assert.equal(shouldRemoveAutocompleteHistoryEntry(entries, 'other-host'), true);
|
||||
assert.equal(shouldRemoveAutocompleteHistoryEntry(entries, 'missing'), false);
|
||||
});
|
||||
@@ -26,6 +26,29 @@ export function sanitizeGlobalHistoryEntries(
|
||||
return entries.filter((entry) => shouldRecordGlobalHistoryCommand(entry.command));
|
||||
}
|
||||
|
||||
/** Remove one persisted global history record by its stable id. */
|
||||
export function removeGlobalHistoryEntry(
|
||||
entries: ShellHistoryEntry[],
|
||||
entryId: string,
|
||||
): ShellHistoryEntry[] {
|
||||
if (!entries.some((entry) => entry.id === entryId)) return entries;
|
||||
return entries.filter((entry) => entry.id !== entryId);
|
||||
}
|
||||
|
||||
/** True when deleting a global row should also remove its autocomplete entry. */
|
||||
export function shouldRemoveAutocompleteHistoryEntry(
|
||||
entries: ShellHistoryEntry[],
|
||||
entryId: string,
|
||||
): boolean {
|
||||
const entry = entries.find((candidate) => candidate.id === entryId);
|
||||
if (!entry) return false;
|
||||
return !entries.some((candidate) => (
|
||||
candidate.id !== entryId
|
||||
&& candidate.command === entry.command
|
||||
&& candidate.hostId === entry.hostId
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one command to global history: trim, drop noise, and de-dupe the most
|
||||
* recent identical command by bumping its timestamp instead of adding a row.
|
||||
@@ -63,6 +86,7 @@ export function mergeGlobalHistoryOnAppend(
|
||||
export interface GlobalHistoryDisplayEntry {
|
||||
id: string;
|
||||
command: string;
|
||||
hostId: string;
|
||||
timestamp: number;
|
||||
hostLabel?: string;
|
||||
}
|
||||
@@ -74,6 +98,7 @@ export function toGlobalHistoryDisplayEntries(
|
||||
return sanitizeGlobalHistoryEntries(entries).map((entry) => ({
|
||||
id: entry.id,
|
||||
command: entry.command,
|
||||
hostId: entry.hostId,
|
||||
timestamp: entry.timestamp,
|
||||
hostLabel: entry.hostLabel,
|
||||
}));
|
||||
|
||||
@@ -3,6 +3,8 @@ import test from 'node:test';
|
||||
import type { Host, Snippet } from './models';
|
||||
import {
|
||||
appendHostConnectScript,
|
||||
ensureHostConnectScriptIds,
|
||||
getEditableHostConnectScriptIds,
|
||||
getGlobalConnectScripts,
|
||||
getHostConnectScriptIds,
|
||||
hasHostConnectAutomation,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
shouldMarkConnectAutomationConsumed,
|
||||
shouldUseFreshSshConnectionForAutomation,
|
||||
syncHostsForSnippetTargetChange,
|
||||
syncSnippetsForHostConnectQueueSave,
|
||||
} from './hostConnectScripts.ts';
|
||||
|
||||
const host: Host = {
|
||||
@@ -131,6 +134,167 @@ test('append updates host connectScriptIds order', () => {
|
||||
assert.deepEqual(getHostConnectScriptIds(next, snippets), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('append keeps default manual scripts in the editable host queue', () => {
|
||||
const snippets = [
|
||||
script({ id: 'reset', label: 'reset-password', trigger: 'manual' }),
|
||||
script({ id: 'teest', label: 'teest', trigger: 'manual' }),
|
||||
];
|
||||
|
||||
let next = appendHostConnectScript(host, 'reset', snippets);
|
||||
assert.deepEqual(getEditableHostConnectScriptIds(next, snippets), ['reset']);
|
||||
// Runtime still ignores non-onConnect until host save promotes the trigger.
|
||||
assert.deepEqual(getHostConnectScriptIds(next, snippets), []);
|
||||
|
||||
next = appendHostConnectScript(next, 'teest', snippets);
|
||||
assert.deepEqual(getEditableHostConnectScriptIds(next, snippets), ['reset', 'teest']);
|
||||
assert.deepEqual(next.connectScriptIds, ['reset', 'teest']);
|
||||
});
|
||||
|
||||
test('ensureHostConnectScriptIds preserves pending manual queue entries while editing', () => {
|
||||
const snippets = [script({ id: 'reset', trigger: 'manual' })];
|
||||
const draft = { ...host, connectScriptIds: ['reset', 'missing'] };
|
||||
const ensured = ensureHostConnectScriptIds(draft, snippets);
|
||||
assert.deepEqual(ensured.connectScriptIds, ['reset']);
|
||||
assert.deepEqual(getEditableHostConnectScriptIds(ensured, snippets), ['reset']);
|
||||
assert.deepEqual(getHostConnectScriptIds(ensured, snippets), []);
|
||||
});
|
||||
|
||||
test('syncSnippetsForHostConnectQueueSave promotes already-persisted manual queue entries', () => {
|
||||
const snippets = [
|
||||
script({ id: 'reset', trigger: 'manual', targets: [] }),
|
||||
script({ id: 'ready', trigger: 'onConnect', targets: ['host-a'] }),
|
||||
];
|
||||
const { snippets: next, changed, connectScriptIds } = syncSnippetsForHostConnectQueueSave(
|
||||
snippets,
|
||||
'host-a',
|
||||
['reset', 'ready'],
|
||||
['reset', 'ready'],
|
||||
);
|
||||
assert.equal(changed, true);
|
||||
assert.equal(next.find((item) => item.id === 'reset')?.trigger, 'onConnect');
|
||||
assert.deepEqual(next.find((item) => item.id === 'reset')?.targets, ['host-a']);
|
||||
assert.equal(next.find((item) => item.id === 'ready'), snippets[1]);
|
||||
assert.deepEqual(connectScriptIds, ['reset', 'ready']);
|
||||
});
|
||||
|
||||
test('syncSnippetsForHostConnectQueueSave leaves global onConnect scripts untouched', () => {
|
||||
const global = script({ id: 'both', targetsAllHosts: true, targets: ['host-a'] });
|
||||
const { snippets: next, changed, connectScriptIds } = syncSnippetsForHostConnectQueueSave(
|
||||
[global],
|
||||
'host-a',
|
||||
['both'],
|
||||
['both'],
|
||||
);
|
||||
assert.equal(changed, false);
|
||||
assert.equal(next[0], global);
|
||||
assert.equal(next[0].targetsAllHosts, true);
|
||||
assert.deepEqual(connectScriptIds, ['both']);
|
||||
});
|
||||
|
||||
test('syncSnippetsForHostConnectQueueSave promotes global manual without clearing targetsAllHosts', () => {
|
||||
const globalManual = script({
|
||||
id: 'everywhere',
|
||||
trigger: 'manual',
|
||||
targetsAllHosts: true,
|
||||
});
|
||||
const { snippets: next, changed, connectScriptIds } = syncSnippetsForHostConnectQueueSave(
|
||||
[globalManual],
|
||||
'host-a',
|
||||
['everywhere'],
|
||||
['everywhere'],
|
||||
);
|
||||
assert.equal(changed, true);
|
||||
assert.equal(next[0].trigger, 'onConnect');
|
||||
assert.equal(next[0].targetsAllHosts, true);
|
||||
assert.deepEqual(connectScriptIds, ['everywhere']);
|
||||
});
|
||||
|
||||
test('syncSnippetsForHostConnectQueueSave does not re-promote concurrently demoted scripts', () => {
|
||||
const baseline = [script({ id: 'run', trigger: 'onConnect', targets: ['host-a'] })];
|
||||
const demoted = [script({ id: 'run', trigger: 'manual', targets: ['host-a'] })];
|
||||
const { snippets: next, changed, connectScriptIds } = syncSnippetsForHostConnectQueueSave(
|
||||
demoted,
|
||||
'host-a',
|
||||
['run'],
|
||||
['run'],
|
||||
{ baselineSnippets: baseline },
|
||||
);
|
||||
assert.equal(changed, true);
|
||||
assert.equal(next[0].trigger, 'manual');
|
||||
assert.deepEqual(connectScriptIds, []);
|
||||
});
|
||||
|
||||
test('syncSnippetsForHostConnectQueueSave preserves concurrent non-onConnect trigger edits', () => {
|
||||
const baseline = [script({ id: 'run', trigger: 'manual', targets: ['host-a'] })];
|
||||
const retargeted = [script({ id: 'run', trigger: 'onOutput', triggerPattern: 'ERR', targets: ['host-a'] })];
|
||||
const { snippets: next, connectScriptIds } = syncSnippetsForHostConnectQueueSave(
|
||||
retargeted,
|
||||
'host-a',
|
||||
['run'],
|
||||
['run'],
|
||||
{ baselineSnippets: baseline },
|
||||
);
|
||||
assert.equal(next[0].trigger, 'onOutput');
|
||||
assert.equal(next[0].triggerPattern, 'ERR');
|
||||
assert.deepEqual(connectScriptIds, []);
|
||||
});
|
||||
|
||||
test('syncSnippetsForHostConnectQueueSave preserves concurrent target removals', () => {
|
||||
const baseline = [script({ id: 'run', trigger: 'onConnect', targets: ['host-a', 'host-b'] })];
|
||||
const unlinked = [script({ id: 'run', trigger: 'onConnect', targets: ['host-b'] })];
|
||||
const { snippets: next, connectScriptIds } = syncSnippetsForHostConnectQueueSave(
|
||||
unlinked,
|
||||
'host-a',
|
||||
['run'],
|
||||
['run'],
|
||||
{ baselineSnippets: baseline },
|
||||
);
|
||||
assert.deepEqual(next[0].targets, ['host-b']);
|
||||
assert.deepEqual(connectScriptIds, []);
|
||||
});
|
||||
|
||||
test('syncSnippetsForHostConnectQueueSave preserves concurrent target removals for manual scripts', () => {
|
||||
const baseline = [script({ id: 'run', trigger: 'manual', targets: ['host-a', 'host-b'] })];
|
||||
const unlinked = [script({ id: 'run', trigger: 'manual', targets: ['host-b'] })];
|
||||
const { snippets: next, connectScriptIds } = syncSnippetsForHostConnectQueueSave(
|
||||
unlinked,
|
||||
'host-a',
|
||||
['run'],
|
||||
['run'],
|
||||
{ baselineSnippets: baseline },
|
||||
);
|
||||
assert.equal(next[0].trigger, 'manual');
|
||||
assert.deepEqual(next[0].targets, ['host-b']);
|
||||
assert.deepEqual(connectScriptIds, []);
|
||||
});
|
||||
|
||||
test('syncSnippetsForHostConnectQueueSave syncs other targeted hosts after promote', () => {
|
||||
const hostB: Host = {
|
||||
id: 'host-b',
|
||||
label: 'B',
|
||||
hostname: 'b.example',
|
||||
username: 'root',
|
||||
os: 'linux',
|
||||
protocol: 'ssh',
|
||||
tags: [],
|
||||
connectScriptIds: [],
|
||||
};
|
||||
const snippets = [
|
||||
script({ id: 'shared', trigger: 'manual', targets: ['host-b'] }),
|
||||
];
|
||||
const { snippets: next, hosts, changed } = syncSnippetsForHostConnectQueueSave(
|
||||
snippets,
|
||||
'host-a',
|
||||
[],
|
||||
['shared'],
|
||||
{ hosts: [host, hostB] },
|
||||
);
|
||||
assert.equal(changed, true);
|
||||
assert.equal(next[0].trigger, 'onConnect');
|
||||
assert.deepEqual(next[0].targets, ['host-b', 'host-a']);
|
||||
assert.deepEqual(hosts?.find((item) => item.id === 'host-b')?.connectScriptIds, ['shared']);
|
||||
});
|
||||
|
||||
test('syncHostsForSnippetTargetChange appends and removes queue entries', () => {
|
||||
const snippets = [script({ id: 'run', targets: ['host-a'], trigger: 'onConnect' })];
|
||||
const hosts = syncHostsForSnippetTargetChange(
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
getScriptsLinkedToHost,
|
||||
linkHostToScript,
|
||||
snippetAppliesToHost,
|
||||
unlinkHostFromScripts,
|
||||
} from './snippetTargets.ts';
|
||||
import { sortByVaultOrder } from './vaultOrder.ts';
|
||||
|
||||
@@ -28,6 +29,23 @@ function pruneConnectScriptIds(ids: string[], snippets: Snippet[]): string[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draft/edit queue prune: keep any existing script IDs.
|
||||
* Host save promotes non-onConnect scripts via prepareSnippetForHostConnectQueue.
|
||||
*/
|
||||
function pruneEditableConnectScriptIds(ids: string[], snippets: Snippet[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const id of ids) {
|
||||
if (!id || seen.has(id)) continue;
|
||||
const snippet = scriptById(snippets, id);
|
||||
if (!snippet) continue;
|
||||
seen.add(id);
|
||||
result.push(id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Global onConnect scripts (targetsAllHosts), sorted by vault order. */
|
||||
export function getGlobalConnectScripts(snippets: Snippet[]): Snippet[] {
|
||||
return sortByVaultOrder(
|
||||
@@ -77,9 +95,20 @@ export function getHostConnectScriptIds(host: Host, snippets: Snippet[]): string
|
||||
return migrateHostConnectScriptIds(host, snippets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-details draft queue: includes scripts pending promote-to-onConnect on save.
|
||||
* Runtime connect still uses getHostConnectScriptIds (onConnect only).
|
||||
*/
|
||||
export function getEditableHostConnectScriptIds(host: Host, snippets: Snippet[]): string[] {
|
||||
if (host.connectScriptIds !== undefined) {
|
||||
return pruneEditableConnectScriptIds(host.connectScriptIds, snippets);
|
||||
}
|
||||
return migrateHostConnectScriptIds(host, snippets);
|
||||
}
|
||||
|
||||
export function ensureHostConnectScriptIds(host: Host, snippets: Snippet[]): Host {
|
||||
if (host.connectScriptIds !== undefined) {
|
||||
const pruned = pruneConnectScriptIds(host.connectScriptIds, snippets);
|
||||
const pruned = pruneEditableConnectScriptIds(host.connectScriptIds, snippets);
|
||||
if (pruned.length === host.connectScriptIds.length
|
||||
&& pruned.every((id, index) => id === host.connectScriptIds![index])) {
|
||||
return host;
|
||||
@@ -164,7 +193,7 @@ export function shouldMarkConnectAutomationConsumed(options: {
|
||||
export function appendHostConnectScript(host: Host, scriptId: string, snippets: Snippet[]): Host {
|
||||
const snippet = scriptById(snippets, scriptId);
|
||||
if (!snippet) return host;
|
||||
const current = getHostConnectScriptIds(host, snippets);
|
||||
const current = getEditableHostConnectScriptIds(host, snippets);
|
||||
if (current.includes(scriptId)) {
|
||||
return { ...host, connectScriptIds: current };
|
||||
}
|
||||
@@ -172,7 +201,7 @@ export function appendHostConnectScript(host: Host, scriptId: string, snippets:
|
||||
}
|
||||
|
||||
export function removeHostConnectScript(host: Host, scriptId: string, snippets: Snippet[]): Host {
|
||||
const current = getHostConnectScriptIds(host, snippets);
|
||||
const current = getEditableHostConnectScriptIds(host, snippets);
|
||||
const next = current.filter((id) => id !== scriptId);
|
||||
return { ...host, connectScriptIds: next };
|
||||
}
|
||||
@@ -185,7 +214,7 @@ export function reorderHostConnectScript(
|
||||
snippets: Snippet[],
|
||||
): Host {
|
||||
if (draggedScriptId === targetScriptId) return host;
|
||||
const current = [...getHostConnectScriptIds(host, snippets)];
|
||||
const current = [...getEditableHostConnectScriptIds(host, snippets)];
|
||||
const fromIndex = current.indexOf(draggedScriptId);
|
||||
const targetIndex = current.indexOf(targetScriptId);
|
||||
if (fromIndex === -1 || targetIndex === -1) return host;
|
||||
@@ -200,12 +229,181 @@ export function reorderHostConnectScript(
|
||||
|
||||
export function prepareSnippetForHostConnectQueue(snippet: Snippet, hostId: string): Snippet {
|
||||
if (!isScriptSnippet(snippet)) return snippet;
|
||||
if (snippet.targetsAllHosts) {
|
||||
return snippet.trigger === 'onConnect'
|
||||
? snippet
|
||||
: { ...snippet, trigger: 'onConnect' };
|
||||
}
|
||||
return {
|
||||
...linkHostToScript(snippet, hostId),
|
||||
trigger: 'onConnect',
|
||||
};
|
||||
}
|
||||
|
||||
function connectQueueSnippetNeedsPromote(snippet: Snippet, hostId: string): boolean {
|
||||
if (!isScriptSnippet(snippet)) return false;
|
||||
if (snippet.trigger !== 'onConnect') return true;
|
||||
if (snippet.targetsAllHosts) return false;
|
||||
return !snippetAppliesToHost(snippet, hostId);
|
||||
}
|
||||
|
||||
function snippetTargetsEqual(left: Snippet, right: Snippet): boolean {
|
||||
if (Boolean(left.targetsAllHosts) !== Boolean(right.targetsAllHosts)) return false;
|
||||
const leftTargets = left.targets ?? [];
|
||||
const rightTargets = right.targets ?? [];
|
||||
if (leftTargets.length !== rightTargets.length) return false;
|
||||
return leftTargets.every((id, index) => id === rightTargets[index]);
|
||||
}
|
||||
|
||||
/**
|
||||
* After promoting a script to onConnect, ensure every remaining target host with an
|
||||
* explicit connectScriptIds queue includes it. Hosts without an explicit queue still
|
||||
* pick the script up via migrateHostConnectScriptIds.
|
||||
*/
|
||||
export function ensureTargetHostsHaveConnectScript(
|
||||
hosts: Host[],
|
||||
snippet: Snippet,
|
||||
snippets: Snippet[],
|
||||
excludeHostId?: string,
|
||||
): Host[] {
|
||||
if (!snippet.id || !isScriptSnippet(snippet) || snippet.trigger !== 'onConnect') return hosts;
|
||||
if (snippet.targetsAllHosts) return hosts;
|
||||
const targetIds = new Set(snippet.targets ?? []);
|
||||
if (targetIds.size === 0) return hosts;
|
||||
|
||||
let changed = false;
|
||||
const nextHosts = hosts.map((host) => {
|
||||
if (host.id === excludeHostId) return host;
|
||||
if (!targetIds.has(host.id)) return host;
|
||||
if (host.connectScriptIds === undefined) return host;
|
||||
const updated = appendHostConnectScript(host, snippet.id!, snippets);
|
||||
if (updated !== host) changed = true;
|
||||
return updated;
|
||||
});
|
||||
return changed ? nextHosts : hosts;
|
||||
}
|
||||
|
||||
export type SyncHostConnectQueueSaveOptions = {
|
||||
/** Snippets snapshot from when the host editor opened (detect concurrent demotion). */
|
||||
baselineSnippets?: Snippet[];
|
||||
/** When provided, promote also syncs other hosts that remain in targets. */
|
||||
hosts?: Host[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync script metadata when saving a host connect queue.
|
||||
* Promotes draft/manual queue entries, preserves global onConnect scripts,
|
||||
* drops concurrently demoted entries, and optionally syncs peer host queues.
|
||||
*/
|
||||
export function syncSnippetsForHostConnectQueueSave(
|
||||
snippets: Snippet[],
|
||||
hostId: string,
|
||||
previousQueueIds: string[],
|
||||
nextQueueIds: string[],
|
||||
options: SyncHostConnectQueueSaveOptions = {},
|
||||
): {
|
||||
snippets: Snippet[];
|
||||
hosts: Host[];
|
||||
connectScriptIds: string[];
|
||||
changed: boolean;
|
||||
} {
|
||||
const previousSet = new Set(previousQueueIds);
|
||||
const baseline = options.baselineSnippets ?? snippets;
|
||||
let nextSnippets = snippets;
|
||||
let nextHosts = options.hosts ?? [];
|
||||
let changed = false;
|
||||
const retainedIds: string[] = [];
|
||||
const demotedDropIds = new Set<string>();
|
||||
|
||||
for (const scriptId of nextQueueIds) {
|
||||
const item = nextSnippets.find((entry) => entry.id === scriptId && isScriptSnippet(entry));
|
||||
if (!item) continue;
|
||||
|
||||
if (!connectQueueSnippetNeedsPromote(item, hostId)) {
|
||||
retainedIds.push(scriptId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newlyAdded = !previousSet.has(scriptId);
|
||||
const baselineItem = baseline.find((entry) => entry.id === scriptId);
|
||||
const baselineTrigger = baselineItem && isScriptSnippet(baselineItem)
|
||||
? baselineItem.trigger
|
||||
: undefined;
|
||||
if (!newlyAdded && baselineTrigger === 'onConnect' && item.trigger !== 'onConnect') {
|
||||
// Concurrent demotion while the editor stayed open: keep demotion, drop stale queue id.
|
||||
demotedDropIds.add(scriptId);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!newlyAdded
|
||||
&& baselineItem
|
||||
&& isScriptSnippet(baselineItem)
|
||||
&& (Boolean(baselineItem.targetsAllHosts) || snippetAppliesToHost(baselineItem, hostId))
|
||||
&& !(Boolean(item.targetsAllHosts) || snippetAppliesToHost(item, hostId))
|
||||
) {
|
||||
// Concurrent target removal for this host: do not re-link on save.
|
||||
demotedDropIds.add(scriptId);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!newlyAdded
|
||||
&& baselineTrigger
|
||||
&& baselineTrigger !== 'onConnect'
|
||||
&& item.trigger !== baselineTrigger
|
||||
) {
|
||||
// Concurrent non-onConnect trigger edit (e.g. manual -> onOutput): do not overwrite.
|
||||
demotedDropIds.add(scriptId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const prepared = prepareSnippetForHostConnectQueue(item, hostId);
|
||||
if (
|
||||
prepared.trigger !== item.trigger
|
||||
|| !snippetTargetsEqual(prepared, item)
|
||||
) {
|
||||
nextSnippets = nextSnippets.map((entry) => (entry.id === scriptId ? prepared : entry));
|
||||
changed = true;
|
||||
if (options.hosts) {
|
||||
const syncedHosts = ensureTargetHostsHaveConnectScript(
|
||||
nextHosts,
|
||||
prepared,
|
||||
nextSnippets,
|
||||
hostId,
|
||||
);
|
||||
if (syncedHosts !== nextHosts) {
|
||||
nextHosts = syncedHosts;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
retainedIds.push(scriptId);
|
||||
}
|
||||
|
||||
for (const scriptId of previousQueueIds) {
|
||||
if (retainedIds.includes(scriptId)) continue;
|
||||
if (demotedDropIds.has(scriptId)) continue;
|
||||
const unlinked = unlinkHostFromScripts(nextSnippets, hostId, scriptId);
|
||||
if (unlinked !== nextSnippets) {
|
||||
nextSnippets = unlinked;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
retainedIds.length !== nextQueueIds.length
|
||||
|| retainedIds.some((id, index) => id !== nextQueueIds[index])
|
||||
) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return {
|
||||
snippets: nextSnippets,
|
||||
hosts: nextHosts,
|
||||
connectScriptIds: retainedIds,
|
||||
changed,
|
||||
};
|
||||
}
|
||||
|
||||
export function syncHostsForSnippetTargetChange(
|
||||
hosts: Host[],
|
||||
snippet: Snippet,
|
||||
|
||||
@@ -22,6 +22,12 @@ export type MiddleClickBehavior = 'context-menu' | 'paste' | 'disabled';
|
||||
export type LinkModifier = 'none' | 'ctrl' | 'alt' | 'meta';
|
||||
export type TerminalEmulationType = 'xterm-256color' | 'xterm-16color' | 'xterm';
|
||||
export type DynamicTabTitleMode = 'off' | 'agent' | 'all';
|
||||
/**
|
||||
* What the terminal host info bar shows as its primary title (#2708).
|
||||
* - address: user@host:port when available (historical default)
|
||||
* - label: vault host label / display name
|
||||
*/
|
||||
export type HostInfoBarTitleMode = 'address' | 'label';
|
||||
/**
|
||||
* How to assist when a sudo/su password prompt appears (#2156).
|
||||
* - off: no assist
|
||||
@@ -129,6 +135,8 @@ export interface TerminalSettings {
|
||||
|
||||
// Server Stats Display (Linux only)
|
||||
showHostInfoBar: boolean; // Show host identity and server stats above the terminal
|
||||
/** Primary title in the host info bar: connection address or vault label. */
|
||||
hostInfoBarTitleMode: HostInfoBarTitleMode;
|
||||
showServerStats: boolean; // Show CPU/Memory/Disk in terminal statusbar
|
||||
serverStatsRefreshInterval: number; // Seconds between stats refresh (default: 30)
|
||||
|
||||
@@ -141,6 +149,11 @@ export interface TerminalSettings {
|
||||
// Paste
|
||||
disableBracketedPaste: boolean; // Disable bracketed paste mode (avoid ^[[200~ artifacts)
|
||||
|
||||
// When true, pasting while the clipboard holds an image automatically runs
|
||||
// the "Upload clipboard image" action (SFTP upload + remote-path paste)
|
||||
// instead of falling back to a text paste. Remote SSH sessions only.
|
||||
autoUploadClipboardImageOnPaste: boolean;
|
||||
|
||||
// Shell `clear` command behavior — controls whether CSI 3 J (erase scrollback)
|
||||
// from the shell is honored. Default true matches POSIX/ncurses since 2013:
|
||||
// `clear` clears both visible screen and scrollback. Disable to keep history
|
||||
@@ -331,6 +344,11 @@ const isDynamicTabTitleMode = (value: unknown): value is DynamicTabTitleMode =>
|
||||
value === 'all'
|
||||
);
|
||||
|
||||
const isHostInfoBarTitleMode = (value: unknown): value is HostInfoBarTitleMode => (
|
||||
value === 'address' ||
|
||||
value === 'label'
|
||||
);
|
||||
|
||||
const isPasswordPromptAssistMode = (value: unknown): value is PasswordPromptAssistMode => (
|
||||
value === 'off' ||
|
||||
value === 'hint' ||
|
||||
@@ -362,6 +380,9 @@ export const normalizeTerminalSettings = (
|
||||
dynamicTabTitleMode: isDynamicTabTitleMode(settings?.dynamicTabTitleMode)
|
||||
? settings.dynamicTabTitleMode
|
||||
: DEFAULT_TERMINAL_SETTINGS.dynamicTabTitleMode,
|
||||
hostInfoBarTitleMode: isHostInfoBarTitleMode(settings?.hostInfoBarTitleMode)
|
||||
? settings.hostInfoBarTitleMode
|
||||
: DEFAULT_TERMINAL_SETTINGS.hostInfoBarTitleMode,
|
||||
passwordPromptAssist: isPasswordPromptAssistMode(settings?.passwordPromptAssist)
|
||||
? settings.passwordPromptAssist
|
||||
: DEFAULT_TERMINAL_SETTINGS.passwordPromptAssist,
|
||||
@@ -459,6 +480,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
x11Display: '', // Empty = use DISPLAY/default local X server
|
||||
moshClientPath: '', // Legacy mosh-client override; normal UI uses bundled mosh-client
|
||||
showHostInfoBar: true, // Preserve the existing host information bar by default
|
||||
hostInfoBarTitleMode: 'address', // Historical default: prefer user@host:port
|
||||
showServerStats: true, // Show server stats by default
|
||||
serverStatsRefreshInterval: 5, // Refresh every 5 seconds
|
||||
systemManagerProcessRefreshInterval: 3,
|
||||
@@ -466,6 +488,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
systemManagerDockerListRefreshInterval: 5,
|
||||
systemManagerDockerStatsRefreshInterval: 3,
|
||||
disableBracketedPaste: false, // Bracketed paste enabled by default
|
||||
autoUploadClipboardImageOnPaste: false, // Opt-in: image in clipboard auto-uploads on paste (remote sessions)
|
||||
clearWipesScrollback: true, // POSIX-standard: shell `clear` clears scrollback too
|
||||
preserveSelectionOnInput: false, // Opt-in: keep selection alive when typing
|
||||
forcePromptNewLine: false, // Opt-in: keep the next shell prompt visually separated from unterminated final output lines
|
||||
|
||||
@@ -450,6 +450,321 @@ test('plugin importer destination moves only newly imported hosts into the selec
|
||||
assert.equal(targeted.addedCount, 2);
|
||||
});
|
||||
|
||||
test('plugin importer destination re-dedupes same-endpoint hosts collapsed into one group', () => {
|
||||
const existingDrafts = normalizePluginImporterRecords([{ type: 'draft', draft: {
|
||||
kind: 'host',
|
||||
value: { label: 'Existing', hostname: 'existing.test', group: 'Original' },
|
||||
} }]);
|
||||
const importedDrafts = normalizePluginImporterRecords([
|
||||
{ type: 'draft', draft: {
|
||||
kind: 'host',
|
||||
value: {
|
||||
label: 'direct',
|
||||
hostname: '10.10.10.10',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
group: 'lan',
|
||||
},
|
||||
} },
|
||||
{ type: 'draft', draft: {
|
||||
kind: 'host',
|
||||
value: {
|
||||
label: 'via-socks',
|
||||
hostname: '10.10.10.10',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
group: 'lan-proxy',
|
||||
},
|
||||
} },
|
||||
]);
|
||||
const merged = mergePluginImporterDrafts({
|
||||
hosts: existingDrafts.hosts,
|
||||
identities: [],
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: ['Original'],
|
||||
}, importedDrafts);
|
||||
|
||||
assert.equal(merged.hosts.length, 3);
|
||||
assert.equal(merged.addedCount, 4); // 2 hosts + 2 new groups
|
||||
|
||||
const targeted = applyPluginImporterDestination(
|
||||
merged,
|
||||
existingDrafts.hosts.length,
|
||||
{ mode: 'group', group: 'Chosen' },
|
||||
['Original'],
|
||||
);
|
||||
|
||||
assert.equal(targeted.hosts.length, 2);
|
||||
assert.equal(targeted.hosts[1]?.group, 'Chosen');
|
||||
assert.deepEqual(targeted.customGroups, ['Original', 'Chosen']);
|
||||
assert.equal(targeted.duplicateCount, merged.duplicateCount + 1);
|
||||
assert.equal(targeted.addedCount, 2); // 1 retained host + Chosen group
|
||||
});
|
||||
|
||||
test('plugin importer destination keeps distinct plugin hosts that share an endpoint', () => {
|
||||
const drafts = normalizePluginImporterRecords([
|
||||
{ type: 'draft', draft: { kind: 'identity', value: {
|
||||
id: 'credential-a', label: 'Credential A', username: 'root', authMethod: 'password', password: 'first',
|
||||
} } },
|
||||
{ type: 'draft', draft: { kind: 'identity', value: {
|
||||
id: 'credential-b', label: 'Credential B', username: 'root', authMethod: 'password', password: 'second',
|
||||
} } },
|
||||
{ type: 'draft', draft: { kind: 'host', value: {
|
||||
label: 'Plugin host A',
|
||||
protocol: 'plugin:com.example.transport.connection',
|
||||
group: 'lan',
|
||||
pluginConnection: {
|
||||
providerId: 'com.example.transport.connection',
|
||||
configuration: { endpoint: 'shared', path: 'a' },
|
||||
credentialId: 'credential-a',
|
||||
},
|
||||
} } },
|
||||
{ type: 'draft', draft: { kind: 'host', value: {
|
||||
label: 'Plugin host B',
|
||||
protocol: 'plugin:com.example.transport.connection',
|
||||
group: 'lan-proxy',
|
||||
pluginConnection: {
|
||||
providerId: 'com.example.transport.connection',
|
||||
configuration: { endpoint: 'shared', path: 'b' },
|
||||
credentialId: 'credential-b',
|
||||
},
|
||||
} } },
|
||||
]);
|
||||
const merged = mergePluginImporterDrafts({
|
||||
hosts: [], identities: [], keys: [], snippets: [], customGroups: [],
|
||||
}, drafts);
|
||||
|
||||
assert.equal(merged.hosts.length, 2);
|
||||
|
||||
const targeted = applyPluginImporterDestination(
|
||||
merged,
|
||||
0,
|
||||
{ mode: 'group', group: 'Chosen' },
|
||||
);
|
||||
|
||||
assert.equal(targeted.hosts.length, 2);
|
||||
assert.deepEqual(targeted.hosts.map((host) => host.group), ['Chosen', 'Chosen']);
|
||||
assert.notEqual(
|
||||
targeted.hosts[0].pluginConnection?.configuration,
|
||||
targeted.hosts[1].pluginConnection?.configuration,
|
||||
);
|
||||
assert.equal(targeted.duplicateCount, merged.duplicateCount);
|
||||
assert.equal(targeted.addedCount, merged.addedCount - 2 + 1); // replace 2 source groups with Chosen
|
||||
});
|
||||
|
||||
test('plugin importer destination skips imports that collide with existing hosts after rewrite', () => {
|
||||
const existingDrafts = normalizePluginImporterRecords([{ type: 'draft', draft: {
|
||||
kind: 'host',
|
||||
value: {
|
||||
label: 'Existing',
|
||||
hostname: '10.10.10.10',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
group: 'Chosen',
|
||||
},
|
||||
} }]);
|
||||
const importedDrafts = normalizePluginImporterRecords([{ type: 'draft', draft: {
|
||||
kind: 'host',
|
||||
value: {
|
||||
label: 'Imported copy',
|
||||
hostname: '10.10.10.10',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
group: 'lan',
|
||||
},
|
||||
} }]);
|
||||
const merged = mergePluginImporterDrafts({
|
||||
hosts: existingDrafts.hosts,
|
||||
identities: [],
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: ['Chosen'],
|
||||
}, importedDrafts);
|
||||
|
||||
assert.equal(merged.hosts.length, 2);
|
||||
|
||||
const targeted = applyPluginImporterDestination(
|
||||
merged,
|
||||
existingDrafts.hosts.length,
|
||||
{ mode: 'group', group: 'Chosen' },
|
||||
['Chosen'],
|
||||
);
|
||||
|
||||
assert.equal(targeted.hosts.length, 1);
|
||||
assert.equal(targeted.hosts[0]?.label, 'Existing');
|
||||
assert.equal(targeted.duplicateCount, merged.duplicateCount + 1);
|
||||
assert.equal(targeted.addedCount, 0);
|
||||
});
|
||||
|
||||
test('plugin importer destination prunes orphan credentials for skipped colliding hosts', () => {
|
||||
const existingDrafts = normalizePluginImporterRecords([{ type: 'draft', draft: {
|
||||
kind: 'host',
|
||||
value: {
|
||||
label: 'Existing',
|
||||
hostname: '10.10.10.10',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
group: 'Chosen',
|
||||
},
|
||||
} }]);
|
||||
const importedDrafts = normalizePluginImporterRecords([
|
||||
{ type: 'draft', draft: { kind: 'identity', value: {
|
||||
id: 'imported-identity',
|
||||
label: 'Imported identity',
|
||||
username: 'root',
|
||||
authMethod: 'password',
|
||||
password: 'secret',
|
||||
} } },
|
||||
{ type: 'draft', draft: { kind: 'key', value: {
|
||||
id: 'imported-key',
|
||||
label: 'Imported key',
|
||||
type: 'ED25519',
|
||||
privateKey: 'private-key-material',
|
||||
} } },
|
||||
{ type: 'draft', draft: { kind: 'host', value: {
|
||||
label: 'Imported copy',
|
||||
hostname: '10.10.10.10',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
group: 'lan',
|
||||
identityId: 'imported-identity',
|
||||
identityFileId: 'imported-key',
|
||||
} } },
|
||||
]);
|
||||
const merged = mergePluginImporterDrafts({
|
||||
hosts: existingDrafts.hosts,
|
||||
identities: [],
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: ['Chosen'],
|
||||
}, importedDrafts);
|
||||
|
||||
assert.equal(merged.hosts.length, 2);
|
||||
assert.equal(merged.identities.length, 1);
|
||||
assert.equal(merged.keys.length, 1);
|
||||
|
||||
const targeted = applyPluginImporterDestination(
|
||||
merged,
|
||||
existingDrafts.hosts.length,
|
||||
{ mode: 'group', group: 'Chosen' },
|
||||
['Chosen'],
|
||||
{ identities: 0, keys: 0 },
|
||||
);
|
||||
|
||||
assert.equal(targeted.hosts.length, 1);
|
||||
assert.deepEqual(targeted.identities, []);
|
||||
assert.deepEqual(targeted.keys, []);
|
||||
assert.equal(targeted.addedCount, 0);
|
||||
});
|
||||
|
||||
test('plugin importer destination keeps existing credentials remapped onto skipped hosts', () => {
|
||||
const existingDrafts = normalizePluginImporterRecords([
|
||||
{ type: 'draft', draft: { kind: 'identity', value: {
|
||||
id: 'existing-identity',
|
||||
label: 'Existing identity',
|
||||
username: 'root',
|
||||
authMethod: 'password',
|
||||
password: 'vault-secret',
|
||||
} } },
|
||||
{ type: 'draft', draft: { kind: 'host', value: {
|
||||
label: 'Existing',
|
||||
hostname: '10.10.10.10',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
group: 'Chosen',
|
||||
identityId: 'existing-identity',
|
||||
} } },
|
||||
]);
|
||||
const existingIdentityId = existingDrafts.identities[0]?.id;
|
||||
assert.ok(existingIdentityId);
|
||||
const importedDrafts = normalizePluginImporterRecords([
|
||||
{ type: 'draft', draft: { kind: 'identity', value: {
|
||||
id: 'imported-identity',
|
||||
label: 'Duplicate identity',
|
||||
username: 'root',
|
||||
authMethod: 'password',
|
||||
password: 'vault-secret',
|
||||
} } },
|
||||
{ type: 'draft', draft: { kind: 'host', value: {
|
||||
label: 'Imported copy',
|
||||
hostname: '10.10.10.10',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
group: 'lan',
|
||||
identityId: 'imported-identity',
|
||||
} } },
|
||||
]);
|
||||
const merged = mergePluginImporterDrafts({
|
||||
hosts: existingDrafts.hosts,
|
||||
identities: existingDrafts.identities,
|
||||
keys: [],
|
||||
snippets: [],
|
||||
customGroups: ['Chosen'],
|
||||
}, importedDrafts);
|
||||
|
||||
assert.equal(merged.identities.length, 1);
|
||||
assert.equal(merged.identities[0]?.id, existingIdentityId);
|
||||
assert.equal(merged.duplicateCount >= 1, true);
|
||||
|
||||
const targeted = applyPluginImporterDestination(
|
||||
merged,
|
||||
existingDrafts.hosts.length,
|
||||
{ mode: 'group', group: 'Chosen' },
|
||||
['Chosen'],
|
||||
{ identities: existingDrafts.identities.length, keys: 0 },
|
||||
);
|
||||
|
||||
assert.equal(targeted.hosts.length, 1);
|
||||
assert.equal(targeted.identities.length, 1);
|
||||
assert.equal(targeted.identities[0]?.id, existingIdentityId);
|
||||
});
|
||||
|
||||
test('plugin importer destination merges referenced credentials when collapsing imports', () => {
|
||||
const drafts = normalizePluginImporterRecords([
|
||||
{ type: 'draft', draft: { kind: 'identity', value: {
|
||||
id: 'imported-identity',
|
||||
label: 'Imported identity',
|
||||
username: 'root',
|
||||
authMethod: 'password',
|
||||
password: 'secret',
|
||||
} } },
|
||||
{ type: 'draft', draft: { kind: 'host', value: {
|
||||
label: 'bare',
|
||||
hostname: '10.10.10.10',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
group: 'lan',
|
||||
} } },
|
||||
{ type: 'draft', draft: { kind: 'host', value: {
|
||||
label: 'with-identity',
|
||||
hostname: '10.10.10.10',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
group: 'lan-proxy',
|
||||
identityId: 'imported-identity',
|
||||
} } },
|
||||
]);
|
||||
const merged = mergePluginImporterDrafts({
|
||||
hosts: [], identities: [], keys: [], snippets: [], customGroups: [],
|
||||
}, drafts);
|
||||
|
||||
assert.equal(merged.hosts.length, 2);
|
||||
assert.equal(merged.identities.length, 1);
|
||||
|
||||
const targeted = applyPluginImporterDestination(
|
||||
merged,
|
||||
0,
|
||||
{ mode: 'group', group: 'Chosen' },
|
||||
[],
|
||||
{ identities: 0, keys: 0 },
|
||||
);
|
||||
|
||||
assert.equal(targeted.hosts.length, 1);
|
||||
assert.equal(targeted.hosts[0]?.identityId, merged.identities[0]?.id);
|
||||
assert.equal(targeted.identities.length, 1);
|
||||
});
|
||||
|
||||
test('plugin importer does not count a host-owned destination group as newly added', () => {
|
||||
const existingDrafts = normalizePluginImporterRecords([{ type: 'draft', draft: {
|
||||
kind: 'host',
|
||||
|
||||
@@ -474,11 +474,29 @@ export function applyPluginImporterDestination(
|
||||
existingHostCount: number,
|
||||
destination?: VaultImportDestination,
|
||||
existingCustomGroups: ReadonlyArray<string> = [],
|
||||
existingCounts: {
|
||||
identities?: number;
|
||||
keys?: number;
|
||||
} = {},
|
||||
): PluginImporterMergeResult {
|
||||
if (!destination || destination.mode === 'preserve') return merged;
|
||||
const splitAt = Math.max(0, Math.min(existingHostCount, merged.hosts.length));
|
||||
const existingHosts = merged.hosts.slice(0, splitAt);
|
||||
const importedHosts = merged.hosts.slice(splitAt);
|
||||
const existingIdentityCount = Math.max(
|
||||
0,
|
||||
Math.min(existingCounts.identities ?? 0, merged.identities.length),
|
||||
);
|
||||
const existingKeyCount = Math.max(
|
||||
0,
|
||||
Math.min(existingCounts.keys ?? 0, merged.keys.length),
|
||||
);
|
||||
const existingIdentityIds = new Set(
|
||||
merged.identities.slice(0, existingIdentityCount).map((identity) => identity.id),
|
||||
);
|
||||
const existingKeyIds = new Set(
|
||||
merged.keys.slice(0, existingKeyCount).map((key) => key.id),
|
||||
);
|
||||
const targeted = applyVaultImportDestination({
|
||||
hosts: importedHosts,
|
||||
groups: [],
|
||||
@@ -489,7 +507,63 @@ export function applyPluginImporterDestination(
|
||||
skipped: 0,
|
||||
duplicates: 0,
|
||||
},
|
||||
}, destination);
|
||||
}, destination, {
|
||||
// Plugin hosts that differ by configuration/credentials must survive a
|
||||
// shared destination group; classic vault hosts still collapse by endpoint.
|
||||
isCollapsible: (host) => !host.pluginConnection,
|
||||
});
|
||||
// Destination rewrite can make an imported vault host collide with an existing
|
||||
// one (same endpoint, now same group). Re-check like the CSV import path.
|
||||
const existingFingerprints = new Set(existingHosts.map(hostFingerprint));
|
||||
const retainedImported: Host[] = [];
|
||||
for (const host of targeted.hosts) {
|
||||
const fingerprint = hostFingerprint(host);
|
||||
if (existingFingerprints.has(fingerprint)) continue;
|
||||
existingFingerprints.add(fingerprint);
|
||||
retainedImported.push(host);
|
||||
}
|
||||
const retainedImportedIds = new Set(retainedImported.map((host) => host.id));
|
||||
const droppedImported = importedHosts.filter((host) => !retainedImportedIds.has(host.id));
|
||||
const collectHostCredentialIds = (hosts: ReadonlyArray<Host>): Set<string> => {
|
||||
const ids = new Set<string>();
|
||||
for (const host of hosts) {
|
||||
if (host.identityId) ids.add(host.identityId);
|
||||
if (host.telnetIdentityId) ids.add(host.telnetIdentityId);
|
||||
if (host.identityFileId) ids.add(host.identityFileId);
|
||||
const pluginCredentialId = host.pluginConnection?.credentialId;
|
||||
if (pluginCredentialId) ids.add(pluginCredentialId);
|
||||
}
|
||||
return ids;
|
||||
};
|
||||
const keptCredentialIds = collectHostCredentialIds([...existingHosts, ...retainedImported]);
|
||||
const droppedOnlyCredentialIds = new Set(
|
||||
[...collectHostCredentialIds(droppedImported)].filter((id) => !keptCredentialIds.has(id)),
|
||||
);
|
||||
// Only prune credentials introduced by this import. Remapped duplicates that
|
||||
// point at pre-existing vault identities/keys must stay in the vault.
|
||||
const nextIdentities = merged.identities.filter((identity) => (
|
||||
existingIdentityIds.has(identity.id)
|
||||
|| !droppedOnlyCredentialIds.has(identity.id)
|
||||
));
|
||||
const keptIdentityKeyIds = new Set(
|
||||
nextIdentities.flatMap((identity) => (identity.keyId ? [identity.keyId] : [])),
|
||||
);
|
||||
const prunedIdentityKeyIds = new Set(
|
||||
merged.identities
|
||||
.filter((identity) => (
|
||||
!existingIdentityIds.has(identity.id)
|
||||
&& droppedOnlyCredentialIds.has(identity.id)
|
||||
))
|
||||
.flatMap((identity) => (identity.keyId ? [identity.keyId] : [])),
|
||||
);
|
||||
const nextKeys = merged.keys.filter((key) => {
|
||||
if (existingKeyIds.has(key.id)) return true;
|
||||
if (keptCredentialIds.has(key.id) || keptIdentityKeyIds.has(key.id)) return true;
|
||||
if (droppedOnlyCredentialIds.has(key.id) || prunedIdentityKeyIds.has(key.id)) return false;
|
||||
return true;
|
||||
});
|
||||
const prunedIdentityCount = merged.identities.length - nextIdentities.length;
|
||||
const prunedKeyCount = merged.keys.length - nextKeys.length;
|
||||
const existingGroupSet = new Set([
|
||||
...existingCustomGroups,
|
||||
...existingHosts.flatMap((host) => host.group ? [host.group] : []),
|
||||
@@ -501,11 +575,22 @@ export function applyPluginImporterDestination(
|
||||
const nextAddedGroupCount = customGroups.filter(
|
||||
(group) => !existingGroupSet.has(group),
|
||||
).length;
|
||||
// Destination rewriting can collapse same-endpoint hosts into one group;
|
||||
// keep added/duplicate counts aligned with the hosts actually retained.
|
||||
const collapsedHostCount = Math.max(0, importedHosts.length - retainedImported.length);
|
||||
return {
|
||||
...merged,
|
||||
hosts: [...existingHosts, ...targeted.hosts],
|
||||
hosts: [...existingHosts, ...retainedImported],
|
||||
identities: nextIdentities,
|
||||
keys: nextKeys,
|
||||
customGroups,
|
||||
addedCount: merged.addedCount - previousAddedGroupCount + nextAddedGroupCount,
|
||||
duplicateCount: merged.duplicateCount + collapsedHostCount,
|
||||
addedCount: merged.addedCount
|
||||
- previousAddedGroupCount
|
||||
+ nextAddedGroupCount
|
||||
- collapsedHostCount
|
||||
- prunedIdentityCount
|
||||
- prunedKeyCount,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -85,3 +85,33 @@ test('unknown prompt-shaped authentication boundaries fail closed', () => {
|
||||
assert.equal(isUntrustedTerminalInputPrompt('router> ', { allowHostStyleGreaterThan: true }), false);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('router> '), true);
|
||||
});
|
||||
|
||||
test('ordinary shell commands ending with a colon are not untrusted input prompts (#2709)', () => {
|
||||
// Broadcast pauses while passwordPromptActive is sticky. Typing `lsof -i:`
|
||||
// must not trip the fail-closed colon heuristic, or peers miss the rest.
|
||||
const commandLines = [
|
||||
'user@host:~$ lsof -i:',
|
||||
'bash-5.2$ lsof -i:',
|
||||
'root# lsof -i:',
|
||||
'PS C:\\Users\\alice> lsof -i:',
|
||||
'C:\\Users\\alice> lsof -i:',
|
||||
];
|
||||
for (const line of commandLines) {
|
||||
assert.equal(isUntrustedTerminalInputPrompt(line), false, line);
|
||||
}
|
||||
assert.equal(
|
||||
isUntrustedTerminalInputPrompt('router> show ip:', { allowHostStyleGreaterThan: true }),
|
||||
false,
|
||||
);
|
||||
// Bare host-style prompts stay fail-closed unless explicitly allowed.
|
||||
assert.equal(isUntrustedTerminalInputPrompt('router> show ip:'), true);
|
||||
// Mid-label `#` is not a prompt boundary (no whitespace after `#`).
|
||||
assert.equal(isUntrustedTerminalInputPrompt('Challenge #1:'), true);
|
||||
// Markers inside English challenge labels must not look like a shell PS1.
|
||||
assert.equal(isUntrustedTerminalInputPrompt('Challenge # 1:'), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('Account $ code:'), true);
|
||||
// Standalone auth-shaped lines without a confirmed shell prompt still fail closed.
|
||||
assert.equal(isUntrustedTerminalInputPrompt('Please authenticate: '), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('Token: '), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('Password: '), true);
|
||||
});
|
||||
@@ -134,6 +134,52 @@ export function shouldUsePluginTerminalCompletionProvider(input: {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Body text before a `$` / `#` / `%` terminator that is plausible as a shell
|
||||
* PS1 (bare marker, user@host, path, shell-version, or lowercase identity).
|
||||
* Rejects English challenge labels that only happen to end with those markers
|
||||
* (`Challenge #`, `Account $`).
|
||||
*/
|
||||
function isPlausibleShellPromptBody(body: string): boolean {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return true;
|
||||
if (/@|[/\\~:]/u.test(trimmed)) return true;
|
||||
if (/^(?:bash|zsh|sh|fish|ksh|csh|tcsh|dash)(?:-[\d.]+)?$/iu.test(trimmed)) return true;
|
||||
// Single lowercase identity token: root, ubuntu, pi — not Title-Case labels.
|
||||
return /^[a-z0-9_.-]+$/u.test(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a confirmed shell/device prompt is followed by typed text on the
|
||||
* same logical line (e.g. `user@host:~$ lsof -i:`). Trailing `:` / `>` in that
|
||||
* typed text is ordinary command input, not an authentication boundary.
|
||||
*
|
||||
* Requires a real prompt boundary (terminator + whitespace) so mid-label
|
||||
* shapes like `Challenge #1:` stay fail-closed. For `$`/`#`/`%`, also requires
|
||||
* a plausible PS1 body so `Challenge # 1:` / `Account $ code:` stay untrusted.
|
||||
*/
|
||||
function hasTypedInputAfterConfirmedPrompt(
|
||||
line: string,
|
||||
options: ConfirmedPromptOptions = {},
|
||||
): boolean {
|
||||
for (let i = 0; i < line.length - 1; i += 1) {
|
||||
const ch = line[i];
|
||||
// Only terminators that can end a confirmed prompt without relying on a
|
||||
// glyph appearing later in the typed command.
|
||||
if (ch !== '$' && ch !== '#' && ch !== '%' && ch !== '>') continue;
|
||||
const rest = line.slice(i + 1);
|
||||
// Prompt terminators are followed by whitespace before typed input.
|
||||
if (!/^\s+\S/u.test(rest)) continue;
|
||||
const candidate = line.slice(0, i + 1);
|
||||
if (!isConfirmedTerminalShellPrompt(candidate, options)) continue;
|
||||
if ((ch === '$' || ch === '#' || ch === '%') && !isPlausibleShellPromptBody(candidate.slice(0, -1))) {
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail closed for prompt-shaped input boundaries that are not positively
|
||||
* identified as an ordinary shell/device prompt. This protects custom PAM,
|
||||
@@ -147,6 +193,9 @@ export function isUntrustedTerminalInputPrompt(
|
||||
if (!prompt) return false;
|
||||
if (isSensitiveTerminalChallenge(prompt)) return true;
|
||||
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.
|
||||
if (hasTypedInputAfterConfirmedPrompt(prompt, options)) return false;
|
||||
return !isConfirmedTerminalShellPrompt(prompt, options);
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,22 @@ test("normalizeTerminalSettings preserves a hidden host information bar", () =>
|
||||
assert.equal(normalizeTerminalSettings({ showHostInfoBar: false }).showHostInfoBar, false);
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings defaults host info bar title mode to address", () => {
|
||||
assert.equal(normalizeTerminalSettings().hostInfoBarTitleMode, "address");
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings preserves supported host info bar title modes", () => {
|
||||
assert.equal(normalizeTerminalSettings({ hostInfoBarTitleMode: "label" }).hostInfoBarTitleMode, "label");
|
||||
assert.equal(normalizeTerminalSettings({ hostInfoBarTitleMode: "address" }).hostInfoBarTitleMode, "address");
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings falls back for unsupported host info bar title modes", () => {
|
||||
assert.equal(
|
||||
normalizeTerminalSettings({ hostInfoBarTitleMode: "both" as never }).hostInfoBarTitleMode,
|
||||
"address",
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings disables hibernate for hidden tabs by default", () => {
|
||||
assert.equal(normalizeTerminalSettings().hibernateHiddenTabs, false);
|
||||
assert.equal(normalizeTerminalSettings().hibernateHiddenTabsDelaySec, 5);
|
||||
|
||||
@@ -7,13 +7,40 @@ import {
|
||||
applyVaultHostDelete,
|
||||
applyVaultHostCreates,
|
||||
applyVaultHostUpdate,
|
||||
buildVaultHostEndpointKey,
|
||||
buildVaultHostFromDraft,
|
||||
buildVaultHostMergeKey,
|
||||
buildVaultHostsFromDrafts,
|
||||
parseVaultHostDraftsInput,
|
||||
type VaultHostDraft,
|
||||
type VaultHostUpdatePatch,
|
||||
} from './vaultHostCreate.ts';
|
||||
|
||||
test('buildVaultHostMergeKey treats group as part of session identity', () => {
|
||||
const base = {
|
||||
hostname: '10.10.10.10',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
protocol: 'ssh' as const,
|
||||
};
|
||||
assert.notEqual(
|
||||
buildVaultHostMergeKey({ ...base, group: 'lan' }),
|
||||
buildVaultHostMergeKey({ ...base, group: 'lan-proxy' }),
|
||||
);
|
||||
assert.equal(
|
||||
buildVaultHostMergeKey({ ...base, group: 'lan' }),
|
||||
buildVaultHostMergeKey({ ...base, group: 'lan' }),
|
||||
);
|
||||
assert.notEqual(
|
||||
buildVaultHostMergeKey({ ...base, group: 'Prod' }),
|
||||
buildVaultHostMergeKey({ ...base, group: 'prod' }),
|
||||
);
|
||||
assert.equal(
|
||||
buildVaultHostEndpointKey({ ...base, group: 'lan' }),
|
||||
buildVaultHostEndpointKey({ ...base, group: 'lan-proxy' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('buildVaultHostFromDraft maps minimal unstructured fields to a vault host', () => {
|
||||
const built = buildVaultHostFromDraft({
|
||||
hostname: '192.168.1.10',
|
||||
|
||||
@@ -169,11 +169,25 @@ const parseKeyPath = (draft: VaultHostDraft): string | undefined => {
|
||||
return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined;
|
||||
};
|
||||
|
||||
export const buildVaultHostMergeKey = (
|
||||
/** Endpoint-only identity (protocol/host/port/user). Used for managed ssh_config matching. */
|
||||
export const buildVaultHostEndpointKey = (
|
||||
host: Pick<Host, 'hostname' | 'port' | 'username' | 'protocol'>,
|
||||
): string =>
|
||||
`${(host.protocol ?? 'ssh').toLowerCase()}|${host.hostname.toLowerCase()}|${host.port}|${(host.username ?? '').toLowerCase()}`;
|
||||
|
||||
/**
|
||||
* Session identity for vault import/create dedupe.
|
||||
* Same endpoint in a different group is a distinct connection (e.g. direct vs proxy copy).
|
||||
*/
|
||||
export const buildVaultHostMergeKey = (
|
||||
host: Pick<Host, 'hostname' | 'port' | 'username' | 'protocol' | 'group'>,
|
||||
): string => {
|
||||
// Keep normalized group spelling (do not case-fold). Vault group paths are
|
||||
// compared exactly elsewhere, so Prod vs prod are distinct sessions.
|
||||
const group = normalizeGroupPath(host.group) ?? '';
|
||||
return `${buildVaultHostEndpointKey(host)}|${group}`;
|
||||
};
|
||||
|
||||
export function buildVaultHostFromDraft(
|
||||
draft: VaultHostDraft,
|
||||
): { ok: true; host: Host } | { ok: false; error: string } {
|
||||
|
||||
@@ -185,6 +185,185 @@ test("vault import can place every imported host into one selected group", () =>
|
||||
assert.deepEqual(imported.groups, ["Production/Web", "Production/DB"]);
|
||||
});
|
||||
|
||||
test("applyVaultImportDestination re-dedupes same-endpoint hosts collapsed into one group", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Label,Hostname,Port,Username",
|
||||
"lan,direct,10.10.10.10,22,root",
|
||||
"lan-proxy,via-socks,10.10.10.10,22,root",
|
||||
].join("\n"));
|
||||
|
||||
assert.equal(imported.hosts.length, 2);
|
||||
|
||||
const targeted = applyVaultImportDestination(imported, {
|
||||
mode: "group",
|
||||
group: "Imported/July",
|
||||
});
|
||||
|
||||
assert.equal(targeted.hosts.length, 1);
|
||||
assert.equal(targeted.hosts[0]?.group, "Imported/July");
|
||||
assert.equal(targeted.stats.duplicates, 1);
|
||||
assert.equal(targeted.stats.imported, 1);
|
||||
assert.deepEqual(targeted.groups, ["Imported/July"]);
|
||||
|
||||
const merged = applyVaultHostImport([], [], targeted);
|
||||
assert.equal(merged.addedCount, 1);
|
||||
assert.equal(merged.hosts.length, 1);
|
||||
});
|
||||
|
||||
test("applyVaultImportDestination remaps a shared key passphrase onto the retained host", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Label,Hostname,Port,Username,KeyPath,Passphrase",
|
||||
"lan,direct,10.10.10.10,22,root,~/.ssh/id_ed25519,",
|
||||
"lan-proxy,via-socks,10.10.10.10,22,root,~/.ssh/id_ed25519,secret",
|
||||
].join("\n"));
|
||||
|
||||
assert.equal(imported.hosts.length, 2);
|
||||
|
||||
const targeted = applyVaultImportDestination(imported, {
|
||||
mode: "group",
|
||||
group: "Imported/July",
|
||||
});
|
||||
|
||||
assert.equal(targeted.hosts.length, 1);
|
||||
assert.deepEqual(targeted.hosts[0]?.identityFilePaths, ["~/.ssh/id_ed25519"]);
|
||||
assert.deepEqual(targeted.keyPassphrases, [{
|
||||
hostId: targeted.hosts[0]?.id,
|
||||
keyPath: "~/.ssh/id_ed25519",
|
||||
passphrase: "secret",
|
||||
}]);
|
||||
});
|
||||
|
||||
test("applyVaultImportDestination does not attach a passphrase for a non-retained key", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Label,Hostname,Port,Username,KeyPath,Passphrase",
|
||||
"lan,direct,10.10.10.10,22,root,~/.ssh/id_first,first",
|
||||
"lan-proxy,via-socks,10.10.10.10,22,root,~/.ssh/id_second,second",
|
||||
].join("\n"));
|
||||
|
||||
const targeted = applyVaultImportDestination(imported, {
|
||||
mode: "group",
|
||||
group: "Imported/July",
|
||||
});
|
||||
|
||||
assert.equal(targeted.hosts.length, 1);
|
||||
assert.deepEqual(targeted.hosts[0]?.identityFilePaths, ["~/.ssh/id_first"]);
|
||||
assert.deepEqual(targeted.keyPassphrases, [{
|
||||
hostId: targeted.hosts[0]?.id,
|
||||
keyPath: "~/.ssh/id_first",
|
||||
passphrase: "first",
|
||||
}]);
|
||||
});
|
||||
|
||||
test("applyVaultImportDestination can keep same-endpoint hosts when collapse is disabled", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Label,Hostname,Port,Username",
|
||||
"Prod,web-a,shared.example.com,22,root",
|
||||
"Staging,web-b,shared.example.com,22,root",
|
||||
].join("\n"));
|
||||
|
||||
assert.equal(imported.hosts.length, 2);
|
||||
|
||||
const targeted = applyVaultImportDestination(
|
||||
imported,
|
||||
{ mode: "group", group: "Imported/SecureCRT" },
|
||||
{ collapseDuplicateEndpoints: false },
|
||||
);
|
||||
|
||||
assert.equal(targeted.hosts.length, 2);
|
||||
assert.deepEqual(targeted.hosts.map((host) => host.group), [
|
||||
"Imported/SecureCRT",
|
||||
"Imported/SecureCRT",
|
||||
]);
|
||||
assert.equal(targeted.stats.duplicates, 0);
|
||||
assert.equal(targeted.stats.imported, 2);
|
||||
});
|
||||
|
||||
test("applyVaultImportDestination can skip collapse for selected hosts", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Label,Hostname,Port,Username",
|
||||
"lan,direct,10.10.10.10,22,root",
|
||||
"lan-proxy,via-socks,10.10.10.10,22,root",
|
||||
].join("\n"));
|
||||
const withPluginFlag = {
|
||||
...imported,
|
||||
hosts: imported.hosts.map((host, index) => (
|
||||
index === 0
|
||||
? host
|
||||
: {
|
||||
...host,
|
||||
pluginConnection: {
|
||||
providerId: "com.example.transport.connection",
|
||||
configuration: { endpoint: "via-socks" },
|
||||
},
|
||||
}
|
||||
)),
|
||||
};
|
||||
|
||||
const targeted = applyVaultImportDestination(
|
||||
withPluginFlag,
|
||||
{ mode: "group", group: "Imported/Plugin" },
|
||||
{ isCollapsible: (host) => !host.pluginConnection },
|
||||
);
|
||||
|
||||
assert.equal(targeted.hosts.length, 2);
|
||||
assert.deepEqual(targeted.hosts.map((host) => host.group), [
|
||||
"Imported/Plugin",
|
||||
"Imported/Plugin",
|
||||
]);
|
||||
});
|
||||
|
||||
test("applyVaultImportDestination merges referenced credentials onto the retained host", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Label,Hostname,Port,Username",
|
||||
"lan,direct,10.10.10.10,22,root",
|
||||
"lan-proxy,via-socks,10.10.10.10,22,root",
|
||||
].join("\n"));
|
||||
const [first, second] = imported.hosts;
|
||||
assert.ok(first && second);
|
||||
const withRefs = {
|
||||
...imported,
|
||||
hosts: [
|
||||
{ ...first, identityId: undefined, identityFileId: undefined },
|
||||
{ ...second, identityId: "identity-1", identityFileId: "key-1" },
|
||||
],
|
||||
};
|
||||
|
||||
const targeted = applyVaultImportDestination(withRefs, {
|
||||
mode: "group",
|
||||
group: "Imported/July",
|
||||
});
|
||||
|
||||
assert.equal(targeted.hosts.length, 1);
|
||||
assert.equal(targeted.hosts[0]?.identityId, "identity-1");
|
||||
assert.equal(targeted.hosts[0]?.identityFileId, "key-1");
|
||||
});
|
||||
|
||||
test("applyVaultImportDestination does not override retained key auth with identity refs", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Label,Hostname,Port,Username,KeyPath",
|
||||
"lan,direct,10.10.10.10,22,root,~/.ssh/id_ed25519",
|
||||
"lan-proxy,via-socks,10.10.10.10,22,root,",
|
||||
].join("\n"));
|
||||
const [first, second] = imported.hosts;
|
||||
assert.ok(first && second);
|
||||
const withRefs = {
|
||||
...imported,
|
||||
hosts: [
|
||||
first,
|
||||
{ ...second, identityId: "identity-1" },
|
||||
],
|
||||
};
|
||||
|
||||
const targeted = applyVaultImportDestination(withRefs, {
|
||||
mode: "group",
|
||||
group: "Imported/July",
|
||||
});
|
||||
|
||||
assert.equal(targeted.hosts.length, 1);
|
||||
assert.deepEqual(targeted.hosts[0]?.identityFilePaths, ["~/.ssh/id_ed25519"]);
|
||||
assert.equal(targeted.hosts[0]?.identityId, undefined);
|
||||
});
|
||||
|
||||
test("CSV import keeps working when KeyPath and Passphrase columns are absent", () => {
|
||||
const result = importVaultHostsFromText(
|
||||
"csv",
|
||||
@@ -614,6 +793,69 @@ test("applyVaultHostImport skips duplicates by default", () => {
|
||||
assert.equal(merged.hosts.length, 2);
|
||||
});
|
||||
|
||||
test("CSV import keeps same-endpoint rows that use different groups", () => {
|
||||
const result = importVaultHostsFromText(
|
||||
"csv",
|
||||
[
|
||||
"Groups,Label,Hostname,Port,Username",
|
||||
"lan,direct,10.10.10.10,22,root",
|
||||
"lan-proxy,via-socks,10.10.10.10,22,root",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
assert.equal(result.hosts.length, 2);
|
||||
assert.equal(result.stats.duplicates, 0);
|
||||
assert.deepEqual(
|
||||
result.hosts.map((host) => host.group).sort(),
|
||||
["lan", "lan-proxy"],
|
||||
);
|
||||
});
|
||||
|
||||
test("applyVaultHostImport keeps same-endpoint hosts when the group differs", () => {
|
||||
const existing: Host = {
|
||||
id: "existing-1",
|
||||
label: "direct",
|
||||
hostname: "10.10.10.10",
|
||||
username: "root",
|
||||
port: 22,
|
||||
group: "lan",
|
||||
};
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Label,Hostname,Port,Username",
|
||||
"lan-proxy,via-socks,10.10.10.10,22,root",
|
||||
].join("\n"));
|
||||
const targeted = applyVaultImportDestination(imported, {
|
||||
mode: "group",
|
||||
group: "lan-proxy",
|
||||
});
|
||||
|
||||
const merged = applyVaultHostImport([existing], ["lan"], targeted);
|
||||
assert.equal(merged.addedCount, 1);
|
||||
assert.equal(merged.skippedExistingCount, 0);
|
||||
assert.equal(merged.hosts.length, 2);
|
||||
assert.equal(merged.addedHosts[0]?.group, "lan-proxy");
|
||||
});
|
||||
|
||||
test("applyVaultHostImport still skips same-endpoint hosts in the same group", () => {
|
||||
const existing: Host = {
|
||||
id: "existing-1",
|
||||
label: "direct",
|
||||
hostname: "10.10.10.10",
|
||||
username: "root",
|
||||
port: 22,
|
||||
group: "lan",
|
||||
};
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Label,Hostname,Port,Username",
|
||||
"lan,copy,10.10.10.10,22,root",
|
||||
].join("\n"));
|
||||
|
||||
const merged = applyVaultHostImport([existing], ["lan"], imported);
|
||||
assert.equal(merged.addedCount, 0);
|
||||
assert.equal(merged.skippedExistingCount, 1);
|
||||
assert.equal(merged.hosts.length, 1);
|
||||
});
|
||||
|
||||
test("applyVaultHostImport can preserve distinct sessions with the same endpoint", () => {
|
||||
const existing: Host = {
|
||||
id: "existing-1",
|
||||
|
||||
+119
-4
@@ -9,7 +9,10 @@ import {
|
||||
type VaultHostDraftProtocol,
|
||||
} from "./vaultHostCreate";
|
||||
|
||||
export { buildVaultHostMergeKey } from "./vaultHostCreate";
|
||||
export {
|
||||
buildVaultHostEndpointKey,
|
||||
buildVaultHostMergeKey,
|
||||
} from "./vaultHostCreate";
|
||||
import { parseQuickConnectInput } from "./quickConnect";
|
||||
import { findExactHeaderIndex, findHeaderIndex, parseCsv } from "./vaultImport/csvUtils";
|
||||
import { decodeCsvKeyPath, decodeCsvPassphrase } from "./vaultImport/csvCredentialFields";
|
||||
@@ -136,6 +139,20 @@ export type VaultImportDestination =
|
||||
| { mode: "preserve" }
|
||||
| { mode: "group"; group: string };
|
||||
|
||||
export type ApplyVaultImportDestinationOptions = {
|
||||
/**
|
||||
* When false, only rewrite groups. SecureCRT keeps distinct session files that
|
||||
* share an endpoint even after an import-location override.
|
||||
* @default true
|
||||
*/
|
||||
collapseDuplicateEndpoints?: boolean;
|
||||
/**
|
||||
* Return false to keep a host even when another shares its merge key after the
|
||||
* destination rewrite (plugin hosts that differ by configuration/credentials).
|
||||
*/
|
||||
isCollapsible?: (host: Host) => boolean;
|
||||
};
|
||||
|
||||
export function mergeVaultImportIssues(
|
||||
...groups: ReadonlyArray<ReadonlyArray<VaultImportIssue>>
|
||||
): VaultImportIssue[] {
|
||||
@@ -162,14 +179,89 @@ const normalizeGroupPath = (raw: string | undefined): string | undefined => {
|
||||
export function applyVaultImportDestination(
|
||||
result: VaultImportResult,
|
||||
destination: VaultImportDestination,
|
||||
options?: ApplyVaultImportDestinationOptions,
|
||||
): VaultImportResult {
|
||||
if (destination.mode === "preserve") return result;
|
||||
const group = normalizeGroupPath(destination.group);
|
||||
if (!group) return result;
|
||||
|
||||
const rewrittenHosts = result.hosts.map((host) => ({ ...host, group }));
|
||||
// SecureCRT (and similar) keep same-endpoint sessions as distinct imports.
|
||||
if (options?.collapseDuplicateEndpoints === false) {
|
||||
return {
|
||||
...result,
|
||||
hosts: rewrittenHosts,
|
||||
groups: [group],
|
||||
};
|
||||
}
|
||||
|
||||
// Destination rewriting can collapse previously distinct group-scoped sessions
|
||||
// onto one merge key; re-dedupe so identical endpoints are not imported twice.
|
||||
// Plugin hosts can opt out via isCollapsible so configuration/credential
|
||||
// differences survive a shared destination group.
|
||||
const { hosts, duplicates } = dedupeHosts(rewrittenHosts, {
|
||||
isCollapsible: options?.isCollapsible,
|
||||
});
|
||||
const retainedIds = new Set(hosts.map((host) => host.id));
|
||||
const retainedByMergeKey = new Map(
|
||||
hosts
|
||||
.filter((host) => options?.isCollapsible?.(host) !== false)
|
||||
.map((host) => [hostKey(host), host]),
|
||||
);
|
||||
const originalById = new Map(result.hosts.map((host) => [host.id, host]));
|
||||
|
||||
const remapHostId = (hostId: string): string | undefined => {
|
||||
if (retainedIds.has(hostId)) return hostId;
|
||||
const original = originalById.get(hostId);
|
||||
if (!original) return undefined;
|
||||
if (options?.isCollapsible?.({ ...original, group }) === false) return undefined;
|
||||
return retainedByMergeKey.get(hostKey({ ...original, group }))?.id;
|
||||
};
|
||||
|
||||
const selectedKeyByHostId = new Map(
|
||||
hosts.map((host) => {
|
||||
const selected = host.identityFilePaths?.find((path) => path.trim())?.trim();
|
||||
return [host.id, selected ? normalizeKeyPathKey(selected) : undefined] as const;
|
||||
}),
|
||||
);
|
||||
|
||||
const remapKeyPassphrases = (
|
||||
entries: VaultHostKeyPassphrase[] | undefined,
|
||||
remapOptions?: { matchSelectedKey?: boolean },
|
||||
): VaultHostKeyPassphrase[] | undefined => {
|
||||
if (!entries) return entries;
|
||||
const remapped: VaultHostKeyPassphrase[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
const nextHostId = remapHostId(entry.hostId);
|
||||
if (!nextHostId) continue;
|
||||
// Match CSV same-group dedupe: never attach a passphrase for a key the
|
||||
// retained host did not keep as its selected identity file.
|
||||
if (remapOptions?.matchSelectedKey) {
|
||||
const selectedKey = selectedKeyByHostId.get(nextHostId);
|
||||
if (!selectedKey || normalizeKeyPathKey(entry.keyPath) !== selectedKey) continue;
|
||||
}
|
||||
const dedupeKey = `${nextHostId}\u0000${normalizeKeyPathKey(entry.keyPath)}\u0000${entry.passphrase}`;
|
||||
if (seen.has(dedupeKey)) continue;
|
||||
seen.add(dedupeKey);
|
||||
remapped.push(nextHostId === entry.hostId ? entry : { ...entry, hostId: nextHostId });
|
||||
}
|
||||
return remapped;
|
||||
};
|
||||
|
||||
return {
|
||||
...result,
|
||||
hosts: result.hosts.map((host) => ({ ...host, group })),
|
||||
hosts,
|
||||
groups: [group],
|
||||
keyPassphrases: remapKeyPassphrases(result.keyPassphrases, { matchSelectedKey: true }),
|
||||
// Keep remapped candidates for alias conflict checks (same as CSV same-group
|
||||
// dedupe); the import handler still gates saves on the retained key path.
|
||||
keyPassphraseCandidates: remapKeyPassphrases(result.keyPassphraseCandidates),
|
||||
stats: {
|
||||
...result.stats,
|
||||
imported: hosts.length,
|
||||
duplicates: result.stats.duplicates + duplicates,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -227,15 +319,24 @@ const createHost = (input: {
|
||||
return built.host;
|
||||
};
|
||||
|
||||
const dedupeHosts = (hosts: Host[]): { hosts: Host[]; duplicates: number } => {
|
||||
const dedupeHosts = (
|
||||
hosts: Host[],
|
||||
options?: { isCollapsible?: (host: Host) => boolean },
|
||||
): { hosts: Host[]; duplicates: number } => {
|
||||
const seen = new Map<string, Host>();
|
||||
const retained: Host[] = [];
|
||||
let duplicates = 0;
|
||||
|
||||
for (const host of hosts) {
|
||||
if (options?.isCollapsible?.(host) === false) {
|
||||
retained.push(host);
|
||||
continue;
|
||||
}
|
||||
const key = hostKey(host);
|
||||
const existing = seen.get(key);
|
||||
if (!existing) {
|
||||
seen.set(key, host);
|
||||
retained.push(host);
|
||||
continue;
|
||||
}
|
||||
duplicates++;
|
||||
@@ -248,13 +349,27 @@ const dedupeHosts = (hosts: Host[]): { hosts: Host[]; duplicates: number } => {
|
||||
existing.authPolicyVersion = host.authPolicyVersion;
|
||||
existing.useSshAgent = host.useSshAgent;
|
||||
}
|
||||
// Only graft referenced credentials onto a retained host that has no auth
|
||||
// source yet. Otherwise identity auth can override an existing key path.
|
||||
const retainedHasCredentialSource = Boolean(
|
||||
existing.identityId
|
||||
|| existing.telnetIdentityId
|
||||
|| existing.identityFileId
|
||||
|| existing.identityFilePaths?.some((path) => path.trim())
|
||||
|| (typeof existing.password === "string" && existing.password.length > 0)
|
||||
);
|
||||
if (!retainedHasCredentialSource) {
|
||||
if (host.identityId) existing.identityId = host.identityId;
|
||||
if (host.telnetIdentityId) existing.telnetIdentityId = host.telnetIdentityId;
|
||||
if (host.identityFileId) existing.identityFileId = host.identityFileId;
|
||||
}
|
||||
if (existing.group == null && host.group != null) existing.group = host.group;
|
||||
if (existing.label === existing.hostname && host.label && host.label !== host.hostname) {
|
||||
existing.label = host.label;
|
||||
}
|
||||
}
|
||||
|
||||
return { hosts: Array.from(seen.values()), duplicates };
|
||||
return { hosts: retained, duplicates };
|
||||
};
|
||||
|
||||
const uniq = (items: string[]) => Array.from(new Set(items.filter(Boolean)));
|
||||
|
||||
@@ -287,8 +287,26 @@ function translateOpenCodeEvent(event, emitter, state = {}) {
|
||||
emitter.toolResult(callId, part.state.output || "", toolName);
|
||||
}
|
||||
} else if (part.state?.status === "error") {
|
||||
emitter.emitError(part.state.error || "OpenCode tool failed");
|
||||
return { idle: false, error: true, content: false };
|
||||
// Tool-level failures must not abort the whole OpenCode turn. Other
|
||||
// drivers (Cursor / Codex / Grok) surface tool errors as tool results
|
||||
// so the model can adapt and continue multi-step work (issue #2718).
|
||||
state.toolCalls = state.toolCalls || new Set();
|
||||
if (!state.toolCalls.has(callId)) {
|
||||
state.toolCalls.add(callId);
|
||||
emitter.toolCall(toolName, input, callId);
|
||||
}
|
||||
state.toolResults = state.toolResults || new Set();
|
||||
if (!state.toolResults.has(callId)) {
|
||||
state.toolResults.add(callId);
|
||||
// Prefer non-empty error, then output, then a stable default (blank
|
||||
// string error must not hide a useful output payload).
|
||||
const rawError = part.state.error || part.state.output || "OpenCode tool failed";
|
||||
const errorText = typeof rawError === "string"
|
||||
? rawError
|
||||
: (extractOpenCodeErrorMessage(rawError) || "OpenCode tool failed");
|
||||
emitter.toolResult(callId, errorText, toolName);
|
||||
}
|
||||
return { idle: false, error: false, content: true };
|
||||
}
|
||||
}
|
||||
return { idle: false, error: false, content: part.type === "tool" };
|
||||
|
||||
@@ -403,6 +403,101 @@ test("translateOpenCodeEvent emits a tool call before a result when only complet
|
||||
]);
|
||||
});
|
||||
|
||||
test("translateOpenCodeEvent maps tool status error to toolResult without failing the turn (#2718)", () => {
|
||||
const { events, emitter } = collector();
|
||||
const state = {};
|
||||
const result = translateOpenCodeEvent(
|
||||
{
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
type: "tool",
|
||||
callID: "tool-err",
|
||||
tool: "netcatty-remote-hosts_terminal_execute",
|
||||
state: {
|
||||
status: "error",
|
||||
input: { command: "du /missing" },
|
||||
error: "Error: Operation failed",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
emitter,
|
||||
state,
|
||||
);
|
||||
|
||||
assert.equal(result.error, false);
|
||||
assert.equal(result.content, true);
|
||||
assert.deepEqual(events, [
|
||||
{
|
||||
k: "toolCall",
|
||||
name: "netcatty-remote-hosts_terminal_execute",
|
||||
args: { command: "du /missing" },
|
||||
id: "tool-err",
|
||||
},
|
||||
{
|
||||
k: "toolResult",
|
||||
id: "tool-err",
|
||||
out: "Error: Operation failed",
|
||||
name: "netcatty-remote-hosts_terminal_execute",
|
||||
},
|
||||
]);
|
||||
assert.equal(events.some((event) => event.k === "error"), false);
|
||||
});
|
||||
|
||||
test("runOpenCodeTurn continues after a tool error part and reaches session.idle (#2718)", async () => {
|
||||
const { events, emitter } = collector();
|
||||
const abortController = new AbortController();
|
||||
const stream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
type: "tool",
|
||||
sessionID: "sess-1",
|
||||
callID: "t1",
|
||||
tool: "terminal_execute",
|
||||
state: { status: "error", input: { command: "false" }, error: "Error: Operation failed" },
|
||||
},
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
type: "text",
|
||||
sessionID: "sess-1",
|
||||
id: "p1",
|
||||
text: "retrying",
|
||||
},
|
||||
delta: "retrying",
|
||||
},
|
||||
};
|
||||
yield { type: "session.idle", properties: { sessionID: "sess-1" } };
|
||||
},
|
||||
};
|
||||
const client = {
|
||||
global: { event: async () => ({ stream }) },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "sess-1" } }),
|
||||
promptAsync: async () => ({ data: true }),
|
||||
},
|
||||
};
|
||||
|
||||
await runOpenCodeTurn({
|
||||
prompt: "run false then continue",
|
||||
emitter,
|
||||
abortController,
|
||||
openCodeFactory: async () => ({ client, server: { close() {} } }),
|
||||
});
|
||||
|
||||
assert.ok(events.some((event) => event.k === "toolResult" && event.out === "Error: Operation failed"));
|
||||
assert.ok(events.some((event) => event.k === "text" && event.t === "retrying"));
|
||||
assert.ok(events.some((event) => event.k === "done"));
|
||||
assert.equal(events.some((event) => event.k === "error"), false);
|
||||
});
|
||||
|
||||
test("mapOpenCodeModels flattens providers", () => {
|
||||
assert.deepEqual(mapOpenCodeModels({
|
||||
providers: [
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
const { invalidateSshTransport } = require("./sshTransportInvalidation.cjs");
|
||||
|
||||
const DEFAULT_SSH_CHANNEL_OPEN_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_SSH_CHANNEL_OPEN_RATE_LIMIT_RETRIES = 3;
|
||||
const DEFAULT_SSH_CHANNEL_OPEN_RATE_LIMIT_BACKOFF_MS = 150;
|
||||
|
||||
function closeLateChannel(channel) {
|
||||
if (!channel || typeof channel === "number") return;
|
||||
@@ -19,6 +21,22 @@ function channelAbortError(signal, label) {
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bastion / jump hosts sometimes reject rapid session channel opens with a
|
||||
* distinctive rate-limit message. The common Chinese bastion typo "offen"
|
||||
* (for "often") is part of the real wire text we see in the field.
|
||||
*/
|
||||
function isSshChannelOpenRateLimitedError(error) {
|
||||
const message = String(error?.message || error || "");
|
||||
return /channelOpen\s+too\s+offen\b/i.test(message)
|
||||
|| /channelOpen\s+too\s+often\b/i.test(message);
|
||||
}
|
||||
|
||||
function sleep(ms, sleepFn = null) {
|
||||
if (typeof sleepFn === "function") return Promise.resolve(sleepFn(ms));
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function openBoundedSshChannel(sshClient, invoke, options = {}) {
|
||||
const label = String(options.label || "SSH channel open");
|
||||
const timeoutMs = Math.max(
|
||||
@@ -76,16 +94,43 @@ function openBoundedSshChannel(sshClient, invoke, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function openBoundedSshShell(sshClient, windowOptions, shellOptions, options = {}) {
|
||||
return openBoundedSshChannel(
|
||||
sshClient,
|
||||
(callback) => sshClient.shell(windowOptions, shellOptions, callback),
|
||||
{
|
||||
...options,
|
||||
label: options.label || "SSH shell channel open",
|
||||
timeoutCode: "SSH_SHELL_OPEN_TIMEOUT",
|
||||
},
|
||||
async function openBoundedSshShell(sshClient, windowOptions, shellOptions, options = {}) {
|
||||
const rateLimitRetries = Math.max(
|
||||
0,
|
||||
Number.isFinite(options.rateLimitRetries)
|
||||
? Number(options.rateLimitRetries)
|
||||
: DEFAULT_SSH_CHANNEL_OPEN_RATE_LIMIT_RETRIES,
|
||||
);
|
||||
const rateLimitBackoffMs = Math.max(
|
||||
1,
|
||||
Number(options.rateLimitBackoffMs) || DEFAULT_SSH_CHANNEL_OPEN_RATE_LIMIT_BACKOFF_MS,
|
||||
);
|
||||
const sleepFn = options.sleepFn || null;
|
||||
let attempt = 0;
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
return await openBoundedSshChannel(
|
||||
sshClient,
|
||||
(callback) => sshClient.shell(windowOptions, shellOptions, callback),
|
||||
{
|
||||
...options,
|
||||
label: options.label || "SSH shell channel open",
|
||||
timeoutCode: "SSH_SHELL_OPEN_TIMEOUT",
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
attempt >= rateLimitRetries
|
||||
|| !isSshChannelOpenRateLimitedError(error)
|
||||
|| options.signal?.aborted
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
attempt += 1;
|
||||
await sleep(rateLimitBackoffMs * attempt, sleepFn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openBoundedForwardOut(
|
||||
@@ -183,7 +228,10 @@ function openBoundedForwardInCallback(
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_SSH_CHANNEL_OPEN_TIMEOUT_MS,
|
||||
DEFAULT_SSH_CHANNEL_OPEN_RATE_LIMIT_RETRIES,
|
||||
DEFAULT_SSH_CHANNEL_OPEN_RATE_LIMIT_BACKOFF_MS,
|
||||
closeLateChannel,
|
||||
isSshChannelOpenRateLimitedError,
|
||||
openBoundedSshChannel,
|
||||
openBoundedSshShell,
|
||||
openBoundedSshShellCallback,
|
||||
|
||||
@@ -4,6 +4,7 @@ const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter, getEventListeners } = require("node:events");
|
||||
const {
|
||||
isSshChannelOpenRateLimitedError,
|
||||
openBoundedForwardIn,
|
||||
openBoundedForwardOut,
|
||||
openBoundedSshShell,
|
||||
@@ -105,3 +106,67 @@ test("channel open keeps its deadline referenced and clears it after success", a
|
||||
assert.equal(timers.active.size, 0);
|
||||
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
|
||||
});
|
||||
|
||||
test("detects bastion channelOpen rate-limit errors including the offen typo", () => {
|
||||
assert.equal(
|
||||
isSshChannelOpenRateLimitedError(
|
||||
new Error("(SSH) Channel open failure: channelOpen too offen type=session"),
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isSshChannelOpenRateLimitedError(
|
||||
new Error("channel open failure: channelOpen too often type=session"),
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isSshChannelOpenRateLimitedError(new Error("Permission denied")),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shell open retries bastion rate-limit failures with short backoff", async () => {
|
||||
const delays = [];
|
||||
let attempts = 0;
|
||||
const client = {
|
||||
shell(_window, _options, next) {
|
||||
attempts += 1;
|
||||
if (attempts < 3) {
|
||||
next(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
||||
return;
|
||||
}
|
||||
next(null, new EventEmitter());
|
||||
},
|
||||
};
|
||||
|
||||
const stream = await openBoundedSshShell(client, {}, {}, {
|
||||
rateLimitRetries: 3,
|
||||
rateLimitBackoffMs: 5,
|
||||
sleepFn: async (ms) => { delays.push(ms); },
|
||||
});
|
||||
|
||||
assert.ok(stream);
|
||||
assert.equal(attempts, 3);
|
||||
assert.deepEqual(delays, [5, 10]);
|
||||
});
|
||||
|
||||
test("shell open does not retry unrelated channel open failures", async () => {
|
||||
let attempts = 0;
|
||||
const client = {
|
||||
shell(_window, _options, next) {
|
||||
attempts += 1;
|
||||
next(new Error("Channel open failure: administratively prohibited"));
|
||||
},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
openBoundedSshShell(client, {}, {}, {
|
||||
rateLimitRetries: 3,
|
||||
rateLimitBackoffMs: 5,
|
||||
sleepFn: async () => {},
|
||||
}),
|
||||
/administratively prohibited/,
|
||||
);
|
||||
assert.equal(attempts, 1);
|
||||
});
|
||||
@@ -341,6 +341,7 @@ function makeSourceSession(conn, endpoint) {
|
||||
hostname: endpoint.hostname,
|
||||
port: endpoint.port || 22,
|
||||
username: endpoint.username,
|
||||
...(Array.isArray(endpoint.jumpHosts) ? { jumpHosts: endpoint.jumpHosts } : {}),
|
||||
},
|
||||
};
|
||||
createConnectionRef(session, conn, []);
|
||||
@@ -473,6 +474,224 @@ test("Copy Tab skips POSIX shell discovery for network devices", async (t) => {
|
||||
assert.ok(sessions.get("copy"));
|
||||
});
|
||||
|
||||
test("Copy Tab with jump hosts still discovers shell PIDs after a short delay", async (t) => {
|
||||
const { bridge } = loadBridgeWithMockedSsh2(t);
|
||||
const sessions = new Map();
|
||||
const sourceConn = makePidTrackingReusableConn();
|
||||
const jumpHosts = [{ hostname: "bastion.example", username: "jump", port: 22 }];
|
||||
const source = makeSourceSession(sourceConn, {
|
||||
hostname: "10.0.0.1",
|
||||
username: "alice",
|
||||
jumpHosts,
|
||||
});
|
||||
sessions.set("source", source);
|
||||
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
await start(
|
||||
{ sender: makeSender() },
|
||||
{
|
||||
sessionId: "copy",
|
||||
hostname: "10.0.0.1",
|
||||
username: "alice",
|
||||
sourceSessionId: "source",
|
||||
jumpHosts,
|
||||
sshChannelOpenRateLimitBackoffMs: 1,
|
||||
},
|
||||
);
|
||||
|
||||
assert.ok(sourceConn.shellPidSnapshots.length >= 2, "jump-host reuse must still run discovery");
|
||||
assert.equal(source.shellPid, "111");
|
||||
assert.equal(sessions.get("copy").shellPid, "222");
|
||||
assert.equal(sourceConn.openedShells.length, 1);
|
||||
});
|
||||
|
||||
test("Copy Tab retries rate-limited pre-open shell PID discovery on jump hosts", async (t) => {
|
||||
const { bridge } = loadBridgeWithMockedSsh2(t);
|
||||
const sessions = new Map();
|
||||
const sourceConn = makeReusableConn();
|
||||
let execCalls = 0;
|
||||
sourceConn.exec = (_command, callback) => {
|
||||
execCalls += 1;
|
||||
// First pre-open scan is rate-limited; later scans succeed.
|
||||
if (execCalls === 1) {
|
||||
callback(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
||||
return;
|
||||
}
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
stream.close = () => {};
|
||||
const snapshot = execCalls === 2 ? ["111"] : ["111", "222"];
|
||||
const pids = `${snapshot.join("\n")}\n__NETCATTY_SHELL_SCAN_COMPLETE__\n`;
|
||||
setImmediate(() => {
|
||||
stream.emit("data", Buffer.from(pids));
|
||||
stream.emit("close", 0);
|
||||
});
|
||||
callback(null, stream);
|
||||
};
|
||||
const jumpHosts = [{ hostname: "bastion.example", username: "jump", port: 22 }];
|
||||
const source = makeSourceSession(sourceConn, {
|
||||
hostname: "10.0.0.1",
|
||||
username: "alice",
|
||||
jumpHosts,
|
||||
});
|
||||
sessions.set("source", source);
|
||||
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
await start(
|
||||
{ sender: makeSender() },
|
||||
{
|
||||
sessionId: "copy",
|
||||
hostname: "10.0.0.1",
|
||||
username: "alice",
|
||||
sourceSessionId: "source",
|
||||
jumpHosts,
|
||||
sshChannelOpenRateLimitBackoffMs: 1,
|
||||
},
|
||||
);
|
||||
|
||||
assert.ok(execCalls >= 3, "pre-open discovery must retry after channelOpen too offen");
|
||||
assert.equal(source.shellPid, "111");
|
||||
assert.equal(sessions.get("copy").shellPid, "222");
|
||||
});
|
||||
|
||||
test("Copy Tab retries rate-limited PID discovery for direct bastion targets", async (t) => {
|
||||
const { bridge } = loadBridgeWithMockedSsh2(t);
|
||||
const sessions = new Map();
|
||||
const sourceConn = makeReusableConn();
|
||||
let execCalls = 0;
|
||||
sourceConn.exec = (_command, callback) => {
|
||||
execCalls += 1;
|
||||
// Direct bastion (no jumpHosts): first pre-open scan is rate-limited.
|
||||
if (execCalls === 1) {
|
||||
callback(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
||||
return;
|
||||
}
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
stream.close = () => {};
|
||||
const snapshot = execCalls === 2 ? ["111"] : ["111", "222"];
|
||||
const pids = `${snapshot.join("\n")}\n__NETCATTY_SHELL_SCAN_COMPLETE__\n`;
|
||||
setImmediate(() => {
|
||||
stream.emit("data", Buffer.from(pids));
|
||||
stream.emit("close", 0);
|
||||
});
|
||||
callback(null, stream);
|
||||
};
|
||||
sessions.set("source", makeSourceSession(sourceConn, {
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
}));
|
||||
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
await start(
|
||||
{ sender: makeSender() },
|
||||
{
|
||||
sessionId: "copy",
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
sourceSessionId: "source",
|
||||
sshChannelOpenRateLimitBackoffMs: 1,
|
||||
},
|
||||
);
|
||||
|
||||
assert.ok(execCalls >= 3, "direct bastion reuse must retry rate-limited PID discovery");
|
||||
assert.equal(sessions.get("source").shellPid, "111");
|
||||
assert.equal(sessions.get("copy").shellPid, "222");
|
||||
});
|
||||
|
||||
test("Copy Tab retries rate-limited post-open shell PID discovery on jump hosts", async (t) => {
|
||||
const { bridge } = loadBridgeWithMockedSsh2(t);
|
||||
const sessions = new Map();
|
||||
const sourceConn = makeReusableConn();
|
||||
let execCalls = 0;
|
||||
sourceConn.exec = (_command, callback) => {
|
||||
execCalls += 1;
|
||||
// Pre-open discovery (1) succeeds with the source shell. The first
|
||||
// post-open discovery (2) is rate-limited; later attempts succeed.
|
||||
if (execCalls === 2) {
|
||||
callback(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
||||
return;
|
||||
}
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
stream.close = () => {};
|
||||
const snapshot = execCalls === 1 ? ["111"] : ["111", "222"];
|
||||
const pids = `${snapshot.join("\n")}\n__NETCATTY_SHELL_SCAN_COMPLETE__\n`;
|
||||
setImmediate(() => {
|
||||
stream.emit("data", Buffer.from(pids));
|
||||
stream.emit("close", 0);
|
||||
});
|
||||
callback(null, stream);
|
||||
};
|
||||
const jumpHosts = [{ hostname: "bastion.example", username: "jump", port: 22 }];
|
||||
const source = makeSourceSession(sourceConn, {
|
||||
hostname: "10.0.0.1",
|
||||
username: "alice",
|
||||
jumpHosts,
|
||||
});
|
||||
sessions.set("source", source);
|
||||
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
await start(
|
||||
{ sender: makeSender() },
|
||||
{
|
||||
sessionId: "copy",
|
||||
hostname: "10.0.0.1",
|
||||
username: "alice",
|
||||
sourceSessionId: "source",
|
||||
jumpHosts,
|
||||
sshChannelOpenRateLimitBackoffMs: 1,
|
||||
},
|
||||
);
|
||||
|
||||
assert.ok(execCalls >= 3, "post-open discovery must retry after channelOpen too offen");
|
||||
assert.equal(source.shellPid, "111");
|
||||
assert.equal(sessions.get("copy").shellPid, "222");
|
||||
});
|
||||
|
||||
test("Copy Tab retries bastion channelOpen too offen before falling back", async (t) => {
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
||||
const sessions = new Map();
|
||||
const sourceConn = makeReusableConn();
|
||||
let shellAttempts = 0;
|
||||
sourceConn.shell = (_opts, _shellOpts, cb) => {
|
||||
shellAttempts += 1;
|
||||
if (shellAttempts === 1) {
|
||||
setImmediate(() => cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session")));
|
||||
return;
|
||||
}
|
||||
const stream = makeStream();
|
||||
sourceConn.openedShells.push(stream);
|
||||
setImmediate(() => cb(null, stream));
|
||||
};
|
||||
const jumpHosts = [{ hostname: "bastion.example", username: "jump", port: 22 }];
|
||||
sessions.set("source", makeSourceSession(sourceConn, {
|
||||
hostname: "10.0.0.1",
|
||||
username: "alice",
|
||||
jumpHosts,
|
||||
}));
|
||||
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
const sender = makeSender();
|
||||
const result = await start(
|
||||
{ sender },
|
||||
{
|
||||
sessionId: "copy",
|
||||
hostname: "10.0.0.1",
|
||||
username: "alice",
|
||||
sourceSessionId: "source",
|
||||
jumpHosts,
|
||||
sshChannelOpenRateLimitBackoffMs: 1,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.sessionId, "copy");
|
||||
assert.equal(shellAttempts, 2);
|
||||
assert.equal(getClientConstructCount(), 0);
|
||||
assert.equal(sourceConn.openedShells.length, 1);
|
||||
assert.equal(getConnectionReuseFallbackEvents(sender).length, 0);
|
||||
});
|
||||
|
||||
test("Copy Tab waits briefly when the new remote shell is not visible immediately", async (t) => {
|
||||
const { bridge } = loadBridgeWithMockedSsh2(t);
|
||||
const sessions = new Map();
|
||||
|
||||
@@ -16,7 +16,7 @@ const {
|
||||
const { runWhenProxyConnectionReady } = require("../proxyUtils.cjs");
|
||||
const { getAttachHomeWebContentsId } = require("../terminalAttachRestore.cjs");
|
||||
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
|
||||
const { openBoundedSshShellCallback } = require("../boundedSshChannelOpen.cjs");
|
||||
const { openBoundedSshShellCallback, isSshChannelOpenRateLimitedError } = require("../boundedSshChannelOpen.cjs");
|
||||
const {
|
||||
annotateMacLocalNetworkErrorMessage,
|
||||
resolveFirstTcpEndpoint,
|
||||
@@ -237,21 +237,57 @@ printf '%s\n' '${scanCompleteMarker}'`;
|
||||
available,
|
||||
pids: available ? lines.filter((value) => /^\d+$/.test(value)) : [],
|
||||
};
|
||||
} catch {
|
||||
return { available: false, pids: [] };
|
||||
} catch (error) {
|
||||
return {
|
||||
available: false,
|
||||
rateLimited: isSshChannelOpenRateLimitedError(error),
|
||||
pids: [],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const waitForNewInteractiveShellPid = async (conn, previousPids) => {
|
||||
const listInteractiveShellPidsResilient = async (conn, opts = {}) => {
|
||||
const attempts = Math.max(1, Number(opts.attempts) || 1);
|
||||
const backoffMs = Math.max(1, Number(opts.backoffMs) || 150);
|
||||
const initialDelayMs = Math.max(0, Number(opts.initialDelayMs) || 0);
|
||||
let last = { available: false, pids: [] };
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
const delayMs = attempt === 0
|
||||
? initialDelayMs
|
||||
: backoffMs * attempt;
|
||||
if (delayMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
last = await listInteractiveShellPids(conn);
|
||||
if (last.available || !last.rateLimited) return last;
|
||||
}
|
||||
return last;
|
||||
};
|
||||
|
||||
const waitForNewInteractiveShellPid = async (conn, previousPids, opts = {}) => {
|
||||
const previous = new Set(previousPids);
|
||||
const initialDelayMs = Math.max(0, Number(opts.initialDelayMs) || 0);
|
||||
const backoffMs = Math.max(1, Number(opts.backoffMs) || 50);
|
||||
if (initialDelayMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, initialDelayMs));
|
||||
}
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
const discovery = await listInteractiveShellPids(conn);
|
||||
if (!discovery.available) return null;
|
||||
if (!discovery.available) {
|
||||
// Bastion rate limits can reject the post-open discovery exec just
|
||||
// after a retried shell open. Back off and try again instead of
|
||||
// permanently leaving the copied session without a shellPid.
|
||||
if (discovery.rateLimited && attempt < 4) {
|
||||
await new Promise((resolve) => setTimeout(resolve, backoffMs * (attempt + 1)));
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const newPids = discovery.pids.filter((pid) => !previous.has(pid));
|
||||
if (newPids.length === 1) return newPids[0];
|
||||
if (newPids.length > 1) return null;
|
||||
if (attempt < 4) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
|
||||
await new Promise((resolve) => setTimeout(resolve, backoffMs * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -657,9 +693,24 @@ printf '%s\n' '${scanCompleteMarker}'`;
|
||||
discoveryConnectionError = err;
|
||||
};
|
||||
let shellDiscoveryBeforeOpen = { available: false, pids: [] };
|
||||
// Bastions (whether configured as jumpHosts or used as the direct SSH
|
||||
// target) often rate-limit rapid session channel opens ("channelOpen too
|
||||
// offen"). Retry rate-limited PID scans on every Copy Tab reuse so a
|
||||
// successful shell open still gets shellPid tracking. Only the optional
|
||||
// pre-scan pause is jump-host specific to avoid delaying the happy path
|
||||
// on ordinary direct hosts.
|
||||
const hasJumpHosts = Array.isArray(options.jumpHosts) && options.jumpHosts.length > 0;
|
||||
const configuredBackoffMs = Number(options.sshChannelOpenRateLimitBackoffMs);
|
||||
const discoveryBackoffMs = Number.isFinite(configuredBackoffMs) && configuredBackoffMs > 0
|
||||
? configuredBackoffMs
|
||||
: 150;
|
||||
if (!options.skipShellPidDiscovery) {
|
||||
conn.once("error", onDiscoveryConnectionError);
|
||||
shellDiscoveryBeforeOpen = await listInteractiveShellPids(conn);
|
||||
shellDiscoveryBeforeOpen = await listInteractiveShellPidsResilient(conn, {
|
||||
initialDelayMs: hasJumpHosts ? discoveryBackoffMs : 0,
|
||||
attempts: 4,
|
||||
backoffMs: discoveryBackoffMs,
|
||||
});
|
||||
conn.removeListener("error", onDiscoveryConnectionError);
|
||||
}
|
||||
const shellPidsBeforeOpen = shellDiscoveryBeforeOpen.pids;
|
||||
@@ -733,6 +784,7 @@ printf '%s\n' '${scanCompleteMarker}'`;
|
||||
conn.once("error", onConnError);
|
||||
|
||||
try {
|
||||
const rateLimitBackoffMs = Number(options.sshChannelOpenRateLimitBackoffMs);
|
||||
openBoundedSshShellCallback(
|
||||
conn,
|
||||
{
|
||||
@@ -786,7 +838,14 @@ printf '%s\n' '${scanCompleteMarker}'`;
|
||||
refHolder.connRef = null;
|
||||
}
|
||||
const newShellPidPromise = shellDiscoveryBeforeOpen.available
|
||||
? waitForNewInteractiveShellPid(conn, shellPidsBeforeOpen)
|
||||
? waitForNewInteractiveShellPid(
|
||||
conn,
|
||||
shellPidsBeforeOpen,
|
||||
{
|
||||
initialDelayMs: hasJumpHosts ? discoveryBackoffMs : 0,
|
||||
backoffMs: discoveryBackoffMs,
|
||||
},
|
||||
)
|
||||
: Promise.resolve(null);
|
||||
void newShellPidPromise.then((newShellPid) => {
|
||||
const liveSession = sessions.get(sessionId);
|
||||
@@ -796,7 +855,10 @@ printf '%s\n' '${scanCompleteMarker}'`;
|
||||
settled = true;
|
||||
resolve({ sessionId });
|
||||
});
|
||||
}
|
||||
},
|
||||
Number.isFinite(rateLimitBackoffMs) && rateLimitBackoffMs > 0
|
||||
? { rateLimitBackoffMs }
|
||||
: {},
|
||||
);
|
||||
} catch (syncErr) {
|
||||
// ssh2 can throw synchronously (e.g. "Not connected") if the borrowed
|
||||
|
||||
@@ -46,19 +46,71 @@ function buildZodSchema(inputShape) {
|
||||
}
|
||||
|
||||
function formatRpcError(result) {
|
||||
return `Error: ${result?.error || "Operation failed"}`;
|
||||
if (result?.error) return `Error: ${result.error}`;
|
||||
if (result?.code) return `Error: Operation failed (${result.code})`;
|
||||
return "Error: Operation failed";
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the RPC result carries command-run evidence (output and/or exit
|
||||
* code). Non-zero exits from PTY exec set ok:false without an error string —
|
||||
* that is still a completed command, not an operational failure.
|
||||
*/
|
||||
function hasTerminalExecuteEvidence(result) {
|
||||
if (!result || typeof result !== "object") return false;
|
||||
if (typeof result.stdout === "string" && result.stdout.length > 0) return true;
|
||||
if (typeof result.stderr === "string" && result.stderr.length > 0) return true;
|
||||
return result.exitCode != null;
|
||||
}
|
||||
|
||||
function formatTerminalExecuteResult(result) {
|
||||
const parts = [];
|
||||
if (result.stdout) parts.push(result.stdout);
|
||||
if (result.stderr) parts.push(`[stderr] ${result.stderr}`);
|
||||
if (result.exitCode != null) {
|
||||
if (result?.stdout) parts.push(result.stdout);
|
||||
if (result?.stderr) parts.push(`[stderr] ${result.stderr}`);
|
||||
if (result?.exitCode != null) {
|
||||
parts.push(`[exit code: ${result.exitCode}]`);
|
||||
}
|
||||
if (result?.error) parts.push(`[error] ${result.error}`);
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
// Align with Catty executor wording when a command ran but produced no text.
|
||||
const TERMINAL_EXECUTE_NO_OUTPUT_TEXT = "Command completed (no output)";
|
||||
|
||||
/**
|
||||
* Format terminal_execute for MCP clients.
|
||||
* Match Catty semantics: when the command actually ran, always surface
|
||||
* stdout/stderr/exitCode so the model can judge the failure. Only pure
|
||||
* operational failures (session missing, blocked, etc.) become a bare
|
||||
* "Error: …" payload. See issue #2718.
|
||||
*
|
||||
* Successful empty results (serial/network raw PTY often returns ok:true with
|
||||
* empty stdout/stderr and exitCode:null) must NOT fall back to
|
||||
* "Error: Operation failed" — Codex P2 on PR #2724.
|
||||
*/
|
||||
function formatTerminalExecuteMcpResponse(result) {
|
||||
if (!result || typeof result !== "object") {
|
||||
return { content: [{ type: "text", text: formatRpcError(result) }], isError: true };
|
||||
}
|
||||
|
||||
// Operational failure with no command evidence (session not found, busy, blocked…).
|
||||
if (result.ok === false && !hasTerminalExecuteEvidence(result)) {
|
||||
return { content: [{ type: "text", text: formatRpcError(result) }], isError: true };
|
||||
}
|
||||
|
||||
// Empty successful payloads (serial/raw PTY): neutral text.
|
||||
// Never fall back to formatRpcError here — that mislabels silent successes (#2724 P2).
|
||||
// When result.error is set, formatTerminalExecuteResult already includes `[error] …`.
|
||||
const text = formatTerminalExecuteResult(result) || TERMINAL_EXECUTE_NO_OUTPUT_TEXT;
|
||||
// Mark isError only for operational/runtime failures that include an error
|
||||
// string (timeout, cancel, stream lost). Non-zero exit alone is success of
|
||||
// the tool call with a failed command — same as Catty ok:true + exitCode.
|
||||
if (result.ok === false && result.error) {
|
||||
return { content: [{ type: "text", text }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text }] };
|
||||
}
|
||||
|
||||
function createToolHandler(toolDef, deps) {
|
||||
const {
|
||||
rpcCall,
|
||||
@@ -76,10 +128,10 @@ function createToolHandler(toolDef, deps) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall(rpcMethod, { ...scopeParams, sessionId, command });
|
||||
if (!result.ok) {
|
||||
return { content: [{ type: "text", text: formatRpcError(result) }], isError: true };
|
||||
}
|
||||
if (TERMINAL_START_TOOLS.has(mcpTool)) {
|
||||
if (!result?.ok) {
|
||||
return { content: [{ type: "text", text: formatRpcError(result) }], isError: true };
|
||||
}
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
@@ -94,7 +146,7 @@ function createToolHandler(toolDef, deps) {
|
||||
}],
|
||||
};
|
||||
}
|
||||
return { content: [{ type: "text", text: formatTerminalExecuteResult(result) }] };
|
||||
return formatTerminalExecuteMcpResponse(result);
|
||||
}
|
||||
|
||||
if (TERMINAL_POLL_TOOLS.has(mcpTool) || TERMINAL_STOP_TOOLS.has(mcpTool)) {
|
||||
@@ -188,6 +240,10 @@ function registerMcpTools(server, deps) {
|
||||
module.exports = {
|
||||
buildZodSchema,
|
||||
buildZodShapeObject,
|
||||
formatRpcError,
|
||||
formatTerminalExecuteResult,
|
||||
formatTerminalExecuteMcpResponse,
|
||||
hasTerminalExecuteEvidence,
|
||||
registerMcpTools,
|
||||
SFTP_WRITE_TOOLS,
|
||||
};
|
||||
@@ -235,3 +235,129 @@ test("session_close remains available as a cleanup action in observer mode", asy
|
||||
assert.equal(result.isError, undefined);
|
||||
assert.equal(guardCalls, 0);
|
||||
});
|
||||
|
||||
test("terminal_execute MCP response preserves stdout/exitCode on non-zero exit (#2718)", async () => {
|
||||
let handler = null;
|
||||
const fakeServer = {
|
||||
tool(name, _description, _shape, candidate) {
|
||||
if (name === "terminal_execute") handler = candidate;
|
||||
},
|
||||
};
|
||||
registerMcpTools(fakeServer, {
|
||||
rpcCall: async () => ({
|
||||
ok: false,
|
||||
stdout: "du: cannot access '/missing': No such file or directory",
|
||||
stderr: "",
|
||||
exitCode: 1,
|
||||
}),
|
||||
scopeParams: { chatSessionId: "chat-1" },
|
||||
guardWriteOperation: () => null,
|
||||
catalogDescription: (_name, fallback) => fallback,
|
||||
});
|
||||
|
||||
assert.ok(handler, "terminal_execute handler registered");
|
||||
const result = await handler({ sessionId: "sess-1", command: "du /missing" });
|
||||
assert.equal(result.isError, undefined);
|
||||
const text = result.content?.[0]?.text || "";
|
||||
assert.match(text, /cannot access '\/missing'/);
|
||||
assert.match(text, /\[exit code: 1\]/);
|
||||
assert.doesNotMatch(text, /Operation failed/);
|
||||
});
|
||||
|
||||
test("terminal_execute MCP response keeps operational failures as isError", async () => {
|
||||
let handler = null;
|
||||
const fakeServer = {
|
||||
tool(name, _description, _shape, candidate) {
|
||||
if (name === "terminal_execute") handler = candidate;
|
||||
},
|
||||
};
|
||||
registerMcpTools(fakeServer, {
|
||||
rpcCall: async () => ({ ok: false, error: "Session not found" }),
|
||||
scopeParams: { chatSessionId: "chat-1" },
|
||||
guardWriteOperation: () => null,
|
||||
catalogDescription: (_name, fallback) => fallback,
|
||||
});
|
||||
|
||||
const result = await handler({ sessionId: "gone", command: "uptime" });
|
||||
assert.equal(result.isError, true);
|
||||
assert.equal(result.content?.[0]?.text, "Error: Session not found");
|
||||
});
|
||||
|
||||
test("terminal_execute MCP response includes partial output on timeout", async () => {
|
||||
let handler = null;
|
||||
const fakeServer = {
|
||||
tool(name, _description, _shape, candidate) {
|
||||
if (name === "terminal_execute") handler = candidate;
|
||||
},
|
||||
};
|
||||
registerMcpTools(fakeServer, {
|
||||
rpcCall: async () => ({
|
||||
ok: false,
|
||||
stdout: "partial lines",
|
||||
stderr: "",
|
||||
exitCode: -1,
|
||||
error: "Command timed out (60s)",
|
||||
}),
|
||||
scopeParams: { chatSessionId: "chat-1" },
|
||||
guardWriteOperation: () => null,
|
||||
catalogDescription: (_name, fallback) => fallback,
|
||||
});
|
||||
|
||||
const result = await handler({ sessionId: "sess-1", command: "sleep 999" });
|
||||
assert.equal(result.isError, true);
|
||||
const text = result.content?.[0]?.text || "";
|
||||
assert.match(text, /partial lines/);
|
||||
assert.match(text, /\[exit code: -1\]/);
|
||||
assert.match(text, /\[error\] Command timed out \(60s\)/);
|
||||
});
|
||||
|
||||
test("terminal_execute MCP response uses neutral text for successful empty output (#2724)", async () => {
|
||||
let handler = null;
|
||||
const fakeServer = {
|
||||
tool(name, _description, _shape, candidate) {
|
||||
if (name === "terminal_execute") handler = candidate;
|
||||
},
|
||||
};
|
||||
// Serial/network-device raw PTY success: ok true, empty streams, exitCode null.
|
||||
registerMcpTools(fakeServer, {
|
||||
rpcCall: async () => ({
|
||||
ok: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: null,
|
||||
}),
|
||||
scopeParams: { chatSessionId: "chat-1" },
|
||||
guardWriteOperation: () => null,
|
||||
catalogDescription: (_name, fallback) => fallback,
|
||||
});
|
||||
|
||||
const result = await handler({ sessionId: "sess-1", command: "configure terminal" });
|
||||
assert.equal(result.isError, undefined);
|
||||
assert.equal(result.content?.[0]?.text, "Command completed (no output)");
|
||||
assert.doesNotMatch(result.content?.[0]?.text || "", /Operation failed/);
|
||||
});
|
||||
|
||||
test("terminal_execute MCP response keeps exit-only non-zero without isError", async () => {
|
||||
let handler = null;
|
||||
const fakeServer = {
|
||||
tool(name, _description, _shape, candidate) {
|
||||
if (name === "terminal_execute") handler = candidate;
|
||||
},
|
||||
};
|
||||
registerMcpTools(fakeServer, {
|
||||
rpcCall: async () => ({
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: 1,
|
||||
}),
|
||||
scopeParams: { chatSessionId: "chat-1" },
|
||||
guardWriteOperation: () => null,
|
||||
catalogDescription: (_name, fallback) => fallback,
|
||||
});
|
||||
|
||||
const result = await handler({ sessionId: "sess-1", command: "false" });
|
||||
assert.equal(result.isError, undefined);
|
||||
assert.equal(result.content?.[0]?.text, "[exit code: 1]");
|
||||
assert.doesNotMatch(result.content?.[0]?.text || "", /Operation failed/);
|
||||
});
|
||||
@@ -5,7 +5,7 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { DatabaseSync } = require("node:sqlite");
|
||||
|
||||
const SCHEMA_VERSION = 2;
|
||||
const SCHEMA_VERSION = 3;
|
||||
const MAX_SECURITY_AUDIT_DETAILS_BYTES = 16 * 1024;
|
||||
const MAX_SECURITY_AUDIT_RECORDS_PER_PLUGIN = 1_000;
|
||||
const REQUIRED_SCHEMA_COLUMNS = Object.freeze({
|
||||
@@ -21,6 +21,8 @@ const REQUIRED_SCHEMA_COLUMNS = Object.freeze({
|
||||
plugin_security_audit: ["id", "plugin_id", "event", "details_json", "created_at"],
|
||||
// User-owned encrypted-sync sidecars: no FK cascade on package uninstall.
|
||||
plugin_sync_sidecars: ["plugin_id", "kind", "key", "value_json", "updated_at"],
|
||||
// Host-owned sync provider→plugin bindings (not plugin-writable secrets).
|
||||
plugin_sync_provider_bindings: ["provider_id", "plugin_id", "created_at", "updated_at"],
|
||||
});
|
||||
|
||||
function parseJson(text, label) {
|
||||
@@ -164,12 +166,18 @@ class PluginDatabase {
|
||||
);
|
||||
CREATE INDEX plugin_sync_sidecars_lookup
|
||||
ON plugin_sync_sidecars(plugin_id, kind, key);
|
||||
PRAGMA user_version = 2;
|
||||
CREATE TABLE plugin_sync_provider_bindings (
|
||||
provider_id TEXT PRIMARY KEY,
|
||||
plugin_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
PRAGMA user_version = 3;
|
||||
`);
|
||||
});
|
||||
} else if (version === 1) {
|
||||
// Pre-sidecar schema-1 databases only created the original tables.
|
||||
// Migrate in place to schema 2 with the non-cascade sidecar table.
|
||||
// Migrate in place to schema 3 with sidecar + provider binding tables.
|
||||
this.transaction(() => {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS plugin_sync_sidecars (
|
||||
@@ -182,7 +190,25 @@ class PluginDatabase {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS plugin_sync_sidecars_lookup
|
||||
ON plugin_sync_sidecars(plugin_id, kind, key);
|
||||
PRAGMA user_version = 2;
|
||||
CREATE TABLE IF NOT EXISTS plugin_sync_provider_bindings (
|
||||
provider_id TEXT PRIMARY KEY,
|
||||
plugin_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
PRAGMA user_version = 3;
|
||||
`);
|
||||
});
|
||||
} else if (version === 2) {
|
||||
this.transaction(() => {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS plugin_sync_provider_bindings (
|
||||
provider_id TEXT PRIMARY KEY,
|
||||
plugin_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
PRAGMA user_version = 3;
|
||||
`);
|
||||
});
|
||||
}
|
||||
@@ -759,6 +785,46 @@ class PluginDatabase {
|
||||
return Number(result?.changes) || 0;
|
||||
}
|
||||
|
||||
/** Find secret rows by exact key across all plugins (sync provider cleanup). */
|
||||
findSecretsByKey(key) {
|
||||
return this.db.prepare(`
|
||||
SELECT plugin_id, key, secret_ref, ciphertext, created_at, updated_at
|
||||
FROM plugin_secrets WHERE key = ?
|
||||
`).all(key).map((row) => this.#mapSecret(row));
|
||||
}
|
||||
|
||||
upsertSyncProviderBinding(providerId, pluginId) {
|
||||
const now = this.clock();
|
||||
this.db.prepare(`
|
||||
INSERT INTO plugin_sync_provider_bindings(provider_id, plugin_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(provider_id) DO UPDATE SET
|
||||
plugin_id = excluded.plugin_id,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(providerId, pluginId, now, now);
|
||||
}
|
||||
|
||||
getSyncProviderBinding(providerId) {
|
||||
const row = this.db.prepare(`
|
||||
SELECT provider_id, plugin_id, created_at, updated_at
|
||||
FROM plugin_sync_provider_bindings WHERE provider_id = ?
|
||||
`).get(providerId);
|
||||
if (!row) return null;
|
||||
return {
|
||||
providerId: row.provider_id,
|
||||
pluginId: row.plugin_id,
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
deleteSyncProviderBinding(providerId) {
|
||||
const result = this.db.prepare(`
|
||||
DELETE FROM plugin_sync_provider_bindings WHERE provider_id = ?
|
||||
`).run(providerId);
|
||||
return Number(result?.changes) || 0;
|
||||
}
|
||||
|
||||
recordSecurityAudit(pluginId, event, details) {
|
||||
let detailsJson = JSON.stringify(details ?? {});
|
||||
const originalBytes = Buffer.byteLength(detailsJson);
|
||||
|
||||
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user