mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-05 22:12:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e794b1300e | ||
|
|
a7c67033c6 | ||
|
|
45af6072fd | ||
|
|
84bef23e1f | ||
|
|
ddd2bcb08f | ||
|
|
d9b23e690a | ||
|
|
abe7433509 |
No files matched your search
@@ -9,7 +9,6 @@ import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
|
||||
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||
import { captureInheritedCwd } from '../state/inheritedCwd';
|
||||
import { fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
|
||||
@@ -154,7 +153,7 @@ export function handleTrayTogglePortForwardImpl(getCtx: AppContextGetter, ruleId
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
void startTunnel(rule, effectiveHost, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, isPortForwardingAutoReconnectEnabled(rule), terminalSettings, knownHosts);
|
||||
}, rule.autoStart, terminalSettings, knownHosts);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -28,35 +28,3 @@ test("top tabs chrome theme keeps accent foreground in sync", () => {
|
||||
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--primary-foreground'\)/);
|
||||
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--accent-foreground'\)/);
|
||||
});
|
||||
|
||||
test("bottom title bar receives and clears the same active theme as the tabs", async () => {
|
||||
const { applyTopTabsChromeThemeVars, clearTopTabsChromeThemeVars } = await import('./topTabsChromeTheme.ts');
|
||||
const { TERMINAL_THEMES } = await import('../../infrastructure/config/terminalThemes.ts');
|
||||
const makeRoot = () => {
|
||||
const values = new Map<string, string>();
|
||||
return { style: {
|
||||
getPropertyValue: (key: string) => values.get(key) ?? '',
|
||||
setProperty: (key: string, value: string) => values.set(key, value),
|
||||
removeProperty: (key: string) => values.delete(key),
|
||||
}};
|
||||
};
|
||||
const tabs = makeRoot();
|
||||
const titlebar = makeRoot();
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: {
|
||||
querySelector: (selector: string) => selector === '[data-top-tabs-root]' ? tabs : titlebar,
|
||||
}});
|
||||
try {
|
||||
applyTopTabsChromeThemeVars(TERMINAL_THEMES[0]);
|
||||
for (const property of ['--secondary', '--foreground', '--top-tabs-bg']) {
|
||||
assert.notEqual(tabs.style.getPropertyValue(property), '');
|
||||
assert.equal(titlebar.style.getPropertyValue(property), tabs.style.getPropertyValue(property));
|
||||
}
|
||||
clearTopTabsChromeThemeVars();
|
||||
assert.equal(tabs.style.getPropertyValue('--secondary'), '');
|
||||
assert.equal(titlebar.style.getPropertyValue('--secondary'), '');
|
||||
} finally {
|
||||
if (original) Object.defineProperty(globalThis, 'document', original);
|
||||
else Reflect.deleteProperty(globalThis, 'document');
|
||||
}
|
||||
});
|
||||
@@ -70,31 +70,23 @@ const TOP_TABS_THEME_PROPERTIES = [
|
||||
'--muted-foreground',
|
||||
] as const;
|
||||
|
||||
export function getTopTabsChromeRoots(): HTMLElement[] {
|
||||
if (typeof document === 'undefined') return [];
|
||||
return [
|
||||
document.querySelector<HTMLElement>('[data-top-tabs-root]'),
|
||||
document.querySelector<HTMLElement>('[data-tab-window-titlebar]'),
|
||||
].filter((root): root is HTMLElement => root !== null);
|
||||
}
|
||||
|
||||
let topTabsChromeThemeVarsApplied = false;
|
||||
|
||||
export function clearTopTabsChromeThemeVars(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (!topTabsChromeThemeVarsApplied) return;
|
||||
for (const root of getTopTabsChromeRoots()) {
|
||||
for (const property of TOP_TABS_THEME_PROPERTIES) {
|
||||
removeStylePropertyIfSet(root, property);
|
||||
}
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
for (const property of TOP_TABS_THEME_PROPERTIES) {
|
||||
removeStylePropertyIfSet(tabsRoot, property);
|
||||
}
|
||||
topTabsChromeThemeVarsApplied = false;
|
||||
}
|
||||
|
||||
export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const roots = getTopTabsChromeRoots();
|
||||
if (roots.length === 0) return;
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
|
||||
const bg = hexToHslToken(theme.colors.background);
|
||||
const fg = hexToHslToken(theme.colors.foreground);
|
||||
@@ -105,21 +97,19 @@ export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
|
||||
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
|
||||
const mutedFg = adjustSaturationToken(adjustLightnessToken(fg, isDark ? -20 : 20), 0.5);
|
||||
|
||||
for (const tabsRoot of roots) {
|
||||
setStylePropertyIfChanged(tabsRoot, '--background', bg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
|
||||
setStylePropertyIfChanged(tabsRoot, '--border', border);
|
||||
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
|
||||
}
|
||||
setStylePropertyIfChanged(tabsRoot, '--background', bg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
|
||||
setStylePropertyIfChanged(tabsRoot, '--border', border);
|
||||
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
|
||||
topTabsChromeThemeVarsApplied = true;
|
||||
}
|
||||
@@ -358,9 +358,6 @@ export const enCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': 'Click once to select a host (accent highlight in grid view), click again to connect. Groups work the same way. Default remains single-click connect.',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': 'Only show ungrouped hosts at root',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'When enabled, the root host list only shows hosts without a group. Open a group from the sidebar to see grouped hosts.',
|
||||
'settings.appearance.tabBarPosition': 'Tab bar position',
|
||||
'settings.appearance.tabBarPosition.top': 'Top',
|
||||
'settings.appearance.tabBarPosition.bottom': 'Bottom',
|
||||
'settings.vault.showSftpTab': 'Show SFTP tab',
|
||||
'settings.vault.showSftpTabDesc': 'Display the standalone SFTP view in the top tab bar. When hidden, use the in-session SFTP side panel instead.',
|
||||
'settings.vault.showHostTreeSidebar': 'Show host list sidebar',
|
||||
|
||||
@@ -266,8 +266,6 @@ export const enVaultMessages: Messages = {
|
||||
'pf.deleteActive.confirm': 'Stop and Delete',
|
||||
'pf.form.autoStart': 'Auto Start',
|
||||
'pf.form.autoStartDesc': 'Automatically start this rule when the app launches',
|
||||
'pf.form.autoReconnect': 'Auto Reconnect',
|
||||
'pf.form.autoReconnectDesc': 'Automatically reconnect when the connection drops',
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': 'New Folder',
|
||||
|
||||
@@ -273,9 +273,6 @@ export const esCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': 'Haz clic una vez para seleccionar un host (resaltado con el color de acento en la vista de cuadrícula), haz clic de nuevo para conectarte. Los grupos funcionan igual. El valor predeterminado sigue siendo conectarse con un solo clic.',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': 'Mostrar solo hosts sin grupo en la raíz',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Cuando está activado, la lista de hosts de la raíz solo muestra hosts sin grupo. Abre un grupo desde la barra lateral para ver los hosts agrupados.',
|
||||
'settings.appearance.tabBarPosition': 'Posición de la barra de pestañas',
|
||||
'settings.appearance.tabBarPosition.top': 'Arriba',
|
||||
'settings.appearance.tabBarPosition.bottom': 'Abajo',
|
||||
'settings.vault.showSftpTab': 'Mostrar pestaña SFTP',
|
||||
'settings.vault.showSftpTabDesc': 'Muestra la vista SFTP independiente en la barra de pestañas superior. Cuando está oculta, usa el panel lateral SFTP dentro de la sesión.',
|
||||
'settings.vault.showHostTreeSidebar': 'Mostrar barra lateral de lista de hosts',
|
||||
|
||||
@@ -266,8 +266,6 @@ export const esVaultMessages: Messages = {
|
||||
'pf.deleteActive.confirm': 'Detener y eliminar',
|
||||
'pf.form.autoStart': 'Inicio automático',
|
||||
'pf.form.autoStartDesc': 'Inicia automáticamente esta regla cuando se abra la aplicación',
|
||||
'pf.form.autoReconnect': 'Reconexión automática',
|
||||
'pf.form.autoReconnectDesc': 'Vuelve a conectar automáticamente si se pierde la conexión',
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': 'Nueva carpeta',
|
||||
|
||||
@@ -342,9 +342,6 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': 'Один клик выбирает хост (акцентная рамка в сетке), повторный клик подключает. Группы работают так же. По умолчанию — подключение одним кликом.',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': 'Показывать в корне только хосты без группы',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Если включено, в корневом списке хостов будут показаны только хосты без группы. Откройте группу на боковой панели, чтобы увидеть сгруппированные хосты.',
|
||||
'settings.appearance.tabBarPosition': 'Расположение панели вкладок',
|
||||
'settings.appearance.tabBarPosition.top': 'Сверху',
|
||||
'settings.appearance.tabBarPosition.bottom': 'Снизу',
|
||||
'settings.vault.showSftpTab': 'Показывать вкладку SFTP',
|
||||
'settings.vault.showSftpTabDesc': 'Показывать отдельный SFTP-вид в верхней панели вкладок. Если скрыто, используйте боковую панель SFTP внутри сессии.',
|
||||
'settings.vault.showHostTreeSidebar': 'Показывать боковую панель хостов',
|
||||
|
||||
@@ -304,8 +304,6 @@ export const ruVaultMessages: Messages = {
|
||||
'pf.deleteActive.confirm': 'Остановить и удалить',
|
||||
'pf.form.autoStart': 'Автозапуск',
|
||||
'pf.form.autoStartDesc': 'Автоматически запускать это правило при запуске приложения',
|
||||
'pf.form.autoReconnect': 'Автопереподключение',
|
||||
'pf.form.autoReconnectDesc': 'Автоматически восстанавливать соединение при обрыве',
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': 'Новая папка',
|
||||
|
||||
@@ -356,9 +356,6 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': '单击选中主机(网格模式显示强调色边框),再次单击连接;分组同理。默认仍为单击直接连接。',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': '根目录只显示未分组主机',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': '开启后,主机库根目录的主机列表只显示没有分组的主机,已分组主机请从左侧分组进入查看。',
|
||||
'settings.appearance.tabBarPosition': '标签栏位置',
|
||||
'settings.appearance.tabBarPosition.top': '顶部',
|
||||
'settings.appearance.tabBarPosition.bottom': '底部',
|
||||
'settings.vault.showSftpTab': '显示 SFTP 标签页',
|
||||
'settings.vault.showSftpTabDesc': '在顶部标签栏显示独立的 SFTP 视图。关闭后可改用会话内左侧的 SFTP 侧栏。',
|
||||
'settings.vault.showHostTreeSidebar': '显示主机列表侧栏',
|
||||
|
||||
@@ -980,8 +980,6 @@ export const zhCNVaultMessages: Messages = {
|
||||
'pf.deleteActive.confirm': '关闭并删除',
|
||||
'pf.form.autoStart': '自动启动',
|
||||
'pf.form.autoStartDesc': '应用启动时自动开启此规则',
|
||||
'pf.form.autoReconnect': '自动重连',
|
||||
'pf.form.autoReconnectDesc': '连接断开后自动尝试重新连接',
|
||||
|
||||
// SFTP (pane + conflict)
|
||||
'sftp.pane.local': '本地',
|
||||
|
||||
@@ -358,9 +358,6 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': '點一下選取主機(網格模式會顯示強調色邊框),再點一下才連線;群組同理。預設仍是點一下直接連線。',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': '根目錄只顯示未歸類主機',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': '開啟後,主機庫根目錄的主機列表只顯示沒有群組的主機,已歸類的主機請從左側群組進入檢視。',
|
||||
'settings.appearance.tabBarPosition': '分頁列位置',
|
||||
'settings.appearance.tabBarPosition.top': '頂部',
|
||||
'settings.appearance.tabBarPosition.bottom': '底部',
|
||||
'settings.vault.showSftpTab': '顯示 SFTP 分頁',
|
||||
'settings.vault.showSftpTabDesc': '在頂部分頁列顯示獨立的 SFTP 檢視。關閉後可改用工作階段內左側的 SFTP 側欄。',
|
||||
'settings.vault.showHostTreeSidebar': '顯示主機列表側欄',
|
||||
|
||||
@@ -980,8 +980,6 @@ export const zhTWVaultMessages: Messages = {
|
||||
'pf.deleteActive.confirm': '關閉並刪除',
|
||||
'pf.form.autoStart': '自動啟動',
|
||||
'pf.form.autoStartDesc': '應用程式啟動時自動開啟此規則',
|
||||
'pf.form.autoReconnect': '自動重連',
|
||||
'pf.form.autoReconnectDesc': '連線中斷後自動嘗試重新連線',
|
||||
|
||||
// SFTP (pane + conflict)
|
||||
'sftp.pane.local': '本機',
|
||||
|
||||
@@ -23,7 +23,6 @@ export type SettingsChromeSnapshot = {
|
||||
darkUiThemeId: string;
|
||||
uiLanguage: UILanguage;
|
||||
windowOpacity: number;
|
||||
tabBarPosition: 'top' | 'bottom';
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
showRecentHosts: boolean;
|
||||
@@ -56,7 +55,6 @@ export const DEFAULT_SETTINGS_CHROME_SNAPSHOT: SettingsChromeSnapshot = Object.f
|
||||
darkUiThemeId: 'default',
|
||||
uiLanguage: 'en',
|
||||
windowOpacity: 1,
|
||||
tabBarPosition: 'top',
|
||||
showSftpTab: true,
|
||||
showHostTreeSidebar: true,
|
||||
showRecentHosts: true,
|
||||
@@ -82,7 +80,6 @@ export function settingsChromeSnapshotsEqual(
|
||||
&& a.darkUiThemeId === b.darkUiThemeId
|
||||
&& a.uiLanguage === b.uiLanguage
|
||||
&& a.windowOpacity === b.windowOpacity
|
||||
&& a.tabBarPosition === b.tabBarPosition
|
||||
&& a.showSftpTab === b.showSftpTab
|
||||
&& a.showHostTreeSidebar === b.showHostTreeSidebar
|
||||
&& a.showRecentHosts === b.showRecentHosts
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { normalizeTabBarPosition, type TabBarPosition } from '../../domain/tabBarPosition';
|
||||
import { useEffect, type Dispatch, type SetStateAction } from 'react';
|
||||
import type { CustomKeyBindings, HotkeyScheme, SessionLogFormat, TerminalSettings, UILanguage } from '../../domain/models';
|
||||
import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindings';
|
||||
@@ -45,7 +44,6 @@ import {
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
@@ -102,7 +100,6 @@ interface UseSettingsIpcSyncParams {
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
setTabBarPositionState: Dispatch<SetStateAction<TabBarPosition>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
|
||||
@@ -149,7 +146,6 @@ export function useSettingsIpcSync({
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setTabBarPositionState,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
@@ -304,9 +300,6 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
|
||||
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_TAB_BAR_POSITION) {
|
||||
setTabBarPositionState(normalizeTabBarPosition(value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && typeof value === 'boolean') {
|
||||
setShowHostTreeSidebarState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -367,7 +360,6 @@ export function useSettingsIpcSync({
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setTabBarPositionState,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { normalizeTabBarPosition, type TabBarPosition } from '../../domain/tabBarPosition';
|
||||
import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react';
|
||||
import type { CustomKeyBindings, HotkeyScheme, SessionLogFormat, TerminalSettings, UILanguage } from '../../domain/models';
|
||||
import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindings';
|
||||
@@ -35,7 +34,6 @@ import {
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
@@ -92,7 +90,6 @@ interface UseSettingsStorageSyncParams {
|
||||
showRecentHosts: boolean;
|
||||
hostClickBehavior: HostClickBehavior;
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
tabBarPosition: TabBarPosition;
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
terminalSidePanelAutoOpen: boolean;
|
||||
@@ -142,7 +139,6 @@ interface UseSettingsStorageSyncParams {
|
||||
setShowRecentHostsState: Dispatch<SetStateAction<boolean>>;
|
||||
setHostClickBehaviorState: Dispatch<SetStateAction<HostClickBehavior>>;
|
||||
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
|
||||
setTabBarPositionState: Dispatch<SetStateAction<TabBarPosition>>;
|
||||
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -180,7 +176,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
|
||||
@@ -189,7 +185,7 @@ export function useSettingsStorageSync({
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setTabBarPositionState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
@@ -204,7 +200,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
});
|
||||
@@ -214,7 +210,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
};
|
||||
@@ -435,9 +431,6 @@ export function useSettingsStorageSync({
|
||||
setShowOnlyUngroupedHostsInRootState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_TAB_BAR_POSITION) {
|
||||
setTabBarPositionState(normalizeTabBarPosition(e.newValue));
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_SFTP_TAB && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showSftpTab) {
|
||||
@@ -582,7 +575,7 @@ export function useSettingsStorageSync({
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
setShowRecentHostsState,
|
||||
setTabBarPositionState, setShowSftpTabState,
|
||||
setShowSftpTabState,
|
||||
setShellOnlyTabNumberShortcutsState,
|
||||
setShowTabNumberBadgesState,
|
||||
setDisableTerminalFontZoomState,
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import TestRenderer, { act } from 'react-test-renderer';
|
||||
import { useCodexConfigModel } from './useCodexConfigModel';
|
||||
import type { ExternalAgentConfig } from '../../infrastructure/ai/types';
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const agent: ExternalAgentConfig = {
|
||||
id: 'discovered_codex', name: 'Codex', command: 'codex', sdkBackend: 'codex', enabled: true,
|
||||
env: { CODEX_HOME: '/fixture/custom' },
|
||||
};
|
||||
|
||||
for (const codexRuntime of ['sdk', 'app-server'] as const) {
|
||||
test(`${codexRuntime}: quick send waits for the picker probe and receives its custom model`, async () => {
|
||||
let resolve!: (result: unknown) => void;
|
||||
const calls: unknown[] = [];
|
||||
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: {
|
||||
aiCodexGetIntegration: (opts: unknown) => { calls.push(opts); return new Promise(done => { resolve = done; }); },
|
||||
} } });
|
||||
let result!: ReturnType<typeof useCodexConfigModel>;
|
||||
function Harness({ config }: { config: ExternalAgentConfig }) {
|
||||
result = useCodexConfigModel(config, true);
|
||||
return null;
|
||||
}
|
||||
let root!: TestRenderer.ReactTestRenderer;
|
||||
try {
|
||||
const config = { ...agent, codexRuntime };
|
||||
await act(async () => { root = TestRenderer.create(React.createElement(Harness, { config })); });
|
||||
assert.equal(result.model, null);
|
||||
let sent = false;
|
||||
const sending = result.loadModel().then(model => { sent = true; return model; });
|
||||
await Promise.resolve();
|
||||
assert.equal(sent, false);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual((calls[0] as { agentEnv: unknown }).agentEnv, agent.env);
|
||||
await act(async () => { resolve({ customConfig: { model: 'glm-5' } }); await sending; });
|
||||
assert.equal(await sending, 'glm-5');
|
||||
assert.equal(result.model, 'glm-5');
|
||||
// A different agent must not inherit the previous home/model or promise.
|
||||
await act(async () => { root.update(React.createElement(Harness, { config: { ...config, env: { CODEX_HOME: '/fixture/other' } } })); });
|
||||
assert.equal(result.model, null);
|
||||
assert.equal(calls.length, 2);
|
||||
await act(async () => { resolve({ customConfig: null }); await result.loadModel(); });
|
||||
assert.equal(result.model, null);
|
||||
} finally {
|
||||
await act(async () => root?.unmount());
|
||||
if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('non-Codex agents keep their model flow without probing Codex', async () => {
|
||||
let result!: ReturnType<typeof useCodexConfigModel>;
|
||||
function Harness() { result = useCodexConfigModel(undefined, true); return null; }
|
||||
let root!: TestRenderer.ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => { root = TestRenderer.create(React.createElement(Harness)); });
|
||||
assert.equal(await result.loadModel(), null);
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { getNetcattyBridge } from '../../infrastructure/ai/aiChatStreamingSupport';
|
||||
import { getManualAgentCommand, matchesManagedAgentConfig } from '../../infrastructure/ai/managedAgents';
|
||||
import type { ExternalAgentConfig } from '../../infrastructure/ai/types';
|
||||
|
||||
/** Share the config probe between the picker and sends, scoped to this agent. */
|
||||
export function useCodexConfigModel(agent: ExternalAgentConfig | undefined, isVisible: boolean) {
|
||||
const loadModel = useMemo(() => {
|
||||
let pending: Promise<string | null> | undefined;
|
||||
return () => {
|
||||
pending ??= Promise.resolve().then(async () => {
|
||||
if (!isVisible || !agent || !matchesManagedAgentConfig(agent, 'codex')) return null;
|
||||
const info = await getNetcattyBridge()?.aiCodexGetIntegration?.({
|
||||
codexPath: getManualAgentCommand(agent),
|
||||
agentEnv: agent.env,
|
||||
});
|
||||
return info?.customConfig?.model || null;
|
||||
}).catch(() => null);
|
||||
return pending;
|
||||
};
|
||||
// Reopening the panel refreshes config changed outside Netcatty.
|
||||
}, [agent, isVisible]);
|
||||
const [result, setResult] = useState<{ loadModel: typeof loadModel; model: string | null }>();
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
let cancelled = false;
|
||||
void loadModel().then(model => {
|
||||
if (!cancelled) setResult({ loadModel, model });
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [isVisible, loadModel]);
|
||||
return { model: result?.loadModel === loadModel ? result.model : null, loadModel };
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
getAutoStartRuleBlockReason,
|
||||
isAutoStartProxyReady,
|
||||
isPortForwardingAutoStartEnabled,
|
||||
isPortForwardingReconnectRequested,
|
||||
} from "./usePortForwardingAutoStart.ts";
|
||||
import type { GroupConfig, Host, PortForwardingRule, ProxyProfile } from "../../domain/models.ts";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys.ts";
|
||||
@@ -178,15 +177,6 @@ test("reconnect eligibility follows the current auto-start setting", () => {
|
||||
assert.equal(isPortForwardingAutoStartEnabled([], "rule-1"), false);
|
||||
});
|
||||
|
||||
test("reconnect is requested for auto-start and auto-reconnect rules only", () => {
|
||||
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: true })], "rule-1"), true);
|
||||
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: true, autoReconnect: false })], "rule-1"), false);
|
||||
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: false, autoReconnect: true })], "rule-1"), true);
|
||||
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: false, autoReconnect: false })], "rule-1"), false);
|
||||
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: false })], "rule-1"), false);
|
||||
assert.equal(isPortForwardingReconnectRequested([], "rule-1"), false);
|
||||
});
|
||||
|
||||
test("network recovery starts an exhausted auto-start rule through the real service path", async (t) => {
|
||||
const env = installStorage();
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
|
||||
@@ -8,7 +8,6 @@ import { GroupConfig, Host, Identity, KnownHost, PortForwardingRule, ProxyProfil
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../../domain/groupConfig";
|
||||
import { materializeHostProxyProfile } from "../../domain/proxyProfiles";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
|
||||
import { isPortForwardingAutoReconnectEnabled } from "../../domain/portForwardingReconnect";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
getActiveConnection,
|
||||
@@ -104,11 +103,6 @@ export const isPortForwardingAutoStartEnabled = (
|
||||
ruleId: string,
|
||||
): boolean => rules.some((rule) => rule.id === ruleId && rule.autoStart === true);
|
||||
|
||||
export const isPortForwardingReconnectRequested = (
|
||||
rules: PortForwardingRule[],
|
||||
ruleId: string,
|
||||
): boolean => rules.some((rule) => rule.id === ruleId && isPortForwardingAutoReconnectEnabled(rule));
|
||||
|
||||
export const shouldStartPortForwardingAutoStartRule = (
|
||||
rule: PortForwardingRule,
|
||||
connection?: { status: PortForwardingRule["status"] },
|
||||
@@ -116,18 +110,6 @@ export const shouldStartPortForwardingAutoStartRule = (
|
||||
!connection || connection.status === "inactive" || connection.status === "error"
|
||||
);
|
||||
|
||||
/**
|
||||
* Network-recovery counterpart of `shouldStartPortForwardingAutoStartRule`:
|
||||
* unlike launch-time auto-start, rules that only opt in via `autoReconnect`
|
||||
* are also restarted after network restoration.
|
||||
*/
|
||||
export const shouldRestartPortForwardingReconnectRule = (
|
||||
rule: PortForwardingRule,
|
||||
connection?: { status: PortForwardingRule["status"] },
|
||||
): boolean => isPortForwardingAutoReconnectEnabled(rule) && (
|
||||
!connection || connection.status === "inactive" || connection.status === "error"
|
||||
);
|
||||
|
||||
export const recoverPortForwardingAutoStartAfterNetworkRestore = async (
|
||||
restartAutoStartRules: (recoverableRuleIds: ReadonlySet<string>) => Promise<void>,
|
||||
): Promise<void> => {
|
||||
@@ -136,7 +118,7 @@ export const recoverPortForwardingAutoStartAfterNetworkRestore = async (
|
||||
) ?? [];
|
||||
const recoverableRuleIds = new Set<string>();
|
||||
for (const rule of rules) {
|
||||
if (isPortForwardingAutoReconnectEnabled(rule) && resetReconnectAttempts(rule.id)) {
|
||||
if (rule.autoStart && resetReconnectAttempts(rule.id)) {
|
||||
recoverableRuleIds.add(rule.id);
|
||||
}
|
||||
}
|
||||
@@ -202,7 +184,7 @@ export const runPortForwardingAutoStart = async ({
|
||||
recoveryRuleIds,
|
||||
}: RunPortForwardingAutoStartOptions): Promise<void> => {
|
||||
await syncWithBackend({
|
||||
shouldReconnect: (ruleId) => isPortForwardingReconnectRequested(
|
||||
shouldReconnect: (ruleId) => isPortForwardingAutoStartEnabled(
|
||||
localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING) ?? [],
|
||||
ruleId,
|
||||
),
|
||||
@@ -215,11 +197,9 @@ export const runPortForwardingAutoStart = async ({
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
) ?? [];
|
||||
const autoStartRules = rules.filter((rule) =>
|
||||
recoveryRuleIds
|
||||
? recoveryRuleIds.has(rule.id) &&
|
||||
isReconnectRecoveryEligible(rule.id) &&
|
||||
shouldRestartPortForwardingReconnectRule(rule, getActiveConnection(rule.id))
|
||||
: shouldStartPortForwardingAutoStartRule(rule, getActiveConnection(rule.id)),
|
||||
(!recoveryRuleIds || recoveryRuleIds.has(rule.id)) &&
|
||||
(!recoveryRuleIds || isReconnectRecoveryEligible(rule.id)) &&
|
||||
shouldStartPortForwardingAutoStartRule(rule, getActiveConnection(rule.id)),
|
||||
);
|
||||
|
||||
if (autoStartRules.length === 0) return;
|
||||
@@ -405,7 +385,7 @@ export const usePortForwardingAutoStart = ({
|
||||
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true, terminalSettingsRef.current, knownHostsRef.current);
|
||||
};
|
||||
|
||||
setReconnectCallback(handleReconnect, (ruleId) => isPortForwardingReconnectRequested(
|
||||
setReconnectCallback(handleReconnect, (ruleId) => isPortForwardingAutoStartEnabled(
|
||||
localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING) ?? [],
|
||||
ruleId,
|
||||
));
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { normalizeTabBarPosition, type TabBarPosition } from '../../domain/tabBarPosition';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
||||
|
||||
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
|
||||
@@ -79,7 +78,6 @@ import {
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
@@ -415,9 +413,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
return stored ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT;
|
||||
});
|
||||
const [tabBarPosition, setTabBarPositionState] = useState<TabBarPosition>(() =>
|
||||
normalizeTabBarPosition(readStoredString(STORAGE_KEY_TAB_BAR_POSITION)),
|
||||
);
|
||||
const [showSftpTab, setShowSftpTabState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
return stored ?? DEFAULT_SHOW_SFTP_TAB;
|
||||
@@ -1017,7 +1012,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
);
|
||||
const storedShowOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
setShowOnlyUngroupedHostsInRootState(storedShowOnlyUngroupedHostsInRoot ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
setTabBarPositionState(normalizeTabBarPosition(readStoredString(STORAGE_KEY_TAB_BAR_POSITION)));
|
||||
const storedShowSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
|
||||
const storedShowHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
@@ -1204,7 +1198,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setTabBarPositionState,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
@@ -1261,7 +1254,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
|
||||
@@ -1271,7 +1264,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setTabBarPositionState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
@@ -1401,14 +1394,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setTabBarPosition = useCallback((value: TabBarPosition) => {
|
||||
const next = normalizeTabBarPosition(value);
|
||||
setTabBarPositionState(next);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TAB_BAR_POSITION, next);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TAB_BAR_POSITION, next);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShowSftpTab = useCallback((enabled: boolean) => {
|
||||
setShowSftpTabState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
|
||||
@@ -1880,7 +1865,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
darkUiThemeId,
|
||||
uiLanguage,
|
||||
windowOpacity,
|
||||
tabBarPosition,
|
||||
showSftpTab,
|
||||
showHostTreeSidebar,
|
||||
showRecentHosts,
|
||||
@@ -1907,7 +1891,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
showHostTreeSidebar,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
showRecentHosts,
|
||||
tabBarPosition,
|
||||
showSftpTab,
|
||||
showTabNumberBadges,
|
||||
terminalSettings.dynamicTabTitleMode,
|
||||
@@ -2080,9 +2063,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setHostClickBehavior,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
setShowOnlyUngroupedHostsInRoot,
|
||||
tabBarPosition,
|
||||
showSftpTab,
|
||||
setTabBarPosition,
|
||||
setShowSftpTab,
|
||||
showHostTreeSidebar,
|
||||
setShowHostTreeSidebar,
|
||||
@@ -2158,7 +2139,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -32,11 +32,6 @@ export const useTrayPanelBackend = () => {
|
||||
await bridge?.connectToHostFromTrayPanel?.(hostId);
|
||||
}, []);
|
||||
|
||||
const startPortForwardFromTrayPanel = useCallback(async (ruleId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.startPortForwardFromTrayPanel?.(ruleId);
|
||||
}, []);
|
||||
|
||||
const onTrayPanelCloseRequest = useCallback((callback: () => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onTrayPanelCloseRequest?.(callback);
|
||||
@@ -76,7 +71,6 @@ export const useTrayPanelBackend = () => {
|
||||
quitApp,
|
||||
jumpToSession,
|
||||
closeSessionFromTrayPanel,
|
||||
startPortForwardFromTrayPanel,
|
||||
connectToHostFromTrayPanel,
|
||||
onTrayPanelCloseRequest,
|
||||
onTrayPanelRefresh,
|
||||
|
||||
@@ -1963,21 +1963,3 @@ test("applySyncPayload applies pluginSidecars through the production applier hoo
|
||||
|
||||
assert.equal((applied as SyncPayload["pluginSidecars"])?.entries[0].value.clock, 3);
|
||||
});
|
||||
|
||||
test("tab bar position survives settings export and import, with a safe fallback", async () => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION, "bottom");
|
||||
const payload = buildSyncPayload(vault([]));
|
||||
assert.equal(payload.settings?.tabBarPosition, "bottom");
|
||||
localStorage.removeItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION);
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "bottom");
|
||||
|
||||
const invalid = { ...payload, settings: { tabBarPosition: "left" } } as unknown as SyncPayload;
|
||||
await applySyncPayload(invalid, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "top");
|
||||
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION, "bottom");
|
||||
await applySyncPayload({ ...payload, settings: {} }, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "bottom");
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { normalizeTabBarPosition } from '../domain/tabBarPosition';
|
||||
/**
|
||||
* Sync Payload Builders - Single source of truth for constructing and applying
|
||||
* the encrypted cloud-sync payload.
|
||||
@@ -93,7 +92,6 @@ import {
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
@@ -317,7 +315,6 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
@@ -545,8 +542,6 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
}
|
||||
const showOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
|
||||
const tabBarPosition = localStorageAdapter.readString(STORAGE_KEY_TAB_BAR_POSITION);
|
||||
if (tabBarPosition != null) settings.tabBarPosition = normalizeTabBarPosition(tabBarPosition);
|
||||
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
|
||||
const shellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
@@ -800,9 +795,6 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
settings.showOnlyUngroupedHostsInRoot,
|
||||
);
|
||||
}
|
||||
if (settings.tabBarPosition != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TAB_BAR_POSITION, normalizeTabBarPosition(settings.tabBarPosition));
|
||||
}
|
||||
if (settings.showSftpTab != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
isInlineTextAttachment,
|
||||
} from '../application/state/terminalSelectionAttachment';
|
||||
import { createVaultNoteAttachment, isVaultNoteAttachment, vaultNoteReferencesFit } from '../application/state/vaultNoteAttachment';
|
||||
import { useCodexConfigModel } from '../application/state/useCodexConfigModel';
|
||||
import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
|
||||
import {
|
||||
useAIChatStreaming,
|
||||
getNetcattyBridge,
|
||||
@@ -843,9 +843,35 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
[currentAgentConfig],
|
||||
);
|
||||
|
||||
const { model: codexConfigModel, loadModel: loadCodexConfigModel } = useCodexConfigModel(
|
||||
currentAgentConfig, isVisible,
|
||||
);
|
||||
const [codexConfigModel, setCodexConfigModel] = useState<string | null>(null);
|
||||
const [codexCustomConfigResolved, setCodexCustomConfigResolved] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
setCodexCustomConfigResolved(false);
|
||||
if (!isCodexManagedAgent) {
|
||||
setCodexConfigModel(null);
|
||||
return;
|
||||
}
|
||||
const bridge = getNetcattyBridge();
|
||||
if (!bridge?.aiCodexGetIntegration) return;
|
||||
let cancelled = false;
|
||||
void Promise.resolve(
|
||||
bridge.aiCodexGetIntegration({ codexPath: getManualAgentCommand(currentAgentConfig) }) as Promise<CodexIntegrationStatus>,
|
||||
).then((info) => {
|
||||
if (cancelled) return;
|
||||
const hasCustom = info?.state === 'connected_custom_config';
|
||||
setCodexConfigModel(info?.customConfig?.model ?? null);
|
||||
setCodexCustomConfigResolved(hasCustom);
|
||||
}).catch(() => {
|
||||
if (!cancelled) {
|
||||
setCodexConfigModel(null);
|
||||
setCodexCustomConfigResolved(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isVisible, isCodexManagedAgent, currentAgentId, currentAgentConfig]);
|
||||
|
||||
const agentModelMapRef = useRef(agentModelMap);
|
||||
agentModelMapRef.current = agentModelMap;
|
||||
@@ -1009,12 +1035,18 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
const isCodexAppServer = isCodexManagedAgent && currentAgentConfig?.codexRuntime === 'app-server';
|
||||
const canSteerCurrentTurn = Boolean(activeSessionId && isStreaming && isCodexAppServer);
|
||||
const hasCodexCustomConfig = Boolean(codexConfigModel) && isCodexManagedAgent;
|
||||
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent && !isCodexAppServer;
|
||||
|
||||
const agentModelPresets = useMemo(() => {
|
||||
const runtimePresets = runtimeAgentModelPresets[currentAgentId];
|
||||
if (hasCodexCustomConfig && codexConfigModel) {
|
||||
return [{ id: codexConfigModel, name: codexConfigModel }];
|
||||
if (hasCodexCustomConfig) {
|
||||
if (runtimePresets) {
|
||||
return runtimePresets;
|
||||
}
|
||||
if (codexConfigModel) {
|
||||
return [{ id: codexConfigModel, name: codexConfigModel }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
if (runtimePresets) return runtimePresets;
|
||||
const presets = getAgentModelPresets(
|
||||
@@ -1204,11 +1236,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
if (sendBridge?.aiSyncWebSearch) {
|
||||
await sendBridge.aiSyncWebSearch(webSearchConfig?.apiHost || null, webSearchConfig?.apiKey || null);
|
||||
}
|
||||
// A quick send must await the same config probe as the picker. Its model
|
||||
// also takes precedence over App Server's built-in model catalog.
|
||||
const configModel = await loadCodexConfigModel();
|
||||
let sendSelectedAgentModel = configModel ?? selectedAgentModel;
|
||||
if (!configModel && currentAgentConfig && shouldLoadSdkRuntimeModels(currentAgentConfig)) {
|
||||
let sendSelectedAgentModel = selectedAgentModel;
|
||||
if (currentAgentConfig && shouldLoadSdkRuntimeModels(currentAgentConfig)) {
|
||||
const runtimeTarget = buildExternalAgentRuntimeModelTarget(currentAgentConfig);
|
||||
if (runtimeTarget) {
|
||||
const catalog = await loadSdkRuntimeModelCatalog(runtimeTarget);
|
||||
@@ -1392,7 +1421,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
}
|
||||
}, [
|
||||
validateNoteMentions, loadCodexConfigModel,
|
||||
validateNoteMentions,
|
||||
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
|
||||
activeModelId, externalAgents,
|
||||
createSession, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
isPortForwardingRuleStartable,
|
||||
isPortForwardingRuleStoppable,
|
||||
} from "../domain/portForwardingBulkActions";
|
||||
import { isPortForwardingAutoReconnectEnabled } from "../domain/portForwardingReconnect";
|
||||
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
||||
import { cn } from "../lib/utils";
|
||||
import SelectHostPanel from "./SelectHostPanel";
|
||||
@@ -208,7 +207,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
);
|
||||
}
|
||||
},
|
||||
isPortForwardingAutoReconnectEnabled(rule), // Enable reconnect for auto-reconnect rules (auto-start implies reconnect)
|
||||
rule.autoStart, // Enable reconnect for auto-start rules
|
||||
terminalSettings,
|
||||
knownHosts,
|
||||
);
|
||||
@@ -517,8 +516,6 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
remoteHost: newFormDraft.remoteHost,
|
||||
remotePort: newFormDraft.remotePort,
|
||||
hostId: newFormDraft.hostId,
|
||||
autoStart: newFormDraft.autoStart ?? false,
|
||||
autoReconnect: isPortForwardingAutoReconnectEnabled(newFormDraft),
|
||||
});
|
||||
|
||||
setShowNewForm(false);
|
||||
@@ -717,8 +714,6 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
remoteHost: draftRule.remoteHost,
|
||||
remotePort: draftRule.remotePort,
|
||||
hostId: draftRule.hostId,
|
||||
autoStart: draftRule.autoStart ?? false,
|
||||
autoReconnect: isPortForwardingAutoReconnectEnabled(draftRule),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -545,8 +545,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
||||
setHostClickBehavior={settings.setHostClickBehavior}
|
||||
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
|
||||
setShowOnlyUngroupedHostsInRoot={settings.setShowOnlyUngroupedHostsInRoot}
|
||||
tabBarPosition={settings.tabBarPosition}
|
||||
setTabBarPosition={settings.setTabBarPosition}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
setShowSftpTab={settings.setShowSftpTab}
|
||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||
|
||||
+8
-23
@@ -226,7 +226,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
const { t } = useI18n();
|
||||
const { maximize, isFullscreen, onFullscreenChanged } = useWindowControls();
|
||||
const {
|
||||
tabBarPosition,
|
||||
hotkeyScheme,
|
||||
showTabNumberBadges,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
@@ -268,8 +267,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
const [hostTreeChromeReady, setHostTreeChromeReady] = useState(false);
|
||||
const [hostTreeGutterExiting, setHostTreeGutterExiting] = useState(false);
|
||||
const [rootTabsCompact, setRootTabsCompact] = useState(false);
|
||||
const tabsAtBottom = tabBarPosition === 'bottom';
|
||||
const showWindowControls = !isMacClient && !tabsAtBottom;
|
||||
const showWindowControls = !isMacClient;
|
||||
|
||||
// Tab reorder drag state
|
||||
const [dropIndicator, setDropIndicator] = useState<{ tabId: string; position: 'before' | 'after' } | null>(null);
|
||||
@@ -996,24 +994,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
}, [isMacClient, maximize]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-tab-window-titlebar
|
||||
data-section="top-tabs"
|
||||
className={cn("h-9 shrink-0 items-end justify-end bg-secondary app-drag", tabsAtBottom ? "flex" : "hidden")}
|
||||
style={dragRegionNoSelect}
|
||||
onDoubleClick={handleTitleBarDoubleClick}
|
||||
>
|
||||
{!isMacClient && <WindowControls />}
|
||||
</div>
|
||||
<div
|
||||
data-top-tabs-root
|
||||
data-position={tabBarPosition}
|
||||
data-section="top-tabs"
|
||||
className="relative w-full shrink-0 bg-secondary app-drag"
|
||||
className="relative w-full bg-secondary app-drag"
|
||||
style={{
|
||||
...dragRegionNoSelect,
|
||||
order: tabsAtBottom ? 2 : undefined,
|
||||
backgroundColor: 'var(--top-tabs-bg, hsl(var(--secondary)))',
|
||||
color: 'var(--top-tabs-fg, hsl(var(--foreground)))',
|
||||
}}
|
||||
@@ -1024,12 +1010,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
updateScrollState={updateScrollState}
|
||||
/>
|
||||
{/* Always-on drag stripe so the window can be moved even when tabs fill the bar */}
|
||||
<div className={cn("absolute inset-x-0 h-1 app-drag pointer-events-auto z-10", tabsAtBottom ? "bottom-0" : "top-0")} style={dragRegionStyle} aria-hidden />
|
||||
<div className="absolute inset-x-0 top-0 h-1 app-drag pointer-events-auto z-10" style={dragRegionStyle} aria-hidden />
|
||||
<div
|
||||
className={cn("h-9 flex gap-0 app-drag overflow-visible", tabsAtBottom ? "items-start" : "items-end")}
|
||||
className="h-9 flex items-end gap-0 app-drag overflow-visible"
|
||||
style={{
|
||||
...dragRegionStyle,
|
||||
paddingLeft: isMacClient && !isWindowFullscreen && !tabsAtBottom ? 76 : 12,
|
||||
paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12,
|
||||
paddingRight: showWindowControls ? 0 : 12,
|
||||
}}
|
||||
>
|
||||
@@ -1077,7 +1063,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{hasHostTreeToggleSurface && (
|
||||
<div
|
||||
ref={hostTreeToggleSlotRef}
|
||||
className={cn("top-tab-host-tree-toggle-slot mb-0 flex-shrink-0 app-no-drag", tabsAtBottom ? "self-start" : "self-end")}
|
||||
className="top-tab-host-tree-toggle-slot mb-0 flex-shrink-0 self-end app-no-drag"
|
||||
data-section="top-tabs-host-tree-toggle"
|
||||
data-visible={effectiveShowHostTreeToggle ? 'true' : 'false'}
|
||||
style={noDragRegionStyle}
|
||||
@@ -1187,7 +1173,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-7 w-7 flex-shrink-0 app-no-drag rounded-none", tabsAtBottom ? "self-start" : "self-end")}
|
||||
className="h-7 w-7 flex-shrink-0 app-no-drag self-end rounded-none"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={onOpenQuickSwitcher}
|
||||
>
|
||||
@@ -1201,7 +1187,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
|
||||
{/* Fixed right controls — utility icons + window controls share one h-7 row */}
|
||||
<div
|
||||
className={cn("flex-shrink-0 flex items-center gap-0.5 app-drag h-7 overflow-visible", tabsAtBottom ? "self-start" : "self-end")}
|
||||
className="flex-shrink-0 flex items-center gap-0.5 app-drag self-end h-7 overflow-visible"
|
||||
style={dragRegionStyle}
|
||||
data-section="top-tabs-toolbar-actions"
|
||||
>
|
||||
@@ -1276,7 +1262,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,8 +10,10 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider";
|
||||
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
|
||||
import { useActiveTabId } from "../application/state/activeTabStore";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
||||
import { upsertKnownHost } from "../domain/knownHosts";
|
||||
import type { KnownHost } from "../domain/models";
|
||||
import type { Host, KnownHost } from "../domain/models";
|
||||
import { getEffectiveKnownHosts } from "../infrastructure/syncHelpers";
|
||||
import { PortForwardHostKeyTrayPrompt } from "./port-forwarding";
|
||||
import { X, Maximize2, ChevronRight, ChevronDown, Power } from "lucide-react";
|
||||
@@ -134,7 +136,11 @@ const WorkspaceGroup: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
const TrayPanelContent: React.FC = () => {
|
||||
interface TrayPanelContentProps {
|
||||
terminalSettings?: { verifyHostKeys: boolean; keepaliveInterval: number; keepaliveCountMax: number };
|
||||
}
|
||||
|
||||
const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings }) => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
hideTrayPanel,
|
||||
@@ -142,23 +148,27 @@ const TrayPanelContent: React.FC = () => {
|
||||
quitApp,
|
||||
jumpToSession,
|
||||
closeSessionFromTrayPanel,
|
||||
startPortForwardFromTrayPanel,
|
||||
onTrayPanelCloseRequest,
|
||||
onTrayPanelRefresh,
|
||||
onTrayPanelMenuData,
|
||||
} = useTrayPanelBackend();
|
||||
|
||||
const { hosts, knownHosts, updateKnownHosts } = useVaultState();
|
||||
const { hosts, keys, identities, proxyProfiles, groupConfigs, knownHosts, updateKnownHosts } = useVaultState();
|
||||
// TrayPanel runs in its own BrowserWindow, so this hook's session state is
|
||||
// independent from (and typically empty compared to) the main App's — it's
|
||||
// used here only for its storage-sync side effects, never for closeSession.
|
||||
useSessionState({ persistSessionRestore: false });
|
||||
const {
|
||||
rules: portForwardingRules,
|
||||
startTunnel,
|
||||
stopTunnel,
|
||||
hasRuntimeTunnel,
|
||||
} = usePortForwardingState();
|
||||
const activeTabId = useActiveTabId();
|
||||
const proxyProfileIdSet = useMemo(
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
);
|
||||
const effectiveKnownHosts = useMemo(
|
||||
() => getEffectiveKnownHosts(knownHosts) ?? [],
|
||||
[knownHosts],
|
||||
@@ -434,9 +444,16 @@ const TrayPanelContent: React.FC = () => {
|
||||
if (!result.success && result.error) toast.error(result.error);
|
||||
});
|
||||
} else {
|
||||
void startPortForwardFromTrayPanel(rule.id).then((result) => {
|
||||
if (result && !result.success && result.error) toast.error(result.error);
|
||||
});
|
||||
const resolveEffectiveHost = (host: Host) => {
|
||||
const withGroupDefaults = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
};
|
||||
const host = resolveEffectiveHost(rawHost);
|
||||
void startTunnel(rule, host, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart, terminalSettings, effectiveKnownHosts);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
@@ -502,7 +519,7 @@ type SettingsState = AppLockGateRenderContext["settings"];
|
||||
const TrayPanel: React.FC<{ settings: SettingsState }> = ({ settings }) => {
|
||||
return (
|
||||
<I18nProvider locale={settings.uiLanguage}>
|
||||
<TrayPanelContent />
|
||||
<TrayPanelContent terminalSettings={settings.terminalSettings} />
|
||||
</I18nProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
import { ChevronDown,Copy,Trash2 } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Host,PortForwardingRule } from '../../domain/models';
|
||||
import { DistroAvatar } from '../DistroAvatar';
|
||||
@@ -183,18 +182,6 @@ export const EditPanel: React.FC<EditPanelProps> = ({
|
||||
onCheckedChange={checked => onDraftChange({ autoStart: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Auto Reconnect Toggle */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">{t('pf.form.autoReconnect')}</Label>
|
||||
<p className="text-[10px] text-muted-foreground">{t('pf.form.autoReconnectDesc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isPortForwardingAutoReconnectEnabled(draft)}
|
||||
onCheckedChange={checked => onDraftChange({ autoReconnect: checked })}
|
||||
/>
|
||||
</div>
|
||||
</AsidePanelContent>
|
||||
<AsidePanelFooter className="space-y-2">
|
||||
<Button
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
import { ChevronDown,Zap } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Host,PortForwardingRule,PortForwardingType } from '../../domain/models';
|
||||
import { cn } from '../../lib/utils';
|
||||
@@ -183,18 +182,6 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
|
||||
onCheckedChange={checked => onDraftChange({ autoStart: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Auto Reconnect Toggle */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">{t('pf.form.autoReconnect')}</Label>
|
||||
<p className="text-[10px] text-muted-foreground">{t('pf.form.autoReconnectDesc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isPortForwardingAutoReconnectEnabled(draft)}
|
||||
onCheckedChange={checked => onDraftChange({ autoReconnect: checked })}
|
||||
/>
|
||||
</div>
|
||||
</AsidePanelContent>
|
||||
<AsidePanelFooter className="space-y-2">
|
||||
<Button
|
||||
|
||||
@@ -645,11 +645,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
}, 900);
|
||||
}, []);
|
||||
|
||||
const codexManagedAgent = useMemo(
|
||||
() => externalAgents.find((agent) => agent.id === "discovered_codex"),
|
||||
[externalAgents],
|
||||
);
|
||||
|
||||
const refreshCodexIntegration = useCallback(async (opts?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string }) => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiCodexGetIntegration) return;
|
||||
@@ -660,7 +655,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setIsCodexLoading(true);
|
||||
setCodexError(null);
|
||||
try {
|
||||
const integration = await bridge.aiCodexGetIntegration({ ...opts, agentEnv: codexManagedAgent?.env });
|
||||
const integration = await bridge.aiCodexGetIntegration(opts);
|
||||
if (!isCurrentRequest()) return;
|
||||
setCodexIntegration(integration);
|
||||
} catch (err) {
|
||||
@@ -672,7 +667,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setIsCodexLoading(false);
|
||||
}
|
||||
}
|
||||
}, [codexManagedAgent?.env]);
|
||||
}, []);
|
||||
|
||||
const codexCommittedPath = useMemo(
|
||||
() => getManagedAgentCommandPath(externalAgents, "codex") || codexPathInfo?.path || undefined,
|
||||
@@ -687,6 +682,10 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
codexCommittedPath
|
||||
), [codexCommittedPath]);
|
||||
|
||||
const codexManagedAgent = useMemo(
|
||||
() => externalAgents.find((agent) => agent.id === "discovered_codex"),
|
||||
[externalAgents],
|
||||
);
|
||||
const codexRuntime = codexManagedAgent?.codexRuntime ?? 'sdk';
|
||||
|
||||
const refreshCodexAppServerStatus = useCallback(async () => {
|
||||
@@ -862,7 +861,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setCodexError(null);
|
||||
setIsCodexLoading(true);
|
||||
try {
|
||||
const result = await bridge.aiCodexStartLogin({ codexPath: getCodexPathOverride(), agentEnv: codexManagedAgent?.env });
|
||||
const result = await bridge.aiCodexStartLogin({ codexPath: getCodexPathOverride() });
|
||||
if (!isCurrentRequest()) return;
|
||||
if (!result.ok || !result.session) {
|
||||
throw new Error(result.error || "Failed to start Codex login");
|
||||
@@ -877,7 +876,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setIsCodexLoading(false);
|
||||
}
|
||||
}
|
||||
}, [getCodexPathOverride, codexManagedAgent?.env]);
|
||||
}, [getCodexPathOverride]);
|
||||
|
||||
const handleCancelCodexLogin = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
@@ -913,7 +912,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setCodexError(null);
|
||||
setIsCodexLoading(true);
|
||||
try {
|
||||
const result = await bridge.aiCodexLogout({ codexPath: getCodexPathOverride(), agentEnv: codexManagedAgent?.env });
|
||||
const result = await bridge.aiCodexLogout({ codexPath: getCodexPathOverride() });
|
||||
if (!isCurrentRequest()) return;
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error || "Failed to log out from Codex");
|
||||
@@ -929,7 +928,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setIsCodexLoading(false);
|
||||
}
|
||||
}
|
||||
}, [getCodexPathOverride, refreshCodexIntegration, codexManagedAgent?.env]);
|
||||
}, [getCodexPathOverride, refreshCodexIntegration]);
|
||||
|
||||
const refreshUserSkillsStatus = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
|
||||
@@ -60,8 +60,6 @@ function SettingsAppearanceTab(props: {
|
||||
setHostClickBehavior: (behavior: "connect" | "select") => void;
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
setShowOnlyUngroupedHostsInRoot: (enabled: boolean) => void;
|
||||
tabBarPosition: 'top' | 'bottom';
|
||||
setTabBarPosition: (position: 'top' | 'bottom') => void;
|
||||
showSftpTab: boolean;
|
||||
setShowSftpTab: (enabled: boolean) => void;
|
||||
showHostTreeSidebar: boolean;
|
||||
@@ -131,8 +129,6 @@ function SettingsAppearanceTab(props: {
|
||||
setHostClickBehavior,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
setShowOnlyUngroupedHostsInRoot,
|
||||
tabBarPosition,
|
||||
setTabBarPosition,
|
||||
showSftpTab,
|
||||
setShowSftpTab,
|
||||
showHostTreeSidebar,
|
||||
@@ -483,29 +479,6 @@ function SettingsAppearanceTab(props: {
|
||||
onChange={setShowOnlyUngroupedHostsInRoot}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="appearance-tab-bar-position"
|
||||
label={t('settings.appearance.tabBarPosition')}
|
||||
>
|
||||
<div className="flex items-center rounded-lg border border-border bg-muted/50 p-0.5">
|
||||
{(['top', 'bottom'] as const).map((position) => (
|
||||
<button
|
||||
key={position}
|
||||
type="button"
|
||||
aria-pressed={tabBarPosition === position}
|
||||
onClick={() => setTabBarPosition(position)}
|
||||
className={cn(
|
||||
"px-2.5 py-1 rounded-md text-xs font-medium transition-colors",
|
||||
tabBarPosition === position
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(`settings.appearance.tabBarPosition.${position}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="appearance-vault-show-sftp-tab"
|
||||
label={t('settings.vault.showSftpTab')}
|
||||
|
||||
@@ -152,11 +152,11 @@ export interface FetchBridge {
|
||||
export interface NetcattyAiBridge {
|
||||
aiDiscoverAgents?: (options?: { refreshShellEnv?: boolean; apiKeyPresent?: boolean }) => Promise<Array<AgentPathInfo & { command: string }>>;
|
||||
aiPrewarmShellEnv?: () => Promise<{ ok: boolean; error?: string }>;
|
||||
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string; agentEnv?: Record<string, string> }) => Promise<CodexIntegrationStatus>;
|
||||
aiCodexStartLogin?: (options?: { codexPath?: string; agentEnv?: Record<string, string> }) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string }) => Promise<CodexIntegrationStatus>;
|
||||
aiCodexStartLogin?: (options?: { codexPath?: string }) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexGetLoginSession?: (sessionId: string) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexLogout?: (options?: { codexPath?: string; agentEnv?: Record<string, string> }) => Promise<{ ok: boolean; state?: CodexIntegrationState; isConnected?: boolean; rawOutput?: string; logoutOutput?: string; error?: string }>;
|
||||
aiCodexLogout?: (options?: { codexPath?: string }) => Promise<{ ok: boolean; state?: CodexIntegrationState; isConnected?: boolean; rawOutput?: string; logoutOutput?: string; error?: string }>;
|
||||
aiResolveCli?: (params: { command: string; customPath?: string; refreshShellEnv?: boolean; apiKeyPresent?: boolean }) => Promise<AgentPathInfo>;
|
||||
aiSdkAgentListModels?: (sdkBackend: string, cwd?: string, providerId?: string, chatSessionId?: string, agentEnv?: Record<string, string>, agentCommand?: string, codexRuntime?: 'sdk' | 'app-server') => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[]; defaultThinkingLevel?: string }>; currentModelId?: string | null; error?: string }>;
|
||||
codexAppServerGetStatus?: (agentCommand?: string, agentEnv?: Record<string, string>) => Promise<{ ok: boolean; available: boolean; error?: string }>;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getTopTabsChromeRoots } from '../../application/app/topTabsChromeTheme';
|
||||
import React, { createContext, lazy, memo, Suspense, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
|
||||
import { activeTabStore } from '../../application/state/activeTabStore';
|
||||
@@ -228,22 +227,22 @@ export const clearHostTreePreviewVars = () => {
|
||||
|
||||
export const clearTopTabsPreviewVars = () => {
|
||||
if (typeof document === 'undefined') return;
|
||||
for (const tabsRoot of getTopTabsChromeRoots()) {
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-bg');
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-fg');
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-muted');
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-active-bg');
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-accent');
|
||||
removeStylePropertyIfSet(tabsRoot, '--background');
|
||||
removeStylePropertyIfSet(tabsRoot, '--foreground');
|
||||
removeStylePropertyIfSet(tabsRoot, '--accent');
|
||||
removeStylePropertyIfSet(tabsRoot, '--accent-foreground');
|
||||
removeStylePropertyIfSet(tabsRoot, '--primary');
|
||||
removeStylePropertyIfSet(tabsRoot, '--primary-foreground');
|
||||
removeStylePropertyIfSet(tabsRoot, '--secondary');
|
||||
removeStylePropertyIfSet(tabsRoot, '--border');
|
||||
removeStylePropertyIfSet(tabsRoot, '--muted-foreground');
|
||||
}
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-bg');
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-fg');
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-muted');
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-active-bg');
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-accent');
|
||||
removeStylePropertyIfSet(tabsRoot, '--background');
|
||||
removeStylePropertyIfSet(tabsRoot, '--foreground');
|
||||
removeStylePropertyIfSet(tabsRoot, '--accent');
|
||||
removeStylePropertyIfSet(tabsRoot, '--accent-foreground');
|
||||
removeStylePropertyIfSet(tabsRoot, '--primary');
|
||||
removeStylePropertyIfSet(tabsRoot, '--primary-foreground');
|
||||
removeStylePropertyIfSet(tabsRoot, '--secondary');
|
||||
removeStylePropertyIfSet(tabsRoot, '--border');
|
||||
removeStylePropertyIfSet(tabsRoot, '--muted-foreground');
|
||||
};
|
||||
|
||||
export const filterTabsMap = <T,>(source: Map<string, T>, validIds: Set<string>): Map<string, T> => {
|
||||
|
||||
@@ -27,9 +27,6 @@ export interface PortForwardingRule {
|
||||
hostId?: string;
|
||||
// Auto-start: if true, this rule will automatically start when the app launches
|
||||
autoStart?: boolean;
|
||||
// Auto-reconnect: if true, the tunnel automatically reconnects (with bounded
|
||||
// retries) after an unexpected disconnect, regardless of auto-start
|
||||
autoReconnect?: boolean;
|
||||
/**
|
||||
* Runtime projection for the UI. Authoritative phase lives in the Electron
|
||||
* main-process registry; this field is rebuilt from snapshots / events and
|
||||
|
||||
@@ -23,15 +23,6 @@ const parsePort = (value: unknown, name: string): Result<number> => {
|
||||
: { ok: false, error: `${name} must be an integer between 1 and 65535.` };
|
||||
};
|
||||
|
||||
const parseToggle = (value: unknown, name: string): Result<boolean | undefined> => {
|
||||
if (value === undefined) return { ok: true, value: undefined };
|
||||
if (typeof value === 'boolean') return { ok: true, value };
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (['true', '1', 'yes'].includes(normalized)) return { ok: true, value: true };
|
||||
if (['false', '0', 'no'].includes(normalized)) return { ok: true, value: false };
|
||||
return { ok: false, error: `${name} must be true or false.` };
|
||||
};
|
||||
|
||||
export const validatePortForwardingHost = (
|
||||
hosts: Host[],
|
||||
hostId: string | undefined,
|
||||
@@ -70,12 +61,16 @@ function buildRule(
|
||||
remotePort = parsedRemotePort.value;
|
||||
}
|
||||
if (!existing && !newRule) return { ok: false, error: 'New rule id and timestamp are required.' };
|
||||
const autoStartToggle = parseToggle(source.autoStart, 'autoStart');
|
||||
if ('error' in autoStartToggle) return autoStartToggle;
|
||||
const autoStart = autoStartToggle.value ?? existing?.autoStart ?? false;
|
||||
const autoReconnectToggle = parseToggle(source.autoReconnect, 'autoReconnect');
|
||||
if ('error' in autoReconnectToggle) return autoReconnectToggle;
|
||||
const autoReconnect = autoReconnectToggle.value ?? existing?.autoReconnect;
|
||||
let autoStart = existing?.autoStart ?? false;
|
||||
if (source.autoStart !== undefined) {
|
||||
if (typeof source.autoStart === 'boolean') autoStart = source.autoStart;
|
||||
else {
|
||||
const normalized = String(source.autoStart).trim().toLowerCase();
|
||||
if (['true', '1', 'yes'].includes(normalized)) autoStart = true;
|
||||
else if (['false', '0', 'no'].includes(normalized)) autoStart = false;
|
||||
else return { ok: false, error: 'autoStart must be true or false.' };
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
@@ -87,7 +82,6 @@ function buildRule(
|
||||
...(type === 'dynamic' ? {} : { remoteHost, remotePort }),
|
||||
hostId,
|
||||
autoStart,
|
||||
...(autoReconnect === undefined ? {} : { autoReconnect }),
|
||||
status: existing?.status ?? 'inactive',
|
||||
error: existing?.error,
|
||||
lastUsedAt: existing?.lastUsedAt,
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { isPortForwardingAutoReconnectEnabled } from "./portForwardingReconnect";
|
||||
|
||||
describe("isPortForwardingAutoReconnectEnabled", () => {
|
||||
it("returns true when autoReconnect is enabled", () => {
|
||||
assert.equal(isPortForwardingAutoReconnectEnabled({ autoReconnect: true }), true);
|
||||
});
|
||||
|
||||
it("returns true for auto-start rules (pre-existing behavior)", () => {
|
||||
assert.equal(isPortForwardingAutoReconnectEnabled({ autoStart: true }), true);
|
||||
});
|
||||
|
||||
it("honors explicit disable even for auto-start rules", () => {
|
||||
assert.equal(isPortForwardingAutoReconnectEnabled({ autoStart: true, autoReconnect: false }), false);
|
||||
});
|
||||
|
||||
it("returns false when neither flag opts in", () => {
|
||||
assert.equal(isPortForwardingAutoReconnectEnabled({}), false);
|
||||
assert.equal(isPortForwardingAutoReconnectEnabled({ autoStart: false }), false);
|
||||
assert.equal(isPortForwardingAutoReconnectEnabled({ autoReconnect: false }), false);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { PortForwardingRule } from "./models";
|
||||
|
||||
/**
|
||||
* Whether a port-forwarding rule should automatically reconnect (with bounded
|
||||
* retries) after an unexpected disconnect.
|
||||
*
|
||||
* Legacy rules without `autoReconnect` retain their auto-start reconnect
|
||||
* behavior. An explicit choice always takes precedence.
|
||||
*/
|
||||
export const isPortForwardingAutoReconnectEnabled = (
|
||||
rule: Pick<PortForwardingRule, "autoStart" | "autoReconnect">,
|
||||
): boolean => rule.autoReconnect ?? rule.autoStart === true;
|
||||
@@ -158,12 +158,6 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
|
||||
descriptionKey: "settings.vault.showOnlyUngroupedHostsInRootDesc",
|
||||
sectionKey: "settings.vault.title",
|
||||
},
|
||||
{
|
||||
id: "appearance-tab-bar-position",
|
||||
tab: "appearance",
|
||||
labelKey: "settings.appearance.tabBarPosition",
|
||||
sectionKey: "settings.vault.title",
|
||||
},
|
||||
{
|
||||
id: "appearance-vault-show-sftp-tab",
|
||||
tab: "appearance",
|
||||
|
||||
@@ -337,7 +337,6 @@ export interface SyncPayload {
|
||||
// Vault: root list shows only ungrouped hosts
|
||||
showOnlyUngroupedHostsInRoot?: boolean;
|
||||
// Top tabs: show standalone SFTP view tab
|
||||
tabBarPosition?: 'top' | 'bottom';
|
||||
showSftpTab?: boolean;
|
||||
// Shortcuts: Cmd/Ctrl+[1...9] and Ctrl+Tab skip pinned Vault/SFTP tabs
|
||||
shellOnlyTabNumberShortcuts?: boolean;
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export type TabBarPosition = 'top' | 'bottom';
|
||||
|
||||
export function normalizeTabBarPosition(value: unknown): TabBarPosition {
|
||||
return value === 'bottom' ? 'bottom' : 'top';
|
||||
}
|
||||
@@ -262,21 +262,9 @@ function parseCodexConfigToml(text) {
|
||||
* because the config.toml is a strong signal the user doesn't want that.
|
||||
*/
|
||||
function readCodexCustomProviderConfig(shellEnv) {
|
||||
// Codex honors $CODEX_HOME and reads $CODEX_HOME/config.toml (see
|
||||
// codexAppServer/protocol.schema.json), so prefer it over the legacy
|
||||
// ~/.codex location — otherwise a custom provider configured under
|
||||
// CODEX_HOME would be missed (or an unrelated default-home config could
|
||||
// shadow what the CLI actually runs).
|
||||
const codexHome = typeof shellEnv?.CODEX_HOME === "string" ? shellEnv.CODEX_HOME.trim() : "";
|
||||
const home = shellEnv?.HOME || shellEnv?.USERPROFILE || os.homedir();
|
||||
if (codexHome) {
|
||||
return readCodexCustomProviderConfigFrom(path.join(codexHome, "config.toml"), shellEnv);
|
||||
}
|
||||
if (!home) return null;
|
||||
return readCodexCustomProviderConfigFrom(path.join(home, ".codex", "config.toml"), shellEnv);
|
||||
}
|
||||
|
||||
function readCodexCustomProviderConfigFrom(configPath, shellEnv) {
|
||||
const configPath = path.join(home, ".codex", "config.toml");
|
||||
if (!existsSync(configPath)) return null;
|
||||
|
||||
let text;
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const {
|
||||
MAX_CODEX_LOGIN_OUTPUT_CHARS,
|
||||
@@ -16,7 +13,6 @@ const {
|
||||
extractCodexError,
|
||||
isCodexAuthError,
|
||||
normalizeCodexIntegrationState,
|
||||
readCodexCustomProviderConfig,
|
||||
recordCodexLoginSession,
|
||||
stopCodexLoginProcess,
|
||||
} = require("./codexHelpers.cjs");
|
||||
@@ -181,97 +177,3 @@ test("extractCodexError handles circular structured errors", () => {
|
||||
|
||||
assert.equal(normalized.message, '{"status":500,"self":"[Circular]"}');
|
||||
});
|
||||
|
||||
test("readCodexCustomProviderConfig parses a switcher-style third-party config.toml", (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-config-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
fs.mkdirSync(path.join(tempDir, ".codex"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, ".codex", "config.toml"),
|
||||
[
|
||||
'model_provider = "ccs"',
|
||||
'model = "glm-5"',
|
||||
"",
|
||||
"[model_providers.ccs]",
|
||||
'name = "Coding Plan"',
|
||||
'base_url = "https://example.invalid/v1"',
|
||||
'wire_api = "responses"',
|
||||
'env_key = "CODING_PLAN_API_KEY"',
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const config = readCodexCustomProviderConfig({
|
||||
HOME: tempDir,
|
||||
CODING_PLAN_API_KEY: "not-a-real-secret-just-presence",
|
||||
});
|
||||
|
||||
assert.equal(config?.providerName, "ccs");
|
||||
assert.equal(config?.displayName, "Coding Plan");
|
||||
assert.equal(config?.baseUrl, "https://example.invalid/v1");
|
||||
assert.equal(config?.model, "glm-5");
|
||||
assert.equal(config?.envKey, "CODING_PLAN_API_KEY");
|
||||
assert.equal(config?.envKeyPresent, true);
|
||||
});
|
||||
|
||||
test("readCodexCustomProviderConfig returns null for the default openai provider", (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-config-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
fs.mkdirSync(path.join(tempDir, ".codex"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, ".codex", "config.toml"),
|
||||
'model_provider = "openai"\n',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.equal(readCodexCustomProviderConfig({ HOME: tempDir }), null);
|
||||
});
|
||||
|
||||
test("readCodexCustomProviderConfig honors CODEX_HOME over ~/.codex", (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-home-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// An unrelated config in the default home must be ignored when CODEX_HOME
|
||||
// points elsewhere.
|
||||
fs.mkdirSync(path.join(tempDir, ".codex"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, ".codex", "config.toml"),
|
||||
'model_provider = "stale"\n\n[model_providers.stale]\nname = "Stale"\n',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const codexHome = path.join(tempDir, "custom-home");
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(codexHome, "config.toml"),
|
||||
[
|
||||
'model_provider = "ccs"',
|
||||
'model = "glm-5"',
|
||||
"",
|
||||
"[model_providers.ccs]",
|
||||
'name = "Coding Plan"',
|
||||
'base_url = "https://example.invalid/v1"',
|
||||
'env_key = "CODING_PLAN_API_KEY"',
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const config = readCodexCustomProviderConfig({
|
||||
HOME: tempDir,
|
||||
CODEX_HOME: codexHome,
|
||||
CODING_PLAN_API_KEY: "not-a-real-secret-just-presence",
|
||||
});
|
||||
|
||||
assert.equal(config?.providerName, "ccs");
|
||||
assert.equal(config?.model, "glm-5");
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
const { buildBashHistoryCleanup, bashHistoryScratchNames } = require("./ptyExecHelpers.cjs");
|
||||
const { buildBashHistoryCleanup, bashHistoryScratchNames, buildInlineHistoryDrop } = require("./ptyExecHelpers.cjs");
|
||||
|
||||
// Both fish and POSIX shells accept this command. Inspect the parent of a
|
||||
// short-lived sh in the interactive PTY, rather than the SSH login shell.
|
||||
@@ -16,7 +16,11 @@ function buildLiveShellProbe(marker) {
|
||||
const fallback = `[ "\${${dispatcher}-}" = command ]||{ ${cleanup}; };${clear}`;
|
||||
// Start display suppression in the PTY before any continuation is read,
|
||||
// independently of PS2 and echo mode. Keep every later physical line short.
|
||||
return ` true ${marker}; printf '\\n%s\\n' '${marker}_I'\n : '${marker}'; command sh -c '${script}' 2>/dev/null; \\\n: '${marker}'; \\command eval '${cleanup}' 2>/dev/null || true; \\\n: '${marker}'; \\eval '${fallback}' 2>/dev/null || true; \\\n: '${marker}'; \\command eval '${clear}' 2>/dev/null || true; printf '%s' '${marker}_Q'\n`;
|
||||
// The standalone first line crosses a prompt boundary, so delete its history
|
||||
// entry inline (buildInlineHistoryDrop) before an immediate-history hook
|
||||
// such as PROMPT_COMMAND='history -a' can persist it — same reason as the
|
||||
// wrapper input marker in ptyExecHelpers.cjs.
|
||||
return ` true ${marker}; printf '\\n%s\\n' '${marker}_I'; ${buildInlineHistoryDrop(marker)}\n : '${marker}'; command sh -c '${script}' 2>/dev/null; \\\n: '${marker}'; \\command eval '${cleanup}' 2>/dev/null || true; \\\n: '${marker}'; \\eval '${fallback}' 2>/dev/null || true; \\\n: '${marker}'; \\command eval '${clear}' 2>/dev/null || true; printf '%s' '${marker}_Q'\n`;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -229,6 +229,25 @@ function buildBashHistoryCleanup(marker, keepDispatcher = false) {
|
||||
].join(` \\\n: "${marker}"; `);
|
||||
}
|
||||
|
||||
// Delete the just-read interactive Bash history entry for a standalone
|
||||
// internal line (the `_I` input marker) before a prompt-boundary
|
||||
// PROMPT_COMMAND='history -a' can persist it. Only an entry whose text
|
||||
// carries this invocation's marker is deleted, so a customization that
|
||||
// suppressed the line (HISTCONTROL=ignorespace) leaves the user's previous
|
||||
// entry untouched — never trust $HISTCMD here. Bash-guarded and
|
||||
// shadowing-safe (verified dispatcher, mirroring buildBashHistoryCleanup);
|
||||
// other POSIX shells leave their entry for the tail cleanup to handle.
|
||||
function buildInlineHistoryDrop(marker) {
|
||||
const { entry, dispatcher } = bashHistoryScratchNames(marker);
|
||||
const inlineEntry = `${entry}_i`;
|
||||
const inlineDispatcher = `${dispatcher}_i`;
|
||||
return [
|
||||
`[ "\${BASH_VERSION-}" ]&&{ for ${inlineDispatcher} in command builtin;do ${inlineEntry}=$($${inlineDispatcher} printf x);[ "$${inlineEntry}" = x ]||continue;`,
|
||||
`${inlineEntry}=$($${inlineDispatcher} history 1);${inlineEntry}=\${${inlineEntry}#"\${${inlineEntry}%%[^[:space:]]*}"};case "$${inlineEntry}" in *'${marker}'*)`,
|
||||
`$${inlineDispatcher} history -d "\${${inlineEntry}%%[[:space:]]*}";;esac;break;done; } 2>/dev/null`,
|
||||
].join(` \\\n: '${marker}'; `);
|
||||
}
|
||||
|
||||
function buildPosixWrapperBody(command, marker, startFormat) {
|
||||
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
|
||||
const commandLines = String(command || "").replace(/\r\n?/g, "\n").split("\n");
|
||||
@@ -264,7 +283,11 @@ function buildPosixWrapperBody(command, marker, startFormat) {
|
||||
// every physical line below that limit, even for short user commands.
|
||||
// Emit a short standalone input marker before the shell reads any PS2
|
||||
// continuation. A leading newline separates it from concurrent PTY echo.
|
||||
return `${marker}=0; printf '\\n%s\\n' '${marker}_I'\n` + [
|
||||
// Because this line ends at a real prompt boundary, an immediate-history
|
||||
// hook (PROMPT_COMMAND='history -a') can persist it before the wrapper's
|
||||
// tail cleanup runs — so delete the entry inline while it is still only in
|
||||
// memory (buildInlineHistoryDrop).
|
||||
return `${marker}=0; printf '\\n%s\\n' '${marker}_I'; ${buildInlineHistoryDrop(marker)}\n` + [
|
||||
` : '${marker}'; ${cmdAssign}`,
|
||||
`{ printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT`,
|
||||
`printf '%s\\n' '${marker}_E:'"$__NCMCP_rc"`,
|
||||
@@ -521,6 +544,7 @@ module.exports = {
|
||||
buildPendingInputClearPrefix,
|
||||
buildWrappedCommand,
|
||||
buildBashHistoryCleanup,
|
||||
buildInlineHistoryDrop,
|
||||
bashHistoryScratchNames,
|
||||
findEndMarker,
|
||||
normalizePtyOutput,
|
||||
|
||||
@@ -29,7 +29,6 @@ const { registerProviderHandlers } = require("./aiBridge/providerHandlers.cjs"),
|
||||
const { createVaultAgentBridge } = require("./aiBridge/vaultAgentBridge.cjs");
|
||||
const { registerAgentDiscoveryHandlers } = require("./aiBridge/agentDiscoveryHandlers.cjs"), { registerAgentProcessHandlers } = require("./aiBridge/agentProcessHandlers.cjs"), { registerSdkStreamHandlers } = require("./aiBridge/sdk/sdkStreamHandlers.cjs");
|
||||
const { probeClaudeAuth, probeCopilotAuth, probeCodexAuth, probeCodebuddyAuth, probeCursorCliAuth, probeGrokAuth } = require("./aiBridge/agentAuthProbes.cjs");
|
||||
const { buildSdkAgentEnv } = require("./aiBridge/sdk/env.cjs");
|
||||
|
||||
// ── Extracted modules ──
|
||||
const {
|
||||
@@ -947,7 +946,6 @@ function createHandlerContext(ipcMain) {
|
||||
killTrackedProcessTree,
|
||||
safeSend,
|
||||
withCliDiscoveryEnv,
|
||||
buildSdkAgentEnv,
|
||||
validateSender,
|
||||
validateSenderOrSettings,
|
||||
envPairsToObject,
|
||||
|
||||
@@ -109,9 +109,6 @@ function loadBridgeWithMocks(options = {}) {
|
||||
"./ai/codexHelpers.cjs": {
|
||||
codexLoginSessions: new Map(),
|
||||
appendCodexLoginOutput() {},
|
||||
createCodexLoginOutputDecoder: () => ({ write() {}, end() {} }),
|
||||
clearCodexLoginKillTimer() {},
|
||||
recordCodexLoginSession: (session) => options.recordCodexLoginSession?.(session),
|
||||
toCodexLoginSessionResponse: (session) => ({ sessionId: session.id, codexPath: session.codexPath }),
|
||||
getActiveCodexLoginSession: () =>
|
||||
typeof options.getActiveCodexLoginSession === "function"
|
||||
@@ -123,10 +120,7 @@ function loadBridgeWithMocks(options = {}) {
|
||||
: realNormalizeCodexIntegrationState(...args),
|
||||
appendCodexChatGptValidationFailure: (rawOutput, validationError) =>
|
||||
`${rawOutput}\n\nChatGPT auth validation failed:\n${validationError}`.trim(),
|
||||
readCodexCustomProviderConfig: (...args) =>
|
||||
typeof options.readCodexCustomProviderConfig === "function"
|
||||
? options.readCodexCustomProviderConfig(...args)
|
||||
: null,
|
||||
readCodexCustomProviderConfig: () => null,
|
||||
getCodexCustomConfigPreflightError: () => null,
|
||||
extractCodexError: (err) => ({ message: err?.message || String(err) }),
|
||||
isCodexAuthError: (...args) =>
|
||||
@@ -977,84 +971,6 @@ test("codex login does not reuse an active session from a different resolved pat
|
||||
}
|
||||
});
|
||||
|
||||
for (const homeVariable of ["HOME", "CODEX_HOME"]) {
|
||||
for (const sameHome of [true, false]) {
|
||||
test(`codex login ${sameHome ? "reuses" : "rejects"} an active session with ${sameHome ? "matching" : "changed"} ${homeVariable}`, async () => {
|
||||
const originalHome = path.join(os.tmpdir(), "codex-login-original");
|
||||
const requestedHome = sameHome ? originalHome : path.join(os.tmpdir(), "codex-login-other");
|
||||
const existingSession = {
|
||||
id: "codex_login_existing",
|
||||
state: "running",
|
||||
process: { killed: false },
|
||||
codexPath: "/fixture/codex",
|
||||
credentialHomeKey: homeVariable === "HOME" ? path.join(originalHome, ".codex") : originalHome,
|
||||
};
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
shellEnv: { HOME: originalHome },
|
||||
resolveCliFromPathAsync: () => existingSession.codexPath,
|
||||
getActiveCodexLoginSession: () => existingSession,
|
||||
prepareCommandForSpawn: () => { throw new Error("An active login must not spawn another process"); },
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
|
||||
bridge.registerHandlers(ipcMain);
|
||||
try {
|
||||
const result = await ipcMain.handlers.get("netcatty:ai:codex:start-login")(
|
||||
{ sender: { id: 1 } },
|
||||
{ agentEnv: { [homeVariable]: requestedHome } },
|
||||
);
|
||||
assert.equal(result.ok, sameHome, JSON.stringify(result));
|
||||
if (sameHome) {
|
||||
assert.equal(result.session.sessionId, existingSession.id);
|
||||
} else {
|
||||
assert.match(result.error, /different credential home/);
|
||||
assert.equal(result.session, undefined);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const action of ["start-login", "logout"]) {
|
||||
test(`codex ${action} child processes use the requested agent home`, async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-agent-home-"));
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }));
|
||||
const codexPath = path.join(tempDir, "codex.cjs");
|
||||
const observedPath = path.join(tempDir, "observed.jsonl");
|
||||
fs.writeFileSync(codexPath, `
|
||||
require('node:fs').appendFileSync(${JSON.stringify(observedPath)}, JSON.stringify({
|
||||
args: process.argv.slice(2), HOME: process.env.HOME, CODEX_HOME: process.env.CODEX_HOME,
|
||||
}) + '\\n');
|
||||
console.log('Not logged in');
|
||||
`);
|
||||
const agentEnv = { HOME: path.join(tempDir, "agent-home"), CODEX_HOME: path.join(tempDir, "agent-codex") };
|
||||
let finishLogin;
|
||||
const loginFinished = new Promise((resolve) => { finishLogin = resolve; });
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
shellEnv: { HOME: path.join(tempDir, "default-home"), CODEX_HOME: path.join(tempDir, "default-codex") },
|
||||
prepareCommandForSpawn: (command, args) => ({ command: process.execPath, args: [command, ...args], shell: false }),
|
||||
recordCodexLoginSession: (session) => {
|
||||
if (session.state !== "running") finishLogin(session.state);
|
||||
},
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
|
||||
bridge.registerHandlers(ipcMain);
|
||||
try {
|
||||
const result = await ipcMain.handlers.get(`netcatty:ai:codex:${action}`)({ sender: { id: 1 } }, { codexPath, agentEnv });
|
||||
assert.equal(result.ok, true, JSON.stringify(result));
|
||||
if (action === "start-login") assert.equal(await loginFinished, "success");
|
||||
const observed = fs.readFileSync(observedPath, "utf8").trim().split("\n").map((line) => JSON.parse(line));
|
||||
assert.deepEqual(observed, (action === "start-login" ? [["login"]] : [["logout"], ["login", "status"]])
|
||||
.map((args) => ({ args, ...agentEnv })));
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("codex integration keeps ChatGPT connected when the SDK validation probe fails", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-integration-"));
|
||||
t.after(() => {
|
||||
@@ -1098,107 +1014,6 @@ test("codex integration keeps ChatGPT connected when the SDK validation probe fa
|
||||
}
|
||||
});
|
||||
|
||||
test("codex integration surfaces config.toml provider even when auth.json reports an API-key login", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-integration-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const codexPath = path.join(tempDir, "codex");
|
||||
fs.writeFileSync(
|
||||
codexPath,
|
||||
`#!${process.execPath}\nconsole.log('Logged in using an API key');\n`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
const customConfig = {
|
||||
providerName: "ccs",
|
||||
displayName: "Coding Plan",
|
||||
baseUrl: "https://example.invalid/v1",
|
||||
envKey: null,
|
||||
envKeyPresent: false,
|
||||
hasHardcodedApiKey: true,
|
||||
model: "glm-5",
|
||||
authHash: "hash",
|
||||
};
|
||||
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
normalizeCliPathForPlatform: (value) => value,
|
||||
shellEnv: { HOME: tempDir },
|
||||
readCodexCustomProviderConfig: () => customConfig,
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const handler = ipcMain.handlers.get("netcatty:ai:codex:get-integration");
|
||||
const result = await handler({ sender: { id: 1 } }, { codexPath });
|
||||
|
||||
assert.equal(result.state, "connected_custom_config", JSON.stringify(result));
|
||||
assert.equal(result.isConnected, true);
|
||||
assert.equal(result.customConfig?.model, "glm-5");
|
||||
assert.equal(result.customConfig?.providerName, "ccs");
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("codex integration keeps a validated ChatGPT login visible but still returns config.toml provider", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-integration-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const codexPath = path.join(tempDir, "codex");
|
||||
fs.writeFileSync(
|
||||
codexPath,
|
||||
`#!${process.execPath}\nconsole.log('Logged in using ChatGPT');\n`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
const customConfig = {
|
||||
providerName: "ccs",
|
||||
displayName: "Coding Plan",
|
||||
baseUrl: null,
|
||||
envKey: null,
|
||||
envKeyPresent: false,
|
||||
hasHardcodedApiKey: true,
|
||||
model: "glm-5",
|
||||
authHash: "hash",
|
||||
};
|
||||
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
normalizeCliPathForPlatform: (value) => value,
|
||||
shellEnv: { HOME: tempDir },
|
||||
readCodexCustomProviderConfig: () => customConfig,
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const handler = ipcMain.handlers.get("netcatty:ai:codex:get-integration");
|
||||
const result = await handler({ sender: { id: 1 } }, { codexPath });
|
||||
|
||||
assert.equal(result.state, "connected_chatgpt", JSON.stringify(result));
|
||||
assert.equal(result.isConnected, true);
|
||||
assert.equal(result.customConfig?.model, "glm-5");
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolve-cli probes Windows cmd paths with spaces", { skip: process.platform !== "win32" }, async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty codex resolve "));
|
||||
t.after(() => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/* eslint-disable no-undef */
|
||||
const { createHash } = require("node:crypto");
|
||||
const { StringDecoder } = require("node:string_decoder");
|
||||
|
||||
const DEFAULT_CODEX_CLI_TIMEOUT_MS = 10_000;
|
||||
@@ -9,7 +8,6 @@ const MAX_AGENT_CLI_BUFFER_CHARS = 10 * 1024 * 1024;
|
||||
function createAgentCliHelpers(ctx) {
|
||||
with (ctx) {
|
||||
const codexAuthValidationInFlight = new Map();
|
||||
let codexValidationKey = null;
|
||||
async function runCommand(command, args, options) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
@@ -145,15 +143,9 @@ function createAgentCliHelpers(ctx) {
|
||||
throw new Error(`Codex CLI path not found: ${requestedPath}`);
|
||||
}
|
||||
const codexCliPath = configuredPath || await resolveCliFromPathAsync("codex", shellEnv) || "codex";
|
||||
// Callers probing on behalf of a specific agent pass `options.env` (the
|
||||
// merged shell + agent env, as built for SDK spawns) so CODEX_HOME/HOME
|
||||
// overrides select the same config the agent turn will use.
|
||||
const env = options?.env && typeof options.env === "object" && Object.keys(options.env).length > 0
|
||||
? options.env
|
||||
: shellEnv;
|
||||
return await runCommand(codexCliPath, args, {
|
||||
cwd: options?.cwd?.trim() || undefined,
|
||||
env,
|
||||
env: shellEnv,
|
||||
timeoutMs: Number.isFinite(options?.timeoutMs)
|
||||
? Number(options.timeoutMs)
|
||||
: DEFAULT_CODEX_CLI_TIMEOUT_MS,
|
||||
@@ -174,21 +166,10 @@ function createAgentCliHelpers(ctx) {
|
||||
}
|
||||
|
||||
async function validateCodexChatGptAuth(options) {
|
||||
const shellEnv = options?.env || await getShellEnv();
|
||||
// Hash sorted entries so equivalent env objects share probes without
|
||||
// retaining raw credentials in cache keys.
|
||||
const envKey = createHash("sha256")
|
||||
.update(JSON.stringify(Object.entries(shellEnv).sort(([a], [b]) => a.localeCompare(b))))
|
||||
.digest("hex");
|
||||
const maxAgeMs = options?.maxAgeMs ?? 30000;
|
||||
const now = Date.now();
|
||||
const rawRequestedCodexPath = String(options?.codexPath || "").trim();
|
||||
const requestedCodexPath = rawRequestedCodexPath ? normalizeCliPathForPlatform?.(rawRequestedCodexPath) : null;
|
||||
const validationKey = JSON.stringify([requestedCodexPath, envKey]);
|
||||
const cacheValidation = (result) => {
|
||||
codexValidationKey = validationKey;
|
||||
setCodexValidationCache(result);
|
||||
};
|
||||
if (rawRequestedCodexPath && !requestedCodexPath) {
|
||||
const result = {
|
||||
ok: false,
|
||||
@@ -197,22 +178,24 @@ function createAgentCliHelpers(ctx) {
|
||||
error: `Codex CLI path not found: ${rawRequestedCodexPath}`,
|
||||
code: "ENOENT",
|
||||
};
|
||||
setCodexValidationCache(result);
|
||||
return result;
|
||||
}
|
||||
const cached = getCodexValidationCache();
|
||||
if (codexValidationKey === validationKey && cached && now - cached.checkedAt < maxAgeMs) return cached;
|
||||
const inFlightKey = validationKey;
|
||||
if (cached && now - cached.checkedAt < maxAgeMs && (cached.codexPath || null) === requestedCodexPath) return cached;
|
||||
const inFlightKey = requestedCodexPath || "__auto__";
|
||||
const existingValidation = codexAuthValidationInFlight.get(inFlightKey);
|
||||
if (existingValidation) return existingValidation;
|
||||
|
||||
const validationPromise = (async () => {
|
||||
const shellEnv = await getShellEnv();
|
||||
const rawCodexPath = requestedCodexPath || await resolveSdkBinPathAsync("codex", shellEnv);
|
||||
const codexPath = rawCodexPath && typeof resolveCodexExecutableForSdk === "function"
|
||||
? resolveCodexExecutableForSdk(rawCodexPath) || null
|
||||
: rawCodexPath;
|
||||
if (!codexPath) {
|
||||
const result = { ok: false, checkedAt: now, codexPath: requestedCodexPath, error: "codex binary not found", code: "ENOENT" };
|
||||
cacheValidation(result);
|
||||
setCodexValidationCache(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -261,12 +244,12 @@ function createAgentCliHelpers(ctx) {
|
||||
|
||||
await Promise.race([probePromise, timeoutPromise]);
|
||||
const result = { ok: true, checkedAt: now, codexPath, error: null };
|
||||
cacheValidation(result);
|
||||
setCodexValidationCache(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const normalized = extractCodexError(error);
|
||||
const result = { ok: false, checkedAt: now, codexPath, error: normalized.message, code: normalized.code };
|
||||
cacheValidation(result);
|
||||
setCodexValidationCache(result);
|
||||
return result;
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
@@ -125,7 +125,7 @@ test("runCodexCli omits an incomplete UTF-8 suffix at its byte limit", async ()
|
||||
assert.doesNotMatch(result.stdout, /�/u);
|
||||
});
|
||||
|
||||
function createValidationHelpers({ loadCodexSdk, setTimeout, clearTimeout, ...overrides }) {
|
||||
function createValidationHelpers({ loadCodexSdk, setTimeout, clearTimeout }) {
|
||||
return createAgentCliHelpers({
|
||||
getCodexValidationCache: () => null,
|
||||
setCodexValidationCache() {},
|
||||
@@ -138,147 +138,6 @@ function createValidationHelpers({ loadCodexSdk, setTimeout, clearTimeout, ...ov
|
||||
loadCodexSdk,
|
||||
...(setTimeout ? { setTimeout } : {}),
|
||||
...(clearTimeout ? { clearTimeout } : {}),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test("agent auth validation does not reuse results from a different environment", async () => {
|
||||
const defaultEnv = { HOME: "/default-home" };
|
||||
const agentEnv = { HOME: "/agent-home", CODEX_HOME: "/agent-codex", CUSTOM_KEY: "test-value" };
|
||||
const defaultFailure = { ok: false, checkedAt: Date.now(), codexPath: "/fake/codex", error: "default login expired" };
|
||||
let cached = defaultFailure;
|
||||
const receivedEnvs = [];
|
||||
const helpers = createValidationHelpers({
|
||||
getShellEnv: async () => defaultEnv,
|
||||
getCodexValidationCache: () => cached,
|
||||
setCodexValidationCache: (result) => { cached = result; },
|
||||
loadCodexSdk: async () => ({
|
||||
Codex: class {
|
||||
constructor(options) { receivedEnvs.push(options.env); }
|
||||
startThread() {
|
||||
return { async runStreamed() {
|
||||
return { events: (async function* () { yield { type: "turn.completed" }; })() };
|
||||
} };
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const agentResult = await helpers.validateCodexChatGptAuth({ codexPath: "/fake/codex", env: agentEnv });
|
||||
assert.equal(agentResult.ok, true);
|
||||
assert.deepEqual(receivedEnvs, [agentEnv]);
|
||||
assert.equal(cached, agentResult);
|
||||
const defaultResult = await helpers.validateCodexChatGptAuth({ codexPath: "/fake/codex" });
|
||||
assert.equal(defaultResult.ok, true);
|
||||
assert.deepEqual(receivedEnvs, [agentEnv, defaultEnv]);
|
||||
assert.equal(cached, defaultResult);
|
||||
});
|
||||
|
||||
test("agent auth failure cannot become the default environment's cached result", async () => {
|
||||
let cached = null;
|
||||
const receivedHomes = [];
|
||||
const helpers = createValidationHelpers({
|
||||
getShellEnv: async () => ({ HOME: "/default-home" }),
|
||||
getCodexValidationCache: () => cached,
|
||||
setCodexValidationCache: (result) => { cached = result; },
|
||||
loadCodexSdk: async () => ({
|
||||
Codex: class {
|
||||
constructor(options) { this.home = options.env.HOME; receivedHomes.push(this.home); }
|
||||
startThread() {
|
||||
const home = this.home;
|
||||
return { async runStreamed() {
|
||||
if (home === "/agent-home") throw new Error("agent login expired");
|
||||
return { events: (async function* () { yield { type: "turn.completed" }; })() };
|
||||
} };
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const agentResult = await helpers.validateCodexChatGptAuth({ codexPath: "/fake/codex", env: { HOME: "/agent-home" } });
|
||||
assert.equal(agentResult.ok, false);
|
||||
assert.equal(cached, agentResult);
|
||||
const defaultResult = await helpers.validateCodexChatGptAuth({ codexPath: "/fake/codex" });
|
||||
assert.equal(defaultResult.ok, true);
|
||||
assert.deepEqual(receivedHomes, ["/agent-home", "/default-home"]);
|
||||
assert.equal(cached, defaultResult);
|
||||
});
|
||||
|
||||
test("an invalid explicit CLI path does not poison automatic discovery auth validation", async () => {
|
||||
let cached = null;
|
||||
let probeCount = 0;
|
||||
const helpers = createValidationHelpers({
|
||||
getShellEnv: async () => ({ HOME: "/agent-home" }),
|
||||
normalizeCliPathForPlatform: () => null,
|
||||
getCodexValidationCache: () => cached,
|
||||
setCodexValidationCache: (result) => { cached = result; },
|
||||
loadCodexSdk: async () => ({
|
||||
Codex: class {
|
||||
startThread() {
|
||||
probeCount += 1;
|
||||
return { async runStreamed() {
|
||||
return { events: (async function* () { yield { type: "turn.completed" }; })() };
|
||||
} };
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
const invalid = await helpers.validateCodexChatGptAuth({ codexPath: "/missing/codex" });
|
||||
assert.equal(invalid.ok, false);
|
||||
assert.equal(invalid.code, "ENOENT");
|
||||
assert.equal(cached, null);
|
||||
assert.equal(probeCount, 0);
|
||||
const discovered = await helpers.validateCodexChatGptAuth({});
|
||||
assert.equal(discovered.ok, true);
|
||||
assert.equal(discovered.codexPath, "/fake/codex");
|
||||
assert.equal(probeCount, 1);
|
||||
assert.equal(await helpers.validateCodexChatGptAuth({}), discovered);
|
||||
assert.equal(probeCount, 1);
|
||||
});
|
||||
|
||||
for (const codexPath of ["/fake/codex", undefined]) {
|
||||
test(`equivalent auth environments coalesce and cache with ${codexPath ? "explicit" : "discovered"} CLI path; credential rotation probes again`, async () => {
|
||||
const defaultEnv = { HOME: "/agent-home", CUSTOM_KEY: "old-test-key" };
|
||||
let cached = null;
|
||||
let runCount = 0;
|
||||
let release;
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
const helpers = createValidationHelpers({
|
||||
getShellEnv: async () => ({ ...defaultEnv }),
|
||||
getCodexValidationCache: () => cached,
|
||||
setCodexValidationCache: (result) => { cached = result; },
|
||||
loadCodexSdk: async () => ({
|
||||
Codex: class {
|
||||
startThread() {
|
||||
return { async runStreamed() {
|
||||
runCount += 1;
|
||||
return { events: (async function* () {
|
||||
await gate;
|
||||
yield { type: "turn.completed" };
|
||||
})() };
|
||||
} };
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
const options = { codexPath };
|
||||
const first = helpers.validateCodexChatGptAuth({ ...options, env: defaultEnv });
|
||||
const second = helpers.validateCodexChatGptAuth({ ...options, env: { CUSTOM_KEY: "old-test-key", HOME: "/agent-home" } });
|
||||
const third = helpers.validateCodexChatGptAuth(options);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(runCount, 1);
|
||||
release();
|
||||
const results = await Promise.all([first, second, third]);
|
||||
assert.equal(results[0].ok, true);
|
||||
assert.equal(results[0], results[1]);
|
||||
assert.equal(results[0], results[2]);
|
||||
assert.equal(await helpers.validateCodexChatGptAuth(options), results[0]);
|
||||
assert.equal(await helpers.validateCodexChatGptAuth({ ...options, env: { CUSTOM_KEY: "old-test-key", HOME: "/agent-home" } }), results[0]);
|
||||
assert.equal(runCount, 1);
|
||||
const rotated = await helpers.validateCodexChatGptAuth({ ...options, env: { ...defaultEnv, CUSTOM_KEY: "new-test-key" } });
|
||||
assert.equal(rotated.ok, true);
|
||||
assert.notEqual(rotated, results[0]);
|
||||
assert.equal(runCount, 2);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -78,14 +78,6 @@ async function probeCursorSdkAvailability(shellEnv, options = {}) {
|
||||
|
||||
function registerAgentDiscoveryHandlers(ctx) {
|
||||
with (ctx) {
|
||||
async function getCodexAgentEnv(options) {
|
||||
return buildSdkAgentEnv({
|
||||
shellEnv: await getShellEnv(),
|
||||
requestedAgentEnv: normalizeAgentEnv(options?.agentEnv),
|
||||
withCliDiscoveryEnv,
|
||||
});
|
||||
}
|
||||
|
||||
ipcMain.handle("netcatty:ai:agents:discover", async (event, options = {}) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
if (options?.refreshShellEnv) {
|
||||
@@ -273,9 +265,7 @@ function registerAgentDiscoveryHandlers(ctx) {
|
||||
invalidateShellEnvCache();
|
||||
}
|
||||
try {
|
||||
// Probe the same config and credentials as the managed agent's turns.
|
||||
const effectiveEnv = await getCodexAgentEnv(options);
|
||||
const codexCliOptions = { codexPath: options?.codexPath, env: effectiveEnv };
|
||||
const codexCliOptions = { codexPath: options?.codexPath };
|
||||
const result = await runCodexCli(["login", "status"], codexCliOptions);
|
||||
const rawOutput = [result.stdout, result.stderr]
|
||||
.filter((chunk) => chunk.trim().length > 0)
|
||||
@@ -285,7 +275,7 @@ function registerAgentDiscoveryHandlers(ctx) {
|
||||
let effectiveRawOutput = rawOutput;
|
||||
|
||||
if (state === "connected_chatgpt" && options?.validateChatGptAuth === true) {
|
||||
const validation = await validateCodexChatGptAuth({ maxAgeMs: 10000, codexPath: options?.codexPath, env: effectiveEnv });
|
||||
const validation = await validateCodexChatGptAuth({ maxAgeMs: 10000, codexPath: options?.codexPath });
|
||||
if (!validation.ok) {
|
||||
if (isCodexAuthError(validation)) {
|
||||
try {
|
||||
@@ -309,21 +299,17 @@ function registerAgentDiscoveryHandlers(ctx) {
|
||||
// functional from the CLI but would look "not_logged_in" here. Probe
|
||||
// config.toml so we can surface that as a valid ready state instead of
|
||||
// pushing the user into the ChatGPT login flow.
|
||||
//
|
||||
// Probe even when auth.json reports a login: provider switcher tools
|
||||
// (cc-switch, ccs) write an API key into auth.json while config.toml's
|
||||
// `model_provider` actually selects a third-party provider, and
|
||||
// config.toml is what Codex uses. Keep a validated ChatGPT login as the
|
||||
// displayed state, but still return customConfig so the chat model
|
||||
// picker can surface the configured third-party model.
|
||||
let customConfig = null;
|
||||
try {
|
||||
customConfig = readCodexCustomProviderConfig(effectiveEnv);
|
||||
if (customConfig && state !== "connected_chatgpt") {
|
||||
state = "connected_custom_config";
|
||||
if (state !== "connected_chatgpt" && state !== "connected_api_key") {
|
||||
try {
|
||||
const shellEnv = await getShellEnv();
|
||||
customConfig = readCodexCustomProviderConfig(shellEnv);
|
||||
if (customConfig) {
|
||||
state = "connected_custom_config";
|
||||
}
|
||||
} catch {
|
||||
customConfig = null;
|
||||
}
|
||||
} catch {
|
||||
customConfig = null;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -356,21 +342,16 @@ function registerAgentDiscoveryHandlers(ctx) {
|
||||
}
|
||||
|
||||
try {
|
||||
const shellEnv = await getCodexAgentEnv(options);
|
||||
const shellEnv = await getShellEnv();
|
||||
const codexCliPath = requestedCodexPath
|
||||
|| await resolveCliFromPathAsync("codex", shellEnv)
|
||||
|| "codex";
|
||||
const credentialHomeKey = shellEnv.CODEX_HOME?.trim()
|
||||
|| require("node:path").join(shellEnv.HOME || shellEnv.USERPROFILE || require("node:os").homedir(), ".codex");
|
||||
const existingSession = getActiveCodexLoginSession();
|
||||
if (existingSession) {
|
||||
const existingPath = existingSession.codexPath || null;
|
||||
if (existingPath && codexCliPath !== existingPath) {
|
||||
return { ok: false, error: "A Codex login is already running for a different CLI path." };
|
||||
}
|
||||
if (existingSession.credentialHomeKey !== credentialHomeKey) {
|
||||
return { ok: false, error: "A Codex login is already running for a different credential home." };
|
||||
}
|
||||
return { ok: true, session: toCodexLoginSessionResponse(existingSession) };
|
||||
}
|
||||
|
||||
@@ -392,7 +373,6 @@ function registerAgentDiscoveryHandlers(ctx) {
|
||||
error: null,
|
||||
exitCode: null,
|
||||
codexPath: codexCliPath,
|
||||
credentialHomeKey,
|
||||
};
|
||||
|
||||
const stdoutDecoder = createCodexLoginOutputDecoder(session);
|
||||
@@ -474,7 +454,7 @@ function registerAgentDiscoveryHandlers(ctx) {
|
||||
ipcMain.handle("netcatty:ai:codex:logout", async (event, options = {}) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
try {
|
||||
const codexCliOptions = { codexPath: options?.codexPath, env: await getCodexAgentEnv(options) };
|
||||
const codexCliOptions = { codexPath: options?.codexPath };
|
||||
const logoutResult = await runCodexCli(["logout"], codexCliOptions);
|
||||
invalidateCodexValidationCache();
|
||||
const statusResult = await runCodexCli(["login", "status"], codexCliOptions);
|
||||
|
||||
@@ -1271,21 +1271,6 @@ function registerHandlers(ipcMain) {
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:trayPanel:startPortForward", async (_event, ruleId) => {
|
||||
const win = await openMainWindowReady();
|
||||
if (!win) return { success: false, error: "Main window is not available" };
|
||||
if (isAppRuntimeLocked()) {
|
||||
sendOrQueuePortForwardToggle(win, ruleId, true);
|
||||
return { success: true };
|
||||
}
|
||||
const delivered = await sendToMainWindow("netcatty:trayPanel:startPortForward", ruleId, {
|
||||
focus: false,
|
||||
});
|
||||
return delivered
|
||||
? { success: true }
|
||||
: { success: false, error: "Main window is not ready" };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:trayPanel:closeSession", async (_event, sessionId) => {
|
||||
const delivered = await sendToMainWindow("netcatty:trayPanel:closeSession", sessionId, {
|
||||
focus: false,
|
||||
|
||||
@@ -1770,55 +1770,3 @@ test("setCloseToTray(false) still destroys an unpinned tray as before", async ()
|
||||
bridge.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("tray panel forwarding start reaches the main renderer coordinator", async () => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const bridge = loadBridge();
|
||||
const electronModule = createElectronStub();
|
||||
const sentMessages = [];
|
||||
const mainWin = new FakeWindow();
|
||||
mainWin.webContents = { send: (...args) => sentMessages.push(args) };
|
||||
electronModule.BrowserWindow.getAllWindows = () => [mainWin];
|
||||
bridge.init({ electronModule, getMainWindow: () => mainWin });
|
||||
const ipcMain = createIpcMainStub();
|
||||
bridge.registerHandlers(ipcMain);
|
||||
const result = await ipcMain.handlers.get("netcatty:trayPanel:startPortForward")(null, "reconnect-rule");
|
||||
assert.deepEqual(result, { success: true });
|
||||
assert.ok(sentMessages.some((args) =>
|
||||
args[0] === "netcatty:trayPanel:startPortForward" && args[1] === "reconnect-rule"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test("tray panel forwarding waits for a newly created main renderer", async () => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const bridge = loadBridge();
|
||||
const electronModule = createElectronStub();
|
||||
const mainWin = new FakeWindow();
|
||||
let main = null;
|
||||
let releaseReady;
|
||||
let delivered = false;
|
||||
const ready = new Promise((resolve) => { releaseReady = resolve; });
|
||||
electronModule.BrowserWindow.getAllWindows = () => main ? [main] : [];
|
||||
bridge.init({
|
||||
electronModule,
|
||||
getMainWindow: () => main,
|
||||
ensureMainWindow: async () => { main = mainWin; return mainWin; },
|
||||
sendWhenRendererReady: async (win, channel, ruleId) => {
|
||||
assert.equal(win, mainWin);
|
||||
assert.equal(channel, "netcatty:trayPanel:startPortForward");
|
||||
assert.equal(ruleId, "new-main-rule");
|
||||
await ready;
|
||||
delivered = true;
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
bridge.registerHandlers(ipcMain);
|
||||
const pending = ipcMain.handlers.get("netcatty:trayPanel:startPortForward")(null, "new-main-rule");
|
||||
assert.equal(delivered, false);
|
||||
releaseReady();
|
||||
assert.deepEqual(await pending, { success: true });
|
||||
assert.equal(delivered, true);
|
||||
});
|
||||
});
|
||||
@@ -55,7 +55,6 @@ let sftpClients = null;
|
||||
let electronModule = null;
|
||||
let sessions = null;
|
||||
let reportOpenedSessionActivity = null;
|
||||
let reportSuppressedError = null;
|
||||
const rendererSftpSourceSessions = new Map();
|
||||
const REMOTE_DELETE_EXEC_OPEN_TIMEOUT_MS = 15_000;
|
||||
const REMOTE_DELETE_EXEC_RUN_TIMEOUT_MS = 10 * 60_000;
|
||||
@@ -735,9 +734,6 @@ function init(deps) {
|
||||
reportOpenedSessionActivity = typeof deps.reportOpenedSessionActivity === "function"
|
||||
? deps.reportOpenedSessionActivity
|
||||
: null;
|
||||
reportSuppressedError = typeof deps.reportSuppressedError === "function"
|
||||
? deps.reportSuppressedError
|
||||
: null;
|
||||
rendererSftpSourceSessions.clear();
|
||||
}
|
||||
|
||||
@@ -2019,14 +2015,10 @@ async function openSftpForSession(_event, payload) {
|
||||
}
|
||||
|
||||
try {
|
||||
// This is a fresh client: retain the initial channel-open error instead
|
||||
// of passing through recovery, which replaces it with a generic error.
|
||||
const channel = await tryOpenSftpChannel(client, {
|
||||
await requireSftpChannel(client, {
|
||||
signal: payload?.abortSignal,
|
||||
timeoutMs: payload?.timeoutMs,
|
||||
});
|
||||
if (!hasSftpChannelApi(channel)) throw new Error("SFTP channel unavailable");
|
||||
client.sftp = channel;
|
||||
client.__netcattyFileProtocol = "sftp";
|
||||
} catch (sftpErr) {
|
||||
if (fileProtocol === "sftp") throw sftpErr;
|
||||
@@ -2035,23 +2027,6 @@ async function openSftpForSession(_event, payload) {
|
||||
`[SFTP] openSftpForSession SFTP channel failed for ${sessionId}; falling back to SCP mode:`,
|
||||
sftpErr?.message || String(sftpErr),
|
||||
);
|
||||
// The terminal worker has no Electron app: forward to main's logger.
|
||||
try {
|
||||
const source = "sftpBridge.openSftpForSession";
|
||||
const message = `SFTP channel failed for ${sourceSessionId}; falling back to SCP mode (${fileProtocol})`;
|
||||
if (reportSuppressedError) {
|
||||
reportSuppressedError(source, sftpErr, message);
|
||||
} else {
|
||||
require("./crashLogBridge.cjs").captureDiagnostic(source, message, {
|
||||
sessionId: sourceSessionId,
|
||||
fileProtocol,
|
||||
reason: sftpErr?.message || String(sftpErr),
|
||||
reasonCode: sftpErr?.code ?? sftpErr?.level ?? undefined,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Diagnostic failures must not prevent the fallback.
|
||||
}
|
||||
client.__netcattyFileProtocol = "scp";
|
||||
client.sftp = null;
|
||||
try {
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const test = require("node:test");
|
||||
const bridge = require("./sftpBridge.cjs");
|
||||
|
||||
for (const loggerThrows of [false, true]) {
|
||||
test(`Auto fallback reports the channel failure; loggerThrows=${loggerThrows}`, async () => {
|
||||
const failure = Object.assign(new Error("subsystem request rejected"), { code: "CHANNEL_OPEN_FAILURE" });
|
||||
const reports = [];
|
||||
const conn = {
|
||||
sftp(callback) { callback(failure); },
|
||||
exec(command, callback) {
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
stream.close = () => {};
|
||||
callback(null, stream);
|
||||
queueMicrotask(() => {
|
||||
if (!command.includes("command -v scp")) {
|
||||
stream.emit("data", Buffer.from("RAW:/home/test\n"));
|
||||
}
|
||||
stream.emit("close", 0);
|
||||
});
|
||||
},
|
||||
};
|
||||
bridge.init({
|
||||
electronModule: { webContents: { fromId: () => null } },
|
||||
sessions: new Map([["fallback-session", { conn }]]),
|
||||
sftpClients: new Map(),
|
||||
reportSuppressedError(...args) {
|
||||
reports.push(args);
|
||||
if (loggerThrows) throw new Error("logger unavailable");
|
||||
},
|
||||
});
|
||||
const opened = await bridge.openSftpForSession(null, { sessionId: "fallback-session" });
|
||||
try {
|
||||
assert.equal(opened.fileProtocol, "scp");
|
||||
assert.equal(reports.length, 1);
|
||||
assert.equal(reports[0][0], "sftpBridge.openSftpForSession");
|
||||
assert.equal(reports[0][1], failure);
|
||||
assert.match(reports[0][2], /fallback-session.*SCP mode \(auto\)/);
|
||||
} finally {
|
||||
await bridge.closeSftp(null, { sftpId: opened.sftpId });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -190,16 +190,8 @@ function createScpBackend(deps = {}) {
|
||||
async function stat(remotePath, options = {}) {
|
||||
const signal = options.signal || null;
|
||||
const encoding = options.encoding || "utf-8";
|
||||
// allowNonZero: parseStatRecord needs the exit code + stderr to tell a real
|
||||
// ENOENT (exit 2) apart from an empty exec-channel response.
|
||||
const result = await runOrThrow(buildStatCommand(remotePath, encoding), {
|
||||
allowNonZero: true,
|
||||
signal,
|
||||
});
|
||||
return parseStatRecord(result.stdout, {
|
||||
stderr: result.stderr,
|
||||
exitCode: result.code,
|
||||
});
|
||||
const result = await runOrThrow(buildStatCommand(remotePath, encoding), { signal });
|
||||
return parseStatRecord(result.stdout);
|
||||
}
|
||||
|
||||
async function mkdir(remotePath, options = {}) {
|
||||
|
||||
@@ -175,137 +175,6 @@ describe("scpBackend browse/manage with fake exec", () => {
|
||||
assert.match(statCommand, /\[ ! -L "\$p" \]/, "broken symlinks must not be reported as missing");
|
||||
});
|
||||
|
||||
it("reports ENOENT when the stat command exits 2 with an ENOENT marker", async () => {
|
||||
const missingBackend = createScpBackend({
|
||||
exec: async () => ({ stdout: "", stderr: "ENOENT\n", code: 2 }),
|
||||
execStream: async () => createMockStream(),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => missingBackend.stat("/home/test/gone.txt"),
|
||||
(err) => err.code === "ENOENT" && err.message === "No such file",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports ENOENT when a shell banner precedes the marker on stderr", async () => {
|
||||
// Codex: non-interactive shells may print warnings before the command, so
|
||||
// the ENOENT marker can appear on a later stderr line.
|
||||
const bannerBackend = createScpBackend({
|
||||
exec: async () => ({ stdout: "", stderr: "warning: env cleared\nENOENT\n", code: 2 }),
|
||||
execStream: async () => createMockStream(),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => bannerBackend.stat("/home/test/gone.txt"),
|
||||
(err) => err.code === "ENOENT" && err.message === "No such file",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports ENOENT when stdout contains a login banner", async () => {
|
||||
const bannerBackend = createScpBackend({
|
||||
exec: async () => ({ stdout: "Welcome\n", stderr: "ENOENT\n", code: 2 }),
|
||||
execStream: async () => createMockStream(),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => bannerBackend.stat("/home/test/gone.txt"),
|
||||
(err) => err.code === "ENOENT",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not mask an empty exec response as ENOENT in stat", async () => {
|
||||
const emptyBackend = createScpBackend({
|
||||
// Simulates the reporter's case: the exec channel returns no output at
|
||||
// all (e.g. channel negotiation failure) — not a missing file.
|
||||
exec: async () => ({ stdout: "", stderr: "", code: 0 }),
|
||||
execStream: async () => createMockStream(),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => emptyBackend.stat("/home/test/readme.txt"),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EMPTY_RESPONSE");
|
||||
assert.match(err.message, /empty response/);
|
||||
assert.ok(!(err instanceof Error && err.message === "No such file"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces stderr and exit code when the stat command fails without output", async () => {
|
||||
const failingBackend = createScpBackend({
|
||||
exec: async () => ({ stdout: "", stderr: "sh: syntax error\n", code: 1 }),
|
||||
execStream: async () => createMockStream(),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => failingBackend.stat("/home/test/readme.txt"),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EMPTY_RESPONSE");
|
||||
assert.match(err.message, /exit 1/);
|
||||
assert.match(err.message, /syntax error/);
|
||||
assert.equal(err.exitCode, 1);
|
||||
assert.equal(err.stderr, "sh: syntax error");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat exit 2 without an ENOENT marker as a missing file", async () => {
|
||||
// Codex: a remote shell failure (e.g. syntax/usage error) can also exit 2;
|
||||
// without the ENOENT marker it must surface the real stderr, not ENOENT.
|
||||
const shellFailBackend = createScpBackend({
|
||||
exec: async () => ({ stdout: "", stderr: "sh: syntax error\n", code: 2 }),
|
||||
execStream: async () => createMockStream(),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => shellFailBackend.stat("/home/test/readme.txt"),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EMPTY_RESPONSE");
|
||||
assert.match(err.message, /exit 2/);
|
||||
assert.match(err.message, /syntax error/);
|
||||
assert.equal(err.exitCode, 2);
|
||||
assert.equal(err.stderr, "sh: syntax error");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat a stdout ENOENT marker with a nonzero, non-2 exit as missing", async () => {
|
||||
// Codex: a forced-command wrapper can print "ENOENT" on stdout before
|
||||
// failing with another status; it must not be reported as ENOENT.
|
||||
const wrapperBackend = createScpBackend({
|
||||
exec: async () => ({ stdout: "ENOENT\n", stderr: "denied\n", code: 126 }),
|
||||
execStream: async () => createMockStream(),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => wrapperBackend.stat("/home/test/readme.txt"),
|
||||
(err) => {
|
||||
assert.notEqual(err.code, "ENOENT");
|
||||
assert.equal(err.exitCode, 126);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects stat metadata forwarded by a wrapper that exits nonzero", async () => {
|
||||
// Codex: a forced-command wrapper can forward the stat record but exit
|
||||
// nonzero; the metadata is untrustworthy and must not be returned.
|
||||
const wrapperBackend = createScpBackend({
|
||||
exec: async () => ({
|
||||
stdout: "f|-rw-r--r--|5|1700000000|/home/test/readme.txt|12345\n",
|
||||
stderr: "forced command failed\n",
|
||||
code: 1,
|
||||
}),
|
||||
execStream: async () => createMockStream(),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => wrapperBackend.stat("/home/test/readme.txt"),
|
||||
(err) => {
|
||||
assert.match(err.message, /exited 1/);
|
||||
assert.match(err.message, /forced command failed/);
|
||||
assert.equal(err.exitCode, 1);
|
||||
assert.equal(err.stderr, "forced command failed");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves home directory", async () => {
|
||||
const home = await backend.homeDir();
|
||||
assert.equal(home, "/home/test");
|
||||
|
||||
@@ -327,57 +327,16 @@ function parseLsLaOutput(stdout, { basePath = "" } = {}) {
|
||||
return results;
|
||||
}
|
||||
|
||||
function parseStatRecord(stdout, { stderr = "", exitCode = null } = {}) {
|
||||
function parseStatRecord(stdout) {
|
||||
const line = String(stdout || "").trim().split(/\r?\n/)[0] || "";
|
||||
const stderrText = String(stderr || "").trim();
|
||||
// A missing path emits an ENOENT marker and exits 2. Shell startup
|
||||
// messages may precede it on either stream; exit status alone is not enough.
|
||||
const hasMissingMarker = line === "ENOENT"
|
||||
|| stderrText.split(/\r?\n/).some((entry) => entry.trim() === "ENOENT");
|
||||
if (hasMissingMarker && (exitCode == null || exitCode === 2)) {
|
||||
throw new ScpShellError("No such file", "ENOENT");
|
||||
}
|
||||
if (!line || line === "ENOENT") {
|
||||
const detail = [
|
||||
"exec channel returned empty response",
|
||||
exitCode != null ? `exit ${exitCode}` : null,
|
||||
stderrText ? `stderr: ${stderrText.slice(0, 200)}` : null,
|
||||
].filter(Boolean).join("; ");
|
||||
const err = new ScpShellError(detail, "EMPTY_RESPONSE");
|
||||
err.code = "EMPTY_RESPONSE";
|
||||
err.exitCode = exitCode;
|
||||
err.stderr = stderrText;
|
||||
throw err;
|
||||
}
|
||||
// Any other nonzero status is a remote execution failure — even when stdout
|
||||
// looks like a valid record (e.g. a forced-command wrapper that forwards the
|
||||
// stat record before failing). Such metadata is untrustworthy, so reject it
|
||||
// instead of letting the caller proceed on it.
|
||||
if (exitCode != null && exitCode !== 0) {
|
||||
const detail = [
|
||||
`stat command exited ${exitCode}`,
|
||||
stderrText ? `stderr: ${stderrText.slice(0, 200)}` : null,
|
||||
].filter(Boolean).join("; ");
|
||||
const err = new ScpShellError(detail);
|
||||
err.exitCode = exitCode;
|
||||
err.stderr = stderrText;
|
||||
const err = new ScpShellError("No such file", "ENOENT");
|
||||
err.code = "ENOENT";
|
||||
throw err;
|
||||
}
|
||||
const parts = line.split("|");
|
||||
if (parts.length < 5) {
|
||||
// Non-empty stdout that isn't a stat record: the remote command may have
|
||||
// failed (nonzero exit, e.g. a restricted shell printing a banner) while
|
||||
// still writing to stdout. Preserve exit code and stderr so failures with
|
||||
// output aren't reduced to a bare "malformed" message.
|
||||
const detail = [
|
||||
`Malformed stat record: ${line.slice(0, 80)}`,
|
||||
exitCode != null ? `exit ${exitCode}` : null,
|
||||
stderrText ? `stderr: ${stderrText.slice(0, 200)}` : null,
|
||||
].filter(Boolean).join("; ");
|
||||
const err = new ScpShellError(detail);
|
||||
err.exitCode = exitCode;
|
||||
err.stderr = stderrText;
|
||||
throw err;
|
||||
throw new ScpShellError(`Malformed stat record: ${line.slice(0, 80)}`);
|
||||
}
|
||||
const [t, modeStr, sizeStr, mtimeStr, abs, inoStr] = parts;
|
||||
const ino = inoStr && /^\d+$/.test(String(inoStr).trim())
|
||||
|
||||
@@ -327,7 +327,6 @@ const TOOL_INPUT_FIELDS = Object.freeze({
|
||||
remotePort: { type: "number", optional: true, description: "Required except for dynamic forwarding." },
|
||||
hostId: { type: "string", description: "Vault host ID used for the tunnel." },
|
||||
autoStart: { type: "string", optional: true, description: "true or false." },
|
||||
autoReconnect: { type: "string", optional: true, description: "true or false. Reconnect automatically after unexpected disconnects." },
|
||||
},
|
||||
"portforward.rules.update": {
|
||||
ruleId: { type: "string", description: "Port forwarding rule ID." },
|
||||
@@ -339,7 +338,6 @@ const TOOL_INPUT_FIELDS = Object.freeze({
|
||||
remotePort: { type: "number", optional: true, description: "Remote port." },
|
||||
hostId: { type: "string", optional: true, description: "Vault host ID used for the tunnel." },
|
||||
autoStart: { type: "string", optional: true, description: "true or false." },
|
||||
autoReconnect: { type: "string", optional: true, description: "true or false. Reconnect automatically after unexpected disconnects." },
|
||||
},
|
||||
"portforward.rules.duplicate": {
|
||||
ruleId: { type: "string", description: "Port forwarding rule ID to copy." },
|
||||
|
||||
@@ -1571,13 +1571,8 @@ function createPreloadApi(ctx) {
|
||||
},
|
||||
onTrayTogglePortForward: (callback) => {
|
||||
const handler = (_event, ruleId, start) => callback(ruleId, start);
|
||||
const startHandler = (_event, ruleId) => callback(ruleId, true);
|
||||
ipcRenderer.on("netcatty:tray:togglePortForward", handler);
|
||||
ipcRenderer.on("netcatty:trayPanel:startPortForward", startHandler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener("netcatty:tray:togglePortForward", handler);
|
||||
ipcRenderer.removeListener("netcatty:trayPanel:startPortForward", startHandler);
|
||||
};
|
||||
return () => ipcRenderer.removeListener("netcatty:tray:togglePortForward", handler);
|
||||
},
|
||||
|
||||
// Tray panel actions forwarded to main window
|
||||
@@ -1605,8 +1600,6 @@ function createPreloadApi(ctx) {
|
||||
ipcRenderer.invoke("netcatty:trayPanel:jumpToSession", sessionId),
|
||||
connectToHostFromTrayPanel: (hostId) =>
|
||||
ipcRenderer.invoke("netcatty:trayPanel:connectToHost", hostId),
|
||||
startPortForwardFromTrayPanel: (ruleId) =>
|
||||
ipcRenderer.invoke("netcatty:trayPanel:startPortForward", ruleId),
|
||||
closeSessionFromTrayPanel: (sessionId) =>
|
||||
ipcRenderer.invoke("netcatty:trayPanel:closeSession", sessionId),
|
||||
onTrayPanelCloseRequest: (callback) => {
|
||||
|
||||
@@ -301,7 +301,6 @@ function main() {
|
||||
selectZmodemUploadFiles,
|
||||
selectZmodemDownloadDirectory,
|
||||
terminalDataPipeline,
|
||||
reportSuppressedError: reportWorkerError,
|
||||
};
|
||||
|
||||
runtime = createTerminalWorkerRuntime({
|
||||
|
||||
@@ -2130,8 +2130,3 @@ html[data-theme-transition] *::after {
|
||||
--terminal-host-tree-drop-bg: var(--nc-term-host-tree-drop-bg, transparent);
|
||||
--terminal-host-tree-folder-fg: var(--nc-term-host-tree-folder-fg, var(--foreground));
|
||||
}
|
||||
|
||||
/* Bottom tabs meet the content above, retaining the same tab elements. */
|
||||
[data-top-tabs-root][data-position="bottom"] .netcatty-tab {
|
||||
border-radius: 0 0 0.375rem 0.375rem;
|
||||
}
|
||||
@@ -1993,11 +1993,6 @@
|
||||
"type": "string",
|
||||
"optional": true,
|
||||
"description": "true or false."
|
||||
},
|
||||
"autoReconnect": {
|
||||
"type": "string",
|
||||
"optional": true,
|
||||
"description": "true or false. Reconnect automatically after unexpected disconnects."
|
||||
}
|
||||
},
|
||||
"policy": {
|
||||
@@ -2061,11 +2056,6 @@
|
||||
"type": "string",
|
||||
"optional": true,
|
||||
"description": "true or false."
|
||||
},
|
||||
"autoReconnect": {
|
||||
"type": "string",
|
||||
"optional": true,
|
||||
"description": "true or false. Reconnect automatically after unexpected disconnects."
|
||||
}
|
||||
},
|
||||
"policy": {
|
||||
|
||||
@@ -2077,11 +2077,6 @@
|
||||
"type": "string",
|
||||
"optional": true,
|
||||
"description": "true or false."
|
||||
},
|
||||
"autoReconnect": {
|
||||
"type": "string",
|
||||
"optional": true,
|
||||
"description": "true or false. Reconnect automatically after unexpected disconnects."
|
||||
}
|
||||
},
|
||||
"policy": {
|
||||
@@ -2146,11 +2141,6 @@
|
||||
"type": "string",
|
||||
"optional": true,
|
||||
"description": "true or false."
|
||||
},
|
||||
"autoReconnect": {
|
||||
"type": "string",
|
||||
"optional": true,
|
||||
"description": "true or false. Reconnect automatically after unexpected disconnects."
|
||||
}
|
||||
},
|
||||
"policy": {
|
||||
|
||||
@@ -59,7 +59,6 @@ import {
|
||||
updatePortForwardingRule,
|
||||
validatePortForwardingHost,
|
||||
} from '../../domain/portForwardingAgentOps';
|
||||
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
|
||||
import { deleteGroup, upsertGroup } from '../../domain/vaultGroupAgentOps';
|
||||
import {
|
||||
remapSnippetTargetGroupPaths,
|
||||
@@ -183,7 +182,6 @@ export function sanitizePortForwardRuleForAgent(rule: PortForwardingRule): Recor
|
||||
remotePort: rule.remotePort,
|
||||
hostId: rule.hostId,
|
||||
autoStart: rule.autoStart,
|
||||
autoReconnect: rule.autoReconnect,
|
||||
status: rule.status,
|
||||
error: rule.error,
|
||||
createdAt: rule.createdAt,
|
||||
@@ -1395,7 +1393,7 @@ export async function handleVaultAgentOp(
|
||||
deps.keys,
|
||||
deps.identities,
|
||||
undefined,
|
||||
isPortForwardingAutoReconnectEnabled(rule),
|
||||
false,
|
||||
deps.terminalSettings,
|
||||
deps.knownHosts,
|
||||
);
|
||||
|
||||
@@ -248,7 +248,6 @@ export const STORAGE_KEY_HOST_CLICK_BEHAVIOR = 'netcatty_host_click_behavior_v1'
|
||||
export const STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = 'netcatty_show_only_ungrouped_hosts_in_root_v1';
|
||||
|
||||
// Top tabs: Show standalone SFTP view tab
|
||||
export const STORAGE_KEY_TAB_BAR_POSITION = 'netcatty_tab_bar_position_v1';
|
||||
export const STORAGE_KEY_SHOW_SFTP_TAB = 'netcatty_show_sftp_tab_v1';
|
||||
export const STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR = 'netcatty_show_host_tree_sidebar_v1';
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import test from "node:test";
|
||||
import { isPortForwardingAutoReconnectEnabled } from "../../domain/portForwardingReconnect.ts";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { Host, PortForwardingRule, SSHKey } from "../../domain/models.ts";
|
||||
@@ -2318,45 +2317,3 @@ test("startAllPortForwards skips tracked error runtimes and stopAll stops them",
|
||||
stopAndCleanupRule("bulk-idle-after-error");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
for (const type of ["local", "remote", "dynamic"] as const) {
|
||||
test(`${type} reconnect follows live rule choices and respects manual stop`, async (t) => {
|
||||
const liveRule = rule({ id: `reconnect-choice-${type}`, type, autoStart: true });
|
||||
let listener: ((status: PortForwardingRule["status"], error?: string) => void) | undefined;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: {
|
||||
startPortForward: async () => ({ success: true }),
|
||||
onPortForwardStatus: (_id: string, callback: typeof listener) => {
|
||||
listener = callback;
|
||||
return () => undefined;
|
||||
},
|
||||
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
|
||||
} },
|
||||
});
|
||||
setReconnectCallback(async () => ({ success: true }), () => isPortForwardingAutoReconnectEnabled(liveRule));
|
||||
t.after(async () => {
|
||||
setReconnectCallback(null);
|
||||
await stopAndCleanupRuleAndWait(liveRule.id);
|
||||
});
|
||||
const start = async () => {
|
||||
await startPortForward(liveRule, host(), [], [], [], () => undefined,
|
||||
isPortForwardingAutoReconnectEnabled(liveRule));
|
||||
listener?.("active");
|
||||
};
|
||||
// Legacy auto-start still reconnects, until explicitly turned off while active.
|
||||
await start();
|
||||
liveRule.autoReconnect = false;
|
||||
listener?.("error", "network dropped");
|
||||
assert.equal(getActiveConnection(liveRule.id)?.reconnectTimerCallback, undefined);
|
||||
await stopAndCleanupRuleAndWait(liveRule.id);
|
||||
// Enabling reconnect while active takes effect without restarting the tunnel.
|
||||
liveRule.autoStart = false;
|
||||
await start();
|
||||
liveRule.autoReconnect = true;
|
||||
listener?.("error", "network dropped");
|
||||
assert.ok(getActiveConnection(liveRule.id)?.reconnectTimerCallback);
|
||||
await stopPortForward(liveRule.id, () => undefined);
|
||||
assert.equal(getActiveConnection(liveRule.id)?.reconnectTimerCallback, undefined);
|
||||
});
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
selectStoppablePortForwardingRules,
|
||||
} from '../../domain/portForwardingBulkActions';
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from '../../domain/credentials';
|
||||
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
|
||||
import { resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from '../../domain/sshAuth';
|
||||
import { resolveHostKeepalive } from '../../domain/host';
|
||||
import { resolveHostSshConnectionTimeouts } from '../../domain/sshConnectionTimeouts';
|
||||
@@ -216,21 +215,11 @@ const scheduleReconnectIfNeeded = (
|
||||
enableReconnect: boolean,
|
||||
onStatusChange: (status: PortForwardingRule['status'], error?: string) => void,
|
||||
): boolean => {
|
||||
// The rule's autoReconnect setting may change while the tunnel is running
|
||||
// (e.g. toggled in the edit panel). The start-time flag alone would keep
|
||||
// rejecting reconnects after such a change, so also consult the dynamic
|
||||
// rule gate, which reads fresh rule state.
|
||||
const effectiveEnableReconnect =
|
||||
enableReconnect || shouldReconnectRule?.(ruleId) === true;
|
||||
if (
|
||||
!effectiveEnableReconnect ||
|
||||
!reconnectCallback ||
|
||||
shouldReconnectRule?.(ruleId) === false
|
||||
) {
|
||||
if (!enableReconnect || !reconnectCallback || shouldReconnectRule?.(ruleId) === false) {
|
||||
return false;
|
||||
}
|
||||
if (rulesPendingCleanup.has(ruleId)) {
|
||||
deferredReconnects.set(ruleId, { enableReconnect: effectiveEnableReconnect, onStatusChange });
|
||||
deferredReconnects.set(ruleId, { enableReconnect, onStatusChange });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1403,7 +1392,7 @@ export const startAllPortForwards = async (
|
||||
keys,
|
||||
identities,
|
||||
(status, error) => onStatusChange(rule.id, status, error),
|
||||
isPortForwardingAutoReconnectEnabled(rule),
|
||||
Boolean(rule.autoStart),
|
||||
terminalSettings,
|
||||
knownHosts,
|
||||
);
|
||||
|
||||
Vendored
+3
-3
@@ -29,7 +29,7 @@ declare global {
|
||||
acpArgs?: string[];
|
||||
}>>;
|
||||
aiPrewarmShellEnv?(): Promise<{ ok: boolean; error?: string }>;
|
||||
aiCodexGetIntegration?(options?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string; agentEnv?: Record<string, string> }): Promise<{
|
||||
aiCodexGetIntegration?(options?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string }): Promise<{
|
||||
state: 'connected_chatgpt' | 'connected_api_key' | 'connected_custom_config' | 'not_logged_in' | 'unknown';
|
||||
isConnected: boolean;
|
||||
rawOutput: string;
|
||||
@@ -45,7 +45,7 @@ declare global {
|
||||
authHash: string | null;
|
||||
} | null;
|
||||
}>;
|
||||
aiCodexStartLogin?(options?: { codexPath?: string; agentEnv?: Record<string, string> }): Promise<{
|
||||
aiCodexStartLogin?(options?: { codexPath?: string }): Promise<{
|
||||
ok: boolean;
|
||||
session?: {
|
||||
sessionId: string;
|
||||
@@ -85,7 +85,7 @@ declare global {
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
aiCodexLogout?(options?: { codexPath?: string; agentEnv?: Record<string, string> }): Promise<{
|
||||
aiCodexLogout?(options?: { codexPath?: string }): Promise<{
|
||||
ok: boolean;
|
||||
state?: 'connected_chatgpt' | 'connected_api_key' | 'connected_custom_config' | 'not_logged_in' | 'unknown';
|
||||
isConnected?: boolean;
|
||||
|
||||
-1
@@ -108,7 +108,6 @@ declare global {
|
||||
quitApp?(): Promise<{ success: boolean }>;
|
||||
jumpToSessionFromTrayPanel?(sessionId: string): Promise<{ success: boolean }>;
|
||||
connectToHostFromTrayPanel?(hostId: string): Promise<{ success: boolean }>;
|
||||
startPortForwardFromTrayPanel?(ruleId: string): Promise<{ success: boolean; error?: string }>;
|
||||
closeSessionFromTrayPanel?(sessionId: string): Promise<{ success: boolean }>;
|
||||
onTrayPanelCloseRequest?(callback: () => void): () => void;
|
||||
onTrayPanelRefresh?(callback: () => void): () => void;
|
||||
|
||||
Reference in new issue
Block a user