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:
63
App.tsx
63
App.tsx
@@ -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() {
|
||||
|
||||
153
application/AppHandlers.newWindow.test.ts
Normal file
153
application/AppHandlers.newWindow.test.ts
Normal 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"]);
|
||||
});
|
||||
@@ -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();
|
||||
{
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -510,6 +510,8 @@ export const ruTerminalMessages: Messages = {
|
||||
'tabs.logPrefix': 'Журнал:',
|
||||
'tabs.logLocal': 'Локальный',
|
||||
'tabs.copyTab': 'Копировать вкладку',
|
||||
'tabs.copyTabToNewWindow': 'Копировать вкладку в новое окно',
|
||||
'tabs.copyTabToNewWindowFailed': 'Не удалось открыть вкладку в новом окне',
|
||||
'tabs.closeOthers': 'Закрыть остальные',
|
||||
'tabs.closeToRight': 'Закрыть вкладки справа',
|
||||
'tabs.closeAll': 'Закрыть все',
|
||||
|
||||
@@ -481,6 +481,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'tabs.logPrefix': '日志:',
|
||||
'tabs.logLocal': '本地',
|
||||
'tabs.copyTab': '复制标签页',
|
||||
'tabs.copyTabToNewWindow': '复制标签页到新窗口',
|
||||
'tabs.copyTabToNewWindowFailed': '无法在新窗口打开标签页',
|
||||
'tabs.closeOthers': '关闭其他标签',
|
||||
'tabs.closeToRight': '关闭右侧标签',
|
||||
'tabs.closeAll': '关闭所有标签',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
11
types/global/netcatty-bridge-sync.d.ts
vendored
11
types/global/netcatty-bridge-sync.d.ts
vendored
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user