Add duplicate tab to new window

Adds a tab context menu action to duplicate a terminal into an independent peer window, with per-window active-tab titles and multi-window lifecycle safeguards.
This commit is contained in:
陈大猫
2026-06-06 22:13:47 +08:00
committed by GitHub
parent 06486e06dd
commit 29a6172120
19 changed files with 893 additions and 72 deletions

63
App.tsx
View File

@@ -65,13 +65,18 @@ import { resolveSnippetCommand } from './components/SnippetExecutionProvider';
import { AppView } from './application/app/AppView';
import { useAppStartupEffects } from './application/app/useAppStartupEffects';
import { LogViewWrapper, SftpViewMount, TerminalLayerMount, VaultViewContainer } from './application/app/AppMounts';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleToggleThemeImpl, handleRootContextMenuImpl } from './application/app/AppHandlers';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleToggleThemeImpl, handleRootContextMenuImpl } from './application/app/AppHandlers';
// Initialize fonts eagerly at app startup
initializeFonts();
initializeUIFonts();
type SettingsState = ReturnType<typeof useSettingsState>;
type OpenSessionInNewWindowPayload = {
title?: string;
sourceSession?: TerminalSession;
localShellType?: TerminalSession['shellType'];
};
const IS_DEV = import.meta.env.DEV;
const HOTKEY_DEBUG =
@@ -100,6 +105,7 @@ function App({ settings }: { settings: SettingsState }) {
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
// Passphrase request queue for encrypted SSH keys
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
const [pendingNewWindowSession, setPendingNewWindowSession] = useState<OpenSessionInNewWindowPayload | null>(null);
const {
theme,
@@ -245,6 +251,7 @@ function App({ settings }: { settings: SettingsState }) {
openLogView,
closeLogView,
copySession,
createSessionFromCloneSource,
} = useSessionState();
const handleRunSnippet = useCallback(
@@ -345,6 +352,56 @@ function App({ settings }: { settings: SettingsState }) {
restoreOriginalTheme: reapplyCurrentTheme,
});
const editorTabFileNameCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const tab of editorTabs) counts.set(tab.fileName, (counts.get(tab.fileName) ?? 0) + 1);
return counts;
}, [editorTabs]);
const activeWindowTitle = useMemo(() => {
if (activeTabId === 'vault') return 'Vaults';
if (activeTabId === 'sftp') return 'SFTP';
if (isEditorTabId(activeTabId)) {
const editorTab = editorTabs.find((tab) => tab.id === fromEditorTabId(activeTabId));
if (!editorTab) return 'Editor';
const suffix = (editorTabFileNameCounts.get(editorTab.fileName) ?? 0) > 1
? ` · ${editorTab.remotePath.split('/').slice(-2, -1)[0] || '/'}`
: '';
return `${editorTab.fileName}${suffix}`;
}
const workspace = workspaceById.get(activeTabId);
if (workspace) return workspace.title;
const session = sessionById.get(activeTabId);
if (session) return session.hostLabel;
const logView = logViews.find((item) => item.id === activeTabId);
if (logView) {
const isLocal = logView.log.protocol === 'local' || logView.log.hostname === 'localhost';
return `${t('tabs.logPrefix')} ${isLocal ? t('tabs.logLocal') : logView.log.hostname}`;
}
return 'Netcatty';
}, [activeTabId, editorTabFileNameCounts, editorTabs, logViews, sessionById, t, workspaceById]);
useEffect(() => {
void netcattyBridge.get()?.setWindowTitle?.(activeWindowTitle);
}, [activeWindowTitle]);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onOpenSessionInNewWindow) return undefined;
return bridge.onOpenSessionInNewWindow((payload) => {
if (!payload?.sourceSession) return;
setPendingNewWindowSession(payload);
});
}, []);
useEffect(() => {
if (!isVaultInitialized || !pendingNewWindowSession?.sourceSession) return;
createSessionFromCloneSource(pendingNewWindowSession.sourceSession, {
localShellType: pendingNewWindowSession.localShellType,
});
setPendingNewWindowSession(null);
}, [createSessionFromCloneSource, isVaultInitialized, pendingNewWindowSession]);
// Get port forwarding rules and import function
const { rules: portForwardingRules, importRules: importPortForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
@@ -715,6 +772,8 @@ function App({ settings }: { settings: SettingsState }) {
const copySessionWithCurrentShell = useCallback((sessionId: string) => { return copySessionWithCurrentShellImpl(() => ({ classifyLocalShellType, copySession, discoveredShells, resolveShellSetting, sessionId, terminalSettings }), sessionId); }, [copySession, terminalSettings, discoveredShells]);
const copySessionToNewWindowWithCurrentShell = useCallback((sessionId: string) => { return copySessionToNewWindowWithCurrentShellImpl(() => ({ classifyLocalShellType, discoveredShells, netcattyBridge, resolveShellSetting, sessions, terminalSettings, t, toast }), sessionId); }, [sessions, terminalSettings, discoveredShells, t]);
const closeTabKeyStr = useMemo(() => {
if (hotkeyScheme === 'disabled') return null;
const closeTabBinding = keyBindings.find((binding) => binding.action === 'closeTab');
@@ -986,7 +1045,7 @@ function App({ settings }: { settings: SettingsState }) {
const handleRootContextMenu = useCallback((e: React.MouseEvent<HTMLDivElement>) => { return handleRootContextMenuImpl(() => ({ e }), e); }, []);
return <AppView ctx={{ accentMode, activeTabId, activeTerminalTheme, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />;
return <AppView ctx={{ accentMode, activeTabId, activeTerminalTheme, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />;
}
function AppWithProviders() {

View File

@@ -0,0 +1,153 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { TerminalSession } from "../domain/models";
import { copySessionToNewWindowWithCurrentShellImpl } from "./app/AppHandlers";
const sourceSession = (overrides: Partial<TerminalSession> = {}): TerminalSession => ({
id: "session-1",
hostId: "host-1",
hostLabel: "Prod SSH",
hostname: "prod.example.com",
username: "deploy",
status: "connected",
protocol: "ssh",
port: 22,
...overrides,
});
test("copySessionToNewWindowWithCurrentShellImpl asks Electron to open a peer window for the selected session", async () => {
const openedPayloads: unknown[] = [];
await copySessionToNewWindowWithCurrentShellImpl(
() => ({
classifyLocalShellType: () => "zsh",
discoveredShells: [],
netcattyBridge: {
get: () => ({
openSessionInNewWindow: async (payload: unknown) => {
openedPayloads.push(payload);
return { success: true };
},
}),
},
resolveShellSetting: () => ({ command: "/bin/zsh" }),
sessions: [sourceSession()],
terminalSettings: { localShell: "system-default" },
}),
"session-1",
);
assert.equal(openedPayloads.length, 1);
assert.deepEqual(openedPayloads[0], {
title: "Prod SSH",
sourceSession: sourceSession(),
localShellType: "zsh",
});
});
test("copySessionToNewWindowWithCurrentShellImpl does nothing when the source session is gone", async () => {
let called = false;
await copySessionToNewWindowWithCurrentShellImpl(
() => ({
classifyLocalShellType: () => "zsh",
discoveredShells: [],
netcattyBridge: {
get: () => ({
openSessionInNewWindow: async () => {
called = true;
return { success: true };
},
}),
},
resolveShellSetting: () => ({ command: "/bin/zsh" }),
sessions: [],
terminalSettings: { localShell: "system-default" },
}),
"missing-session",
);
assert.equal(called, false);
});
test("copySessionToNewWindowWithCurrentShellImpl shows an error when Electron cannot open the window", async () => {
const errors: string[] = [];
const result = await copySessionToNewWindowWithCurrentShellImpl(
() => ({
classifyLocalShellType: () => "zsh",
discoveredShells: [],
netcattyBridge: {
get: () => ({
openSessionInNewWindow: async () => ({ success: false }),
}),
},
resolveShellSetting: () => ({ command: "/bin/zsh" }),
sessions: [sourceSession()],
terminalSettings: { localShell: "system-default" },
t: (key: string) => key === "tabs.copyTabToNewWindowFailed" ? "Could not open" : key,
toast: {
error: (message: string) => errors.push(message),
},
}),
"session-1",
);
assert.equal(result, false);
assert.deepEqual(errors, ["Could not open"]);
});
test("copySessionToNewWindowWithCurrentShellImpl shows an error when the bridge is unavailable", async () => {
const errors: string[] = [];
const result = await copySessionToNewWindowWithCurrentShellImpl(
() => ({
classifyLocalShellType: () => "zsh",
discoveredShells: [],
netcattyBridge: {
get: () => ({}),
},
resolveShellSetting: () => ({ command: "/bin/zsh" }),
sessions: [sourceSession()],
terminalSettings: { localShell: "system-default" },
t: (key: string) => key === "tabs.copyTabToNewWindowFailed" ? "Could not open" : key,
toast: {
error: (message: string) => errors.push(message),
},
}),
"session-1",
);
assert.equal(result, false);
assert.deepEqual(errors, ["Could not open"]);
});
test("copySessionToNewWindowWithCurrentShellImpl shows an error when the bridge throws", async () => {
const errors: string[] = [];
const result = await copySessionToNewWindowWithCurrentShellImpl(
() => ({
classifyLocalShellType: () => "zsh",
discoveredShells: [],
netcattyBridge: {
get: () => ({
openSessionInNewWindow: async () => {
throw new Error("boom");
},
}),
},
resolveShellSetting: () => ({ command: "/bin/zsh" }),
sessions: [sourceSession()],
terminalSettings: { localShell: "system-default" },
t: (key: string) => key === "tabs.copyTabToNewWindowFailed" ? "Could not open" : key,
toast: {
error: (message: string) => errors.push(message),
},
}),
"session-1",
);
assert.equal(result, false);
assert.deepEqual(errors, ["Could not open"]);
});

View File

@@ -319,6 +319,36 @@ export function copySessionWithCurrentShellImpl(getCtx: AppContextGetter, sessio
}
}
export async function copySessionToNewWindowWithCurrentShellImpl(getCtx: AppContextGetter, sessionId: string) {
const { classifyLocalShellType, discoveredShells, netcattyBridge, resolveShellSetting, sessions, terminalSettings, t, toast } = getCtx();
{
const sourceSession = sessions.find((session: { id: string }) => session.id === sessionId);
if (!sourceSession) return false;
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
const bridge = netcattyBridge.get();
if (!bridge?.openSessionInNewWindow) {
toast?.error?.(t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window');
return false;
}
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
try {
const result = await bridge.openSessionInNewWindow({
title: sourceSession.hostLabel,
sourceSession,
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, userAgent),
});
const success = result?.success === true;
if (!success) toast?.error?.(t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window');
return success;
} catch {
toast?.error?.(t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window');
return false;
}
}
}
export async function confirmIfBusyLocalTerminalImpl(getCtx: AppContextGetter, sessionIds: string[]) {
const { netcattyBridge, sessions, t } = getCtx();
{

View File

@@ -33,7 +33,7 @@ type AppViewContext = Record<string, any>;
export function AppView({ ctx }: { ctx: AppViewContext }) {
const {
accentMode, activeTabId, activeTerminalTheme, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, copySessionWithCurrentShell,
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell,
connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent,
customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost,
@@ -121,6 +121,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
onCloseSession={closeSession}
onRenameSession={startSessionRename}
onCopySession={copySessionWithCurrentShell}
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
onRenameWorkspace={startWorkspaceRename}
onCloseWorkspace={closeWorkspace}
onCloseLogView={closeLogView}

View File

@@ -495,6 +495,8 @@ export const enTerminalMessages: Messages = {
'tabs.logPrefix': 'Log:',
'tabs.logLocal': 'Local',
'tabs.copyTab': 'Copy Tab',
'tabs.copyTabToNewWindow': 'Copy Tab to New Window',
'tabs.copyTabToNewWindowFailed': 'Failed to open tab in a new window',
'tabs.closeOthers': 'Close Others',
'tabs.closeToRight': 'Close Tabs to the Right',
'tabs.closeAll': 'Close All',

View File

@@ -510,6 +510,8 @@ export const ruTerminalMessages: Messages = {
'tabs.logPrefix': 'Журнал:',
'tabs.logLocal': 'Локальный',
'tabs.copyTab': 'Копировать вкладку',
'tabs.copyTabToNewWindow': 'Копировать вкладку в новое окно',
'tabs.copyTabToNewWindowFailed': 'Не удалось открыть вкладку в новом окне',
'tabs.closeOthers': 'Закрыть остальные',
'tabs.closeToRight': 'Закрыть вкладки справа',
'tabs.closeAll': 'Закрыть все',

View File

@@ -481,6 +481,8 @@ export const zhCNTerminalMessages: Messages = {
'tabs.logPrefix': '日志:',
'tabs.logLocal': '本地',
'tabs.copyTab': '复制标签页',
'tabs.copyTabToNewWindow': '复制标签页到新窗口',
'tabs.copyTabToNewWindowFailed': '无法在新窗口打开标签页',
'tabs.closeOthers': '关闭其他标签',
'tabs.closeToRight': '关闭右侧标签',
'tabs.closeAll': '关闭所有标签',

View File

@@ -820,6 +820,25 @@ export const useSessionState = () => {
});
}, [orphanSessions, workspaces, logViews, setActiveTabId]);
const createSessionFromCloneSource = useCallback((sourceSession: TerminalSession, options?: {
localShellType?: TerminalSession['shellType'];
}) => {
const newSessionId = crypto.randomUUID();
const newSession = createCopiedTerminalSessionClone(sourceSession, {
id: newSessionId,
localShellType: options?.localShellType,
});
delete newSession.workspaceId;
setSessions(prevSessions => {
if (prevSessions.some(session => session.id === newSessionId)) return prevSessions;
return [...prevSessions, newSession];
});
setTabOrder(prevTabOrder => [...prevTabOrder, newSessionId]);
setActiveTabId(newSessionId);
return newSessionId;
}, [setActiveTabId]);
// Toggle broadcast mode for a workspace
const toggleBroadcast = useCallback((workspaceId: string) => {
setBroadcastWorkspaceIds(prev => {
@@ -946,5 +965,6 @@ export const useSessionState = () => {
closeLogView,
// Copy session
copySession,
createSessionFromCloneSource,
};
};

View File

@@ -41,6 +41,7 @@ interface TopTabsProps {
onCloseSession: (sessionId: string, e?: React.MouseEvent) => void;
onRenameSession: (sessionId: string) => void;
onCopySession: (sessionId: string) => void;
onCopySessionToNewWindow: (sessionId: string) => void;
onRenameWorkspace: (workspaceId: string) => void;
onCloseWorkspace: (workspaceId: string) => void;
onCloseLogView: (logViewId: string) => void;
@@ -73,6 +74,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onCloseSession,
onRenameSession,
onCopySession,
onCopySessionToNewWindow,
onRenameWorkspace,
onCloseWorkspace,
onCloseLogView,
@@ -407,6 +409,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onCloseSession={onCloseSession}
onRenameSession={onRenameSession}
onCopySession={onCopySession}
onCopySessionToNewWindow={onCopySessionToNewWindow}
renderBulkCloseItems={renderBulkCloseItems}
t={t}
/>
@@ -659,6 +662,8 @@ const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
prev.logViews === next.logViews &&
prev.draggingSessionId === next.draggingSessionId &&
prev.isMacClient === next.isMacClient &&
prev.onCopySession === next.onCopySession &&
prev.onCopySessionToNewWindow === next.onCopySessionToNewWindow &&
prev.onOpenSettings === next.onOpenSettings &&
prev.onSyncNow === next.onSyncNow &&
prev.onToggleTheme === next.onToggleTheme &&

View File

@@ -396,6 +396,7 @@ interface SessionTopTabProps {
onCloseSession: (sessionId: string, e?: React.MouseEvent) => void;
onRenameSession: (sessionId: string) => void;
onCopySession: (sessionId: string) => void;
onCopySessionToNewWindow: (sessionId: string) => void;
renderBulkCloseItems: RenderBulkCloseItems;
t: TranslateFn;
}
@@ -417,6 +418,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
onCloseSession,
onRenameSession,
onCopySession,
onCopySessionToNewWindow,
renderBulkCloseItems,
t,
}) => {
@@ -501,6 +503,9 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
<ContextMenuItem onClick={() => onCopySession(session.id)}>
{t('tabs.copyTab')}
</ContextMenuItem>
<ContextMenuItem onClick={() => onCopySessionToNewWindow(session.id)}>
{t('tabs.copyTabToNewWindow')}
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => onCloseSession(session.id)}>
{t('common.close')}
</ContextMenuItem>

View File

@@ -237,23 +237,24 @@ function setQuittingForUpdate(enabled) {
}
/**
* The webContents of the main window, or null if there's no usable main window
* to talk to. Used by the install handler to (a) ask the renderer about unsaved
* editors before committing to a quit, and (b) tell it to surface a "save
* first" notice. Targets the main window specifically (not getAllWindows()[0])
* so we never query the tray panel / settings window, whose renderers don't
* participate in the dirty-editor protocol.
* The webContents for usable main windows. Used by the install handler to ask
* every renderer that can own editor tabs about unsaved work before committing
* to a quit. Targets registered main windows specifically (not
* getAllWindows()[0]) so we never query tray/settings windows, whose renderers
* don't participate in the dirty-editor protocol.
*/
function getMainWebContents() {
function getMainWebContentsList() {
try {
const windowManager = require("./windowManager.cjs");
const win = windowManager.getMainWindow?.();
if (!win || win.isDestroyed?.()) return null;
const wc = win.webContents;
if (!wc || wc.isDestroyed?.() || wc.isCrashed?.()) return null;
return wc;
const windows = typeof windowManager.getMainWindows === "function"
? windowManager.getMainWindows()
: [windowManager.getMainWindow?.()].filter(Boolean);
return windows
.filter((win) => win && !win.isDestroyed?.())
.map((win) => win.webContents)
.filter((wc) => wc && !wc.isDestroyed?.() && !wc.isCrashed?.());
} catch {
return null;
return [];
}
}
@@ -485,15 +486,18 @@ function registerHandlers(ipcMain) {
// false, so it commits the quit and silently drops unsaved SFTP edits.
//
// So we ask the renderer here, while the window and renderer are still
// alive. If there's unsaved work, abort the install (don't touch the
// quitting flags, don't quitAndInstall) and tell the renderer to prompt the
// user to save; they can click "Restart Now" again afterwards. If the main
// window isn't reachable (no window / crashed renderer) there's no user to
// ask, so we install directly — matching the before-quit fail-open path.
const mainWc = getMainWebContents();
if (mainWc) {
const hasDirty = await queryDirtyEditorsSafe(mainWc, ipcMain);
if (hasDirty) {
// alive. If there's unsaved work in any main window, abort the install
// (don't touch the quitting flags, don't quitAndInstall) and tell the
// renderer to prompt the user to save; they can click "Restart Now" again
// afterwards. If no main window is reachable (no window / crashed
// renderer) there's no user to ask, so we install directly — matching the
// before-quit fail-open path.
const mainWebContents = getMainWebContentsList();
if (mainWebContents.length > 0) {
const dirtyResults = await Promise.all(
mainWebContents.map((webContents) => queryDirtyEditorsSafe(webContents, ipcMain)),
);
if (dirtyResults.some(Boolean)) {
// Broadcast so the notice reaches whichever window the user clicked
// from (main or Settings), not just the main window we queried.
notifyNeedsSave();

View File

@@ -158,6 +158,50 @@ function makeWindowManagerWithMainWindow() {
},
};
},
getMainWindows() {
return [this.getMainWindow()];
},
};
}
function makeWindowManagerWithMainWindows(count) {
const windows = Array.from({ length: count }, (_unused, index) => {
const sentChannels = [];
const webContents = {
id: index + 1,
sentChannels,
send(channel) {
sentChannels.push(channel);
},
isDestroyed() {
return false;
},
isCrashed() {
return false;
},
};
return {
webContents,
isDestroyed() {
return false;
},
};
});
return {
calls: [],
windows,
setQuittingForUpdate(value) {
this.calls.push(value);
},
isQuittingForUpdate() {
return this.calls[this.calls.length - 1] === true;
},
getMainWindow() {
return windows[0] || null;
},
getMainWindows() {
return windows;
},
};
}
@@ -395,6 +439,49 @@ test("install handler aborts and notifies when the renderer reports dirty editor
);
});
test("install handler checks every main window before installing", async () => {
const order = [];
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
quitAndInstall() {
order.push("quitAndInstall");
},
};
const fakeWindowManager = makeWindowManagerWithMainWindows(2);
const queriedWebContents = [];
const fakeDirtyEditorGuard = {
queryDirtyEditors(webContents) {
order.push(`queryDirtyEditors:${webContents.id}`);
queriedWebContents.push(webContents);
return Promise.resolve(webContents.id === 2);
},
};
const win = makeBroadcastWindow();
await withMocks(
{
autoUpdater,
windowManager: fakeWindowManager,
dirtyEditorGuard: fakeDirtyEditorGuard,
browserWindows: [win],
},
async ({ bridge, fakeGlobalShortcut }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
await ipcMain.invoke("netcatty:update:install");
assert.deepEqual(queriedWebContents, fakeWindowManager.windows.map((window) => window.webContents));
assert.equal(order.includes("quitAndInstall"), false);
assert.deepEqual(fakeWindowManager.calls, []);
assert.equal(fakeGlobalShortcut.cleanupCount, 0);
assert.equal(win.sentChannels.includes("netcatty:update:needs-save"), true);
},
);
});
test("install handler proceeds to quitAndInstall when there are no dirty editors", async () => {
const order = [];
const autoUpdater = {

View File

@@ -28,6 +28,8 @@ const THEME_COLORS = {
// State
let mainWindow = null;
const mainWindows = new Set();
let lastFocusedMainWindow = null;
let settingsWindow = null;
let currentTheme = "light";
let currentLanguage = "en";
@@ -268,7 +270,68 @@ function getWindowForIpcEvent(event) {
} catch {
// ignore
}
return mainWindow;
return getMainWindow();
}
function pruneMainWindows() {
for (const win of Array.from(mainWindows)) {
if (!win || win.isDestroyed?.()) {
mainWindows.delete(win);
if (lastFocusedMainWindow === win) lastFocusedMainWindow = null;
if (mainWindow === win) mainWindow = null;
}
}
}
function getMainWindowList() {
pruneMainWindows();
return Array.from(mainWindows).filter((win) => isWindowUsable(win));
}
function rememberMainWindow(win) {
if (!win || win.isDestroyed?.()) return;
lastFocusedMainWindow = win;
mainWindow = win;
}
function registerMainWindow(win) {
if (!win || win.isDestroyed?.()) return;
mainWindows.add(win);
rememberMainWindow(win);
try {
win.on("focus", () => rememberMainWindow(win));
} catch {
// ignore
}
}
function unregisterMainWindow(win) {
if (!win) return;
mainWindows.delete(win);
if (lastFocusedMainWindow === win) lastFocusedMainWindow = null;
if (mainWindow === win) mainWindow = null;
const fallback = getMainWindowList().at(-1) || null;
if (fallback) rememberMainWindow(fallback);
}
function forEachMainWindow(callback) {
for (const win of getMainWindowList()) {
try {
callback(win);
} catch {
// ignore per-window broadcast failures
}
}
}
function getMainWindowCount() {
return getMainWindowList().length;
}
function isMainWindow(win) {
if (!win || win.isDestroyed?.()) return false;
pruneMainWindows();
return mainWindows.has(win);
}
function closeBrowserWindow(win) {
@@ -295,9 +358,7 @@ function requestWindowCommandClose(win) {
function broadcastLanguageChanged() {
try {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents?.send?.("netcatty:languageChanged", currentLanguage);
}
forEachMainWindow((win) => win.webContents?.send?.("netcatty:languageChanged", currentLanguage));
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.webContents?.send?.("netcatty:languageChanged", currentLanguage);
}
@@ -718,6 +779,9 @@ const mainWindowApi = createMainWindowApi({
registerWindowHandlers,
requestWindowCommandClose,
shouldCloseWindowFromInput,
registerMainWindow,
unregisterMainWindow,
getMainWindowCount,
closeSettingsWindow: (...args) => closeSettingsWindow(...args),
hideSettingsWindow: (...args) => hideSettingsWindow(...args),
});
@@ -841,6 +905,18 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
return restoreWindowInputFocus(win);
});
ipcMain.handle("netcatty:window:setTitle", (event, title) => {
const win = getWindowForIpcEvent(event);
if (!win || win.isDestroyed()) return false;
const value = typeof title === "string" ? title.trim() : "";
try {
win.setTitle(value || "Netcatty");
return true;
} catch {
return false;
}
});
ipcMain.handle("netcatty:setTheme", (_event, theme) => {
currentTheme = theme;
nativeTheme.themeSource = theme;
@@ -848,9 +924,7 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
? (nativeTheme?.shouldUseDarkColors ? "dark" : "light")
: theme;
const themeConfig = THEME_COLORS[effectiveTheme] || THEME_COLORS.light;
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.setBackgroundColor(themeConfig.background);
}
forEachMainWindow((win) => win.setBackgroundColor(themeConfig.background));
// Also update settings window if open
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.setBackgroundColor(themeConfig.background);
@@ -861,9 +935,7 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
ipcMain.handle("netcatty:setBackgroundColor", (_event, color) => {
const normalized = normalizeBackgroundColor(color);
if (!normalized) return false;
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.setBackgroundColor(normalized);
}
forEachMainWindow((win) => win.setBackgroundColor(normalized));
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.setBackgroundColor(normalized);
}
@@ -913,9 +985,11 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
// Notify all windows except the sender
// Check both isDestroyed() and webContents.isDestroyed() to handle HMR refresh
try {
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed() && mainWindow.webContents.id !== senderId) {
mainWindow.webContents.send("netcatty:settings:changed", payload);
}
forEachMainWindow((win) => {
if (!win.webContents.isDestroyed() && win.webContents.id !== senderId) {
win.webContents.send("netcatty:settings:changed", payload);
}
});
if (settingsWindow && !settingsWindow.isDestroyed() && !settingsWindow.webContents.isDestroyed() && settingsWindow.webContents.id !== senderId) {
settingsWindow.webContents.send("netcatty:settings:changed", payload);
}
@@ -940,13 +1014,13 @@ function buildAppMenu(Menu, app, isMac, language = currentLanguage) {
menuDeps = { Menu, app, isMac };
const closeFocusedWindow = (_menuItem, browserWindow) => {
// 只有主窗口/设置窗口会接收 command-close其他 BrowserWindow 直接关闭。
if (browserWindow && browserWindow !== mainWindow && browserWindow !== settingsWindow) {
if (browserWindow && !isMainWindow(browserWindow) && browserWindow !== settingsWindow) {
closeBrowserWindow(browserWindow);
return;
}
// macOS 的 Cmd+W 先交给渲染层关闭标签页;没有标签页时渲染层再关闭窗口。
requestWindowCommandClose(browserWindow) || requestWindowCommandClose(mainWindow);
requestWindowCommandClose(browserWindow) || requestWindowCommandClose(getMainWindow());
};
const template = [
...(isMac
@@ -1018,7 +1092,14 @@ function buildAppMenu(Menu, app, isMac, language = currentLanguage) {
* Get the main window instance
*/
function getMainWindow() {
return mainWindow;
const candidates = getMainWindowList();
if (lastFocusedMainWindow && candidates.includes(lastFocusedMainWindow)) {
return lastFocusedMainWindow;
}
if (mainWindow && candidates.includes(mainWindow)) {
return mainWindow;
}
return candidates.at(-1) || null;
}
/**
@@ -1035,6 +1116,11 @@ module.exports = {
prewarmSettingsWindow,
buildAppMenu,
getMainWindow,
getMainWindows: getMainWindowList,
getMainWindowCount,
isMainWindow,
registerMainWindow,
unregisterMainWindow,
getSettingsWindow,
isWindowUsable,
registerWindowHandlers,

View File

@@ -72,7 +72,11 @@ function createMainWindowApi(ctx) {
},
});
mainWindow = win;
if (typeof registerMainWindow === "function") {
registerMainWindow(win);
} else {
mainWindow = win;
}
// Clear reference when the main window is destroyed
win.on('closed', () => {
@@ -84,7 +88,11 @@ function createMainWindowApi(ctx) {
} catch {
// ignore
}
if (mainWindow === win) mainWindow = null;
if (typeof unregisterMainWindow === "function") {
unregisterMainWindow(win);
} else if (mainWindow === win) {
mainWindow = null;
}
});
// Log renderer crashes for diagnostics (skip normal clean exits)
@@ -169,6 +177,7 @@ function createMainWindowApi(ctx) {
// Track window bounds for saving (use last non-maximized/non-fullscreen bounds)
let lastNormalBounds = null;
let saveStateTimer = null;
let thisWindowCloseRequested = false;
const updateNormalBounds = () => {
if (!win.isDestroyed() && !win.isMaximized() && !win.isFullScreen()) {
@@ -204,7 +213,8 @@ function createMainWindowApi(ctx) {
// Save state when window is about to close
win.on("close", (event) => {
// Check if close-to-tray is enabled
if (!isQuitting && getGlobalShortcutBridge().handleWindowClose(event, win)) {
const trackedMainWindowCount = typeof getMainWindowCount === "function" ? getMainWindowCount() : 1;
if (trackedMainWindowCount <= 1 && !isQuitting && getGlobalShortcutBridge().handleWindowClose(event, win)) {
// Window was hidden to tray - save state before returning
if (saveStateTimer) clearTimeout(saveStateTimer);
const state = getWindowBoundsState(win, lastNormalBounds);
@@ -213,10 +223,10 @@ function createMainWindowApi(ctx) {
return;
}
if (windowStateCloseRequested) {
if (thisWindowCloseRequested) {
return;
}
windowStateCloseRequested = true;
thisWindowCloseRequested = true;
if (saveStateTimer) clearTimeout(saveStateTimer);
const state = getWindowBoundsState(win, lastNormalBounds);
if (pendingWindowStateWrite) {

View File

@@ -4,11 +4,13 @@ const assert = require("node:assert/strict");
const {
buildAppMenu,
isWindowUsable,
registerMainWindow,
registerWindowHandlers,
resolveSettingsWindowBounds,
restoreWindowInputFocus,
requestWindowCommandClose,
shouldCloseWindowFromInput,
unregisterMainWindow,
} = require("./windowManager.cjs");
const { createMainWindowApi } = require("./windowManager/mainWindow.cjs");
@@ -207,6 +209,53 @@ test("buildAppMenu closes a non-app window directly when Cmd+W is invoked", () =
assert.deepEqual(calls, ["close"]);
});
test("buildAppMenu sends Cmd+W to any registered main window renderer", () => {
let capturedTemplate = null;
const Menu = {
buildFromTemplate(template) {
capturedTemplate = template;
return { template };
},
};
const calls = [];
const firstMainWindow = {
isDestroyed() { return false; },
on() {},
webContents: {
isDestroyed() { return false; },
send(channel) {
calls.push(`first:${channel}`);
},
},
};
const secondMainWindow = {
isDestroyed() { return false; },
on() {},
webContents: {
isDestroyed() { return false; },
send(channel) {
calls.push(`second:${channel}`);
},
},
};
registerMainWindow(firstMainWindow);
registerMainWindow(secondMainWindow);
try {
buildAppMenu(Menu, { name: "Netcatty" }, true);
const windowMenu = capturedTemplate.find((item) => item.label === "Window");
const closeItem = windowMenu.submenu.find((item) => item.accelerator === "CommandOrControl+W");
closeItem.click(null, firstMainWindow);
assert.deepEqual(calls, ["first:netcatty:window:command-close"]);
} finally {
unregisterMainWindow(firstMainWindow);
unregisterMainWindow(secondMainWindow);
}
});
test("requestWindowCommandClose sends command-close to renderer-capable windows", () => {
const sentChannels = [];
const win = {
@@ -342,7 +391,237 @@ test("main window asks renderer to close tabs from macOS Command+W before-input-
assert.equal(commandCloseRequests.length, 1);
});
test("window focus IPC handler focuses the sender owner window", async () => {
test("createWindow registers each main window as an independent app window", async () => {
const registered = [];
const unregistered = [];
class BrowserWindowStub {
constructor() {
this.webContents = {
id: registered.length + 1,
on() {},
once() {},
isDestroyed() {
return false;
},
isCrashed() {
return false;
},
setIgnoreMenuShortcuts() {},
setWindowOpenHandler() {},
openDevTools() {},
};
}
on(channel, handler) {
if (channel === "closed") this._closedHandler = handler;
}
once() {}
isDestroyed() { return false; }
isMaximized() { return false; }
isFullScreen() { return false; }
getBounds() { return { x: 0, y: 0, width: 1400, height: 900 }; }
setBackgroundColor() {}
async loadURL() {}
close() {
this._closedHandler?.();
}
}
const api = createMainWindowApi({
mainWindow: null,
electronApp: null,
currentTheme: "light",
isQuitting: false,
pendingWindowStateWrite: null,
queuedWindowState: null,
windowStateCloseRequested: false,
DEFAULT_WINDOW_WIDTH: 1400,
DEFAULT_WINDOW_HEIGHT: 900,
MIN_WINDOW_WIDTH: 1100,
MIN_WINDOW_HEIGHT: 640,
V8_CACHE_OPTIONS: "bypassHeatCheck",
THEME_COLORS: { light: { background: "#fff" } },
unhealthyWebContentsIds: new Set(),
rendererReadySeenByWebContentsId: new Set(),
__dirname,
URL,
require,
console,
setTimeout,
clearTimeout,
getGlobalShortcutBridge() {
return { handleWindowClose: () => false };
},
debugLog() {},
resolveFrontendBackgroundColor() { return null; },
loadWindowState() { return null; },
getDevRendererBaseUrl(url) { return url; },
getWindowBoundsState() { return null; },
queueWindowStateSave() {},
saveWindowStateSync() {},
setupDeferredShow() {},
createExternalOnlyWindowOpenHandler() { return {}; },
createAppWindowOpenHandler() { return {}; },
attachOAuthLoadingOverlay() {},
registerWindowHandlers() {},
requestWindowCommandClose() {
return true;
},
shouldCloseWindowFromInput,
registerMainWindow(win) {
registered.push(win);
},
unregisterMainWindow(win) {
unregistered.push(win);
},
closeSettingsWindow() {},
hideSettingsWindow() {},
});
const electronModule = {
BrowserWindow: BrowserWindowStub,
nativeTheme: {},
app: {},
screen: {},
shell: {},
ipcMain: {},
};
const options = {
preload: "/tmp/preload.cjs",
devServerUrl: "http://localhost:5173",
isDev: true,
appIcon: null,
isMac: true,
electronDir: __dirname,
};
const first = await api.createWindow(electronModule, options);
const second = await api.createWindow(electronModule, options);
assert.equal(registered.length, 2);
assert.equal(registered[0], first);
assert.equal(registered[1], second);
assert.notEqual(first, second);
first.close();
assert.deepEqual(unregistered, [first]);
});
test("each main window close saves its own state", async () => {
const closeHandlers = [];
const savedStates = [];
class BrowserWindowStub {
constructor() {
this.webContents = {
id: closeHandlers.length + 1,
on() {},
once() {},
isDestroyed() {
return false;
},
isCrashed() {
return false;
},
setIgnoreMenuShortcuts() {},
setWindowOpenHandler() {},
openDevTools() {},
};
}
on(channel, handler) {
if (channel === "close") closeHandlers.push(handler);
}
once() {}
isDestroyed() { return false; }
isMaximized() { return false; }
isFullScreen() { return false; }
getBounds() { return { x: 0, y: 0, width: 1400, height: 900 }; }
setBackgroundColor() {}
async loadURL() {}
close() {}
}
const api = createMainWindowApi({
mainWindow: null,
electronApp: null,
currentTheme: "light",
isQuitting: false,
pendingWindowStateWrite: null,
queuedWindowState: null,
windowStateCloseRequested: false,
DEFAULT_WINDOW_WIDTH: 1400,
DEFAULT_WINDOW_HEIGHT: 900,
MIN_WINDOW_WIDTH: 1100,
MIN_WINDOW_HEIGHT: 640,
V8_CACHE_OPTIONS: "bypassHeatCheck",
THEME_COLORS: { light: { background: "#fff" } },
unhealthyWebContentsIds: new Set(),
rendererReadySeenByWebContentsId: new Set(),
__dirname,
URL,
require,
console,
setTimeout,
clearTimeout,
getGlobalShortcutBridge() {
return { handleWindowClose: () => false };
},
debugLog() {},
resolveFrontendBackgroundColor() { return null; },
loadWindowState() { return null; },
getDevRendererBaseUrl(url) { return url; },
getWindowBoundsState(win) {
return { windowId: win.webContents.id };
},
queueWindowStateSave() {},
saveWindowStateSync(state) {
savedStates.push(state);
},
setupDeferredShow() {},
createExternalOnlyWindowOpenHandler() { return {}; },
createAppWindowOpenHandler() { return {}; },
attachOAuthLoadingOverlay() {},
registerWindowHandlers() {},
requestWindowCommandClose() {
return true;
},
shouldCloseWindowFromInput,
registerMainWindow() {},
unregisterMainWindow() {},
closeSettingsWindow() {},
hideSettingsWindow() {},
});
const electronModule = {
BrowserWindow: BrowserWindowStub,
nativeTheme: {},
app: {},
screen: {},
shell: {},
ipcMain: {},
};
const options = {
preload: "/tmp/preload.cjs",
devServerUrl: "http://localhost:5173",
isDev: true,
appIcon: null,
isMac: true,
electronDir: __dirname,
};
await api.createWindow(electronModule, options);
await api.createWindow(electronModule, options);
assert.equal(closeHandlers.length, 2);
closeHandlers[0]({});
closeHandlers[1]({});
assert.deepEqual(savedStates, [{ windowId: 1 }, { windowId: 2 }]);
});
test("window IPC handlers target the sender owner window", async () => {
const handlers = new Map();
const ipcMain = {
handle(channel, handler) {
@@ -353,10 +632,14 @@ test("window focus IPC handler focuses the sender owner window", async () => {
},
};
const calls = [];
const titles = [];
const win = {
isDestroyed() {
return false;
},
setTitle(title) {
titles.push(title);
},
focus() {
calls.push("focus");
},
@@ -384,6 +667,17 @@ test("window focus IPC handler focuses the sender owner window", async () => {
assert.equal(result, true);
assert.deepEqual(calls, ["focus", "webContents.focus"]);
const titleResult = await handlers.get("netcatty:window:setTitle")({
sender: {
id: 202,
getOwnerBrowserWindow() {
return win;
},
},
}, "Prod SSH");
assert.equal(titleResult, true);
assert.deepEqual(titles, ["Prod SSH"]);
});
test("resolveSettingsWindowBounds centers settings on the requesting window display", () => {

View File

@@ -743,34 +743,32 @@ if (!gotLock) {
}
const { ipcMain: _ipcMain } = electronModule;
// Target the main window explicitly. Falling back to
// BrowserWindow.getAllWindows()[0] could pick the tray panel or settings
// window, whose renderers don't listen for app:query-dirty-editors and
// would force the 5s timeout fallback to run on every quit.
const win = getWindowManager().getMainWindow();
// No main window, or it's hidden (tray-panel "Quit" path) — there's no
// visible UI to surface a "save first" toast on, so skip the round-trip
// and quit directly. The renderer's dirty-editor check exists to warn the
// user; if they can't see the warning, it's just dead 5-second wait.
//
// A minimized window is *not* hidden: the user has a taskbar/Dock entry
// and can restore in one click, so we still want to gate the quit on the
// dirty-editor check there. Some platforms report isVisible()=false on a
// minimized window (see globalShortcutBridge.cjs:478), so check both.
const isReachableByUser =
win && !win.isDestroyed?.() &&
(win.isVisible?.() || win.isMinimized?.());
if (!isReachableByUser) {
// Target all visible/recoverable main windows explicitly. Falling back to
// BrowserWindow.getAllWindows() could pick tray/settings windows whose
// renderers don't listen for app:query-dirty-editors and would force the
// timeout fallback on every quit.
const mainWindows = typeof getWindowManager().getMainWindows === "function"
? getWindowManager().getMainWindows()
: [getWindowManager().getMainWindow()].filter(Boolean);
// No reachable main window (tray-panel "Quit" path) — there's no visible
// UI to surface a "save first" toast on, so skip the round-trip and quit
// directly. A minimized window is still reachable via taskbar/Dock.
const reachableMainWindows = mainWindows.filter((candidate) => (
candidate && !candidate.isDestroyed?.() &&
(candidate.isVisible?.() || candidate.isMinimized?.())
));
if (reachableMainWindows.length === 0) {
commitQuit();
return;
}
// The renderer needs to be alive for the IPC roundtrip to make sense.
// A crashed renderer would silently drop the message and we'd wait
// 5 s for nothing — skip straight to quit (we can't ask the user
// anyway, the UI is gone).
const wc = win.webContents;
if (!wc || wc.isDestroyed?.() || wc.isCrashed?.()) {
// Crashed/dead renderers are skipped; there is no usable UI to warn from.
const queryableWebContents = reachableMainWindows
.map((candidate) => candidate.webContents)
.filter((wc) => wc && !wc.isDestroyed?.() && !wc.isCrashed?.());
if (queryableWebContents.length === 0) {
commitQuit();
return;
}
@@ -783,9 +781,12 @@ if (!gotLock) {
// through queryDirtyEditors so the request/reply/timeout handling stays in
// one place. It fails open (resolves false) on timeout / dead renderer, so
// a hung renderer can never strand the quit.
queryDirtyEditors(wc, QUIT_GUARD_TIMEOUT_MS, { ipcMain: _ipcMain })
.then((hasDirty) => {
Promise.all(
queryableWebContents.map((wc) => queryDirtyEditors(wc, QUIT_GUARD_TIMEOUT_MS, { ipcMain: _ipcMain })),
)
.then((dirtyResults) => {
quitGuardChannelBusy = false;
const hasDirty = dirtyResults.some(Boolean);
if (!hasDirty) {
commitQuit();
return;

View File

@@ -299,6 +299,48 @@ function createBridgeRegistrar(context) {
return false;
}
});
ipcMain.handle("netcatty:window:openSession", async (_event, payload) => {
try {
if (!payload || typeof payload !== "object" || !payload.sourceSession) {
return { success: false, error: "Invalid session payload" };
}
const title = typeof payload.title === "string" && payload.title.trim()
? payload.title.trim()
: "Netcatty";
const win = await getWindowManager().createWindow(electronModule, {
preload,
devServerUrl: effectiveDevServerUrl,
isDev,
appIcon,
isMac,
electronDir,
onRegisterBridge: registerBridges,
});
try {
win.setTitle(title);
} catch {
// ignore
}
try {
await getWindowManager().waitForRendererReady(win, { timeoutMs: 8000 });
} catch (err) {
console.warn("[Main] New session window did not report ready before payload send:", err?.message || err);
}
if (win.isDestroyed?.() || win.webContents?.isDestroyed?.()) {
return { success: false, error: "Window closed before session could open" };
}
win.webContents.send("netcatty:window:openSession", {
title,
sourceSession: payload.sourceSession,
localShellType: payload.localShellType,
});
return { success: true };
} catch (err) {
console.error("[Main] Failed to open session in new window:", err);
return { success: false, error: err?.message || "Failed to open new window" };
}
});
// Cloud sync master password (stored in-memory + persisted via safeStorage)
ipcMain.handle("netcatty:cloudSync:session:setPassword", async (_event, password) => {

View File

@@ -354,6 +354,13 @@ function createPreloadApi(ctx) {
windowIsMaximized: () => ipcRenderer.invoke("netcatty:window:isMaximized"),
windowIsFullscreen: () => ipcRenderer.invoke("netcatty:window:isFullscreen"),
windowFocus: () => ipcRenderer.invoke("netcatty:window:focus"),
setWindowTitle: (title) => ipcRenderer.invoke("netcatty:window:setTitle", title),
openSessionInNewWindow: (payload) => ipcRenderer.invoke("netcatty:window:openSession", payload),
onOpenSessionInNewWindow: (cb) => {
const handler = (_event, payload) => cb(payload);
ipcRenderer.on("netcatty:window:openSession", handler);
return () => ipcRenderer.removeListener("netcatty:window:openSession", handler);
},
onWindowCommandCloseRequested: (cb) => {
const handler = () => cb();
ipcRenderer.on("netcatty:window:command-close", handler);

View File

@@ -12,6 +12,17 @@ declare global {
windowIsMaximized?(): Promise<boolean>;
windowIsFullscreen?(): Promise<boolean>;
windowFocus?(): Promise<boolean>;
setWindowTitle?(title: string): Promise<boolean>;
openSessionInNewWindow?(payload: {
title: string;
sourceSession: import("../../domain/models").TerminalSession;
localShellType?: import("../../domain/models").TerminalSession['shellType'];
}): Promise<{ success: boolean; error?: string }>;
onOpenSessionInNewWindow?(cb: (payload: {
title: string;
sourceSession: import("../../domain/models").TerminalSession;
localShellType?: import("../../domain/models").TerminalSession['shellType'];
}) => void): () => void;
onWindowCommandCloseRequested?(cb: () => void): () => void;
onWindowFullScreenChanged?(cb: (isFullscreen: boolean) => void): () => void;