Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 c5308a573d fix(ui): cut unnecessary AppView/VaultView/settings re-renders
Stabilize TopTabs close handler, memo AppHostTreeLayer, exclude accent from
settingsVersion, ignore connectionLogs off the logs section, share lastConnected
host refs, and keep rename dialog drafts local so chrome domain stays stable.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:35:24 +00:00
Cursor Agentand陈大猫 d43885bdbc fix(electron): reduce baseline memory via spellcheck/GPU/prewarm guards
Disable spellcheck on BrowserWindows and the default session, drop spare
renderers, gate aggressive GPU switches behind NETCATTY_COMPAT_GPU, and make
settings prewarm opt-in via NETCATTY_PREWARM_SETTINGS.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:33:24 +00:00
18 changed files with 394 additions and 235 deletions

No files matched your search

+1 -9
View File
@@ -291,15 +291,11 @@ function App({ settings }: { settings: SettingsState }) {
draggingSessionId,
setDraggingSessionId,
sessionRenameTarget,
sessionRenameValue,
setSessionRenameValue,
startSessionRename,
renameSessionInline,
submitSessionRename,
resetSessionRename,
workspaceRenameTarget,
workspaceRenameValue,
setWorkspaceRenameValue,
startWorkspaceRename,
submitWorkspaceRename,
resetWorkspaceRename,
@@ -1945,10 +1941,7 @@ function App({ settings }: { settings: SettingsState }) {
resetWorkspaceRename,
resolvedTheme,
sessionRenameTarget,
sessionRenameValue,
setActiveTabId,
setSessionRenameValue,
setWorkspaceRenameValue,
settings,
startSessionRename,
renameSessionInline,
@@ -1960,8 +1953,7 @@ function App({ settings }: { settings: SettingsState }) {
toggleConnectionLogSaved,
updateConnectionLog,
workspaceRenameTarget,
workspaceRenameValue,
}), [accentMode, clearUnsavedConnectionLogs, closeLogView, customAccent, deleteConnectionLog, handleEndSessionDrag, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handleSyncNowManual, isMacClient, logViews, openLogView, orderedTabsWithEditors, orphanSessionsForShell, reorderWorkTabs, resetSessionRename, resetWorkspaceRename, resolvedTheme, sessionRenameTarget, sessionRenameValue, setActiveTabId, setSessionRenameValue, setWorkspaceRenameValue, settings, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, themeById, toggleConnectionLogSaved, updateConnectionLog, workspaceRenameTarget, workspaceRenameValue]);
}), [accentMode, clearUnsavedConnectionLogs, closeLogView, customAccent, deleteConnectionLog, handleEndSessionDrag, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handleSyncNowManual, isMacClient, logViews, openLogView, orderedTabsWithEditors, orphanSessionsForShell, reorderWorkTabs, resetSessionRename, resetWorkspaceRename, resolvedTheme, sessionRenameTarget, setActiveTabId, settings, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, themeById, toggleConnectionLogSaved, updateConnectionLog, workspaceRenameTarget]);
const appDialogsDomain = useMemo(() => ({
addToWorkspaceDialog,
+5
View File
@@ -53,3 +53,8 @@ test('shared host tree forwards work-surface host management callbacks', () => {
assert.match(hostTreeLayerSource, /onNewHost=\{onNewHost\}/);
assert.match(hostTreeLayerSource, /onEditHost=\{onEditHost\}/);
});
test('shared host tree layer is memoized with a custom areEqual', () => {
assert.match(hostTreeLayerSource, /memo\(AppHostTreeLayerInner,\s*appHostTreeLayerAreEqual\)/);
});
+31 -2
View File
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React, { memo, useMemo } from 'react';
import { useActiveTabId } from '../state/activeTabStore';
import type { EditorTabChrome } from '../state/editorTabStore';
@@ -47,7 +47,33 @@ export function getAppHostTreeLayerStyle(surfaceVisible: boolean): React.CSSProp
};
}
export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
function appHostTreeLayerAreEqual(
prev: AppHostTreeLayerProps,
next: AppHostTreeLayerProps,
): boolean {
return prev.enabled === next.enabled
&& prev.hosts === next.hosts
&& prev.customGroups === next.customGroups
&& prev.groupConfigs === next.groupConfigs
&& prev.sessions === next.sessions
&& prev.workspaces === next.workspaces
&& prev.editorTabs === next.editorTabs
&& prev.logViews === next.logViews
&& prev.orderedTabs === next.orderedTabs
&& prev.accentMode === next.accentMode
&& prev.currentTerminalTheme === next.currentTerminalTheme
&& prev.customAccent === next.customAccent
&& prev.followAppTerminalTheme === next.followAppTerminalTheme
&& prev.hostById === next.hostById
&& prev.themeById === next.themeById
&& prev.resolveSessionAppearance === next.resolveSessionAppearance
&& prev.onConnect === next.onConnect
&& prev.onNewHost === next.onNewHost
&& prev.onEditHost === next.onEditHost
&& prev.onCreateLocalTerminal === next.onCreateLocalTerminal;
}
const AppHostTreeLayerInner: React.FC<AppHostTreeLayerProps> = ({
enabled,
hosts,
customGroups,
@@ -154,3 +180,6 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
</div>
);
};
export const AppHostTreeLayer = memo(AppHostTreeLayerInner, appHostTreeLayerAreEqual);
AppHostTreeLayer.displayName = 'AppHostTreeLayer';
+153 -108
View File
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo } from 'react';
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
import { editorTabStore } from '../state/editorTabStore';
@@ -12,7 +12,7 @@ import { QuickScriptEditorDialog } from '../../components/scripts/QuickScriptEdi
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
import { PassphraseModal } from '../../components/PassphraseModal';
import { UnsavedChangesProvider } from '../../components/editor/UnsavedChangesDialog';
import { UnsavedChangesProvider, promptUnsavedChanges } from '../../components/editor/UnsavedChangesDialog';
import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider';
import { Button } from '../../components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../components/ui/dialog';
@@ -66,6 +66,64 @@ const TextEditorTabFallback = ({ tabId }: { tabId: string }) => {
);
};
/** Local draft so keystrokes do not rebuild App chrome domain every character. */
function RenameDraftDialog({
open,
inputId,
initialName,
title,
nameLabel,
placeholder,
cancelLabel,
saveLabel,
onCancel,
onSave,
}: {
open: boolean;
inputId: string;
initialName: string;
title: string;
nameLabel: string;
placeholder: string;
cancelLabel: string;
saveLabel: string;
onCancel: () => void;
onSave: (name: string) => void;
}) {
const [draft, setDraft] = useState(initialName);
useEffect(() => {
if (open) setDraft(initialName);
}, [open, initialName]);
return (
<Dialog open={open} onOpenChange={(nextOpen) => {
if (!nextOpen) onCancel();
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor={inputId}>{nameLabel}</Label>
<Input
id={inputId}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') onSave(draft); }}
autoFocus
placeholder={placeholder}
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onCancel}>{cancelLabel}</Button>
<Button onClick={() => onSave(draft)} disabled={!draft.trim()}>{saveLabel}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export type AppViewProps = {
domains: AppViewDomains;
};
@@ -127,14 +185,14 @@ function AppViewInner({ domains }: AppViewProps) {
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
resolveEmptyVaultConflict, resolvedTheme, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessionRenameValue, sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
setNavigateToSection, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
setWorkspaceFocusedSession, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
sftpShowHiddenFiles, sftpUseCompressedUpload, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
readPersistedHosts, readPersistedManagedSources, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces,
updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaces,
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
} = ctx;
@@ -211,6 +269,68 @@ function AppViewInner({ domains }: AppViewProps) {
pluginViewTabStore.close(tabId);
}, [orderedTabsWithEditors]);
const orderedTabsWithEditorsRef = useRef(orderedTabsWithEditors);
orderedTabsWithEditorsRef.current = orderedTabsWithEditors;
// Stable for TopTabs memo: read ordered tabs via ref; prompt via module singleton.
const handleRequestCloseEditorTab = useCallback(async (id: string): Promise<boolean> => {
const tab = editorTabStore.getTab(id);
if (!tab) return false;
const closeEditorAndActivateNeighbor = () => {
const closingTabId = toEditorTabId(id);
const list = orderedTabsWithEditorsRef.current;
const idx = list.indexOf(closingTabId);
releaseEditorTabSaveCoordinator(id);
editorTabStore.close(id);
if (activeTabStore.getActiveTabId() !== closingTabId) return;
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
};
const dirty = tab.content !== tab.baselineContent;
if (!dirty) {
closeEditorAndActivateNeighbor();
return true;
}
const choice = await promptUnsavedChanges(tab.fileName);
if (choice === 'cancel') return false;
if (choice === 'discard') {
closeEditorAndActivateNeighbor();
return true;
}
if (choice === 'save') {
const ok = await saveEditorTab(id);
if (!ok) {
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
toast.error(msg, 'SFTP');
return false;
}
const latest = editorTabStore.getTab(id);
if (!latest || latest.content !== latest.baselineContent) return false;
closeEditorAndActivateNeighbor();
return true;
}
return false;
}, []);
useEffect(() => {
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
}, [handleRequestCloseEditorTab, handleRequestCloseEditorTabRef]);
const handleSaveSessionRename = useCallback((name: string) => {
if (!sessionRenameTarget) return;
submitSessionRename(sessionRenameTarget.id, name);
resetSessionRename();
}, [resetSessionRename, sessionRenameTarget, submitSessionRename]);
const handleSaveWorkspaceRename = useCallback((name: string) => {
if (!workspaceRenameTarget) return;
submitWorkspaceRename(workspaceRenameTarget.id, name);
resetWorkspaceRename();
}, [resetWorkspaceRename, submitWorkspaceRename, workspaceRenameTarget]);
useEffect(() => {
const catalog = buildPluginSettingScopeCatalog({
hosts,
@@ -224,54 +344,7 @@ function AppViewInner({ domains }: AppViewProps) {
return (
<SnippetExecutionProvider>
<UnsavedChangesProvider>
{({ prompt }) => {
// Helper: close an editor tab and activate the neighbor (left-preference), or vault.
const closeEditorAndActivateNeighbor = (id: string) => {
const closingTabId = toEditorTabId(id);
const list = orderedTabsWithEditors;
const idx = list.indexOf(closingTabId);
releaseEditorTabSaveCoordinator(id);
editorTabStore.close(id);
if (activeTabStore.getActiveTabId() !== closingTabId) return;
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
};
// Real dirty-confirm close handler.
const handleRequestCloseEditorTab = async (id: string): Promise<boolean> => {
const tab = editorTabStore.getTab(id);
if (!tab) return false;
const dirty = tab.content !== tab.baselineContent;
if (!dirty) {
closeEditorAndActivateNeighbor(id);
return true;
}
const choice = await prompt(tab.fileName);
if (choice === 'cancel') return false;
if (choice === 'discard') {
closeEditorAndActivateNeighbor(id);
return true;
}
if (choice === 'save') {
const ok = await saveEditorTab(id);
if (!ok) {
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
toast.error(msg, 'SFTP');
return false;
}
const latest = editorTabStore.getTab(id);
if (!latest || latest.content !== latest.baselineContent) return false;
closeEditorAndActivateNeighbor(id);
return true;
}
return false;
};
// Expose to the hotkey dispatcher (Cmd/Ctrl+W).
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
return (
{() => (
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" data-terminal-appearance-root onContextMenu={handleRootContextMenu}>
<TopTabs
theme={resolvedTheme}
@@ -715,59 +788,32 @@ function AppViewInner({ domains }: AppViewProps) {
</LazyLoadBoundary>
)}
<Dialog open={!!sessionRenameTarget} onOpenChange={(open) => {
if (!open) {
resetSessionRename();
}
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t('dialog.renameSession.title')}</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor="session-name">{t('field.name')}</Label>
<Input
id="session-name"
value={sessionRenameValue}
onChange={(e) => setSessionRenameValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submitSessionRename(); }}
autoFocus
placeholder={t('placeholder.sessionName')}
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={resetSessionRename}>{t('common.cancel')}</Button>
<Button onClick={submitSessionRename} disabled={!sessionRenameValue.trim()}>{t('common.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<RenameDraftDialog
open={!!sessionRenameTarget}
inputId="session-name"
initialName={sessionRenameTarget ? (sessionRenameTarget.customName || sessionRenameTarget.hostLabel) : ''}
title={t('dialog.renameSession.title')}
nameLabel={t('field.name')}
placeholder={t('placeholder.sessionName')}
cancelLabel={t('common.cancel')}
saveLabel={t('common.save')}
onCancel={resetSessionRename}
onSave={handleSaveSessionRename}
/>
<RenameDraftDialog
open={!!workspaceRenameTarget}
inputId="workspace-name"
initialName={workspaceRenameTarget ? workspaceRenameTarget.title : ''}
title={t('dialog.renameWorkspace.title')}
nameLabel={t('field.name')}
placeholder={t('placeholder.workspaceName')}
cancelLabel={t('common.cancel')}
saveLabel={t('common.save')}
onCancel={resetWorkspaceRename}
onSave={handleSaveWorkspaceRename}
/>
<Dialog open={!!workspaceRenameTarget} onOpenChange={(open) => {
if (!open) {
resetWorkspaceRename();
}
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t('dialog.renameWorkspace.title')}</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor="workspace-name">{t('field.name')}</Label>
<Input
id="workspace-name"
value={workspaceRenameValue}
onChange={(e) => setWorkspaceRenameValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submitWorkspaceRename(); }}
autoFocus
placeholder={t('placeholder.workspaceName')}
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={resetWorkspaceRename}>{t('common.cancel')}</Button>
<Button onClick={submitWorkspaceRename} disabled={!workspaceRenameValue.trim()}>{t('common.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{isCreateWorkspaceOpen && (
<LazyLoadBoundary name="Create workspace" resetKey="create-workspace">
@@ -871,8 +917,7 @@ function AppViewInner({ domains }: AppViewProps) {
</DialogContent>
</Dialog>
</div>
);
}}
)}
</UnsavedChangesProvider>
</SnippetExecutionProvider>
);
+15 -6
View File
@@ -619,17 +619,26 @@ export const useSessionState = ({
});
}, []);
const submitWorkspaceRename = useCallback(() => {
const submitWorkspaceRename = useCallback((workspaceId?: string, name?: string) => {
if (workspaceId !== undefined && name !== undefined) {
const trimmed = name.trim();
if (!trimmed) return;
setWorkspaces(prev => prev.map(w => (
w.id === workspaceId ? { ...w, title: trimmed, autoTitle: false } : w
)));
return;
}
setWorkspaceRenameValue(prevValue => {
const name = prevValue.trim();
if (!name) return prevValue;
const trimmed = prevValue.trim();
if (!trimmed) return prevValue;
setWorkspaceRenameTarget(prevTarget => {
if (!prevTarget) return prevTarget;
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? { ...w, title: name, autoTitle: false } : w));
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? { ...w, title: trimmed, autoTitle: false } : w));
return null;
});
return '';
});
}, []);
+1 -1
View File
@@ -1841,7 +1841,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
// Opaque version that changes when any synced setting changes, used by useAutoSync.
// eslint-disable-next-line react-hooks/exhaustive-deps
settingsVersion: useMemo(() => Math.random(), [
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
theme, lightUiThemeId, darkUiThemeId, accentMode,
uiFontFamilyId, uiLanguage, customCSS,
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
+6 -3
View File
@@ -1375,9 +1375,12 @@ export const useVaultState = () => {
const updateHostLastConnected = useCallback((hostId: string) => {
setHosts((prev) => {
const next = prev.map((h) =>
h.id === hostId ? { ...h, lastConnectedAt: Date.now() } : h,
);
const idx = prev.findIndex((h) => h.id === hostId);
if (idx < 0) return prev;
const now = Date.now();
if (prev[idx].lastConnectedAt === now) return prev;
const next = prev.slice();
next[idx] = { ...prev[idx], lastConnectedAt: now };
const ver = ++hostsWriteVersion.current;
const encryptPromise = encryptHosts(next);
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
+53 -87
View File
@@ -1,29 +1,29 @@
import test from "node:test";
import assert from "node:assert/strict";
import { vaultViewAreEqual } from "./VaultView.tsx";
import { syncVaultViewMemoSection, vaultViewAreEqual } from "./VaultView.tsx";
const baseProps = {
hosts: [],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
snippetPackages: [],
notes: [],
noteGroups: [],
customGroups: [],
knownHosts: [],
connectionLogs: [],
sessions: [],
managedSources: [],
groupConfigs: {},
terminalThemeId: "default",
terminalFontSize: 14,
navigateToSection: null,
};
test("VaultView re-renders when an external section navigation request changes", () => {
const baseProps = {
hosts: [],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
snippetPackages: [],
notes: [],
noteGroups: [],
customGroups: [],
knownHosts: [],
connectionLogs: [],
sessions: [],
managedSources: [],
groupConfigs: {},
terminalThemeId: "default",
terminalFontSize: 14,
navigateToSection: null,
};
assert.equal(
vaultViewAreEqual(
baseProps as never,
@@ -34,26 +34,6 @@ test("VaultView re-renders when an external section navigation request changes",
});
test("VaultView memo does not depend on shellHistory prop identity", () => {
const baseProps = {
hosts: [],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
snippetPackages: [],
notes: [],
noteGroups: [],
customGroups: [],
knownHosts: [],
connectionLogs: [],
sessions: [],
managedSources: [],
groupConfigs: {},
terminalThemeId: "default",
terminalFontSize: 14,
navigateToSection: null,
};
// History-only updates must not be part of VaultView equality (store path).
assert.equal(
vaultViewAreEqual(baseProps as never, { ...baseProps } as never),
true,
@@ -65,26 +45,6 @@ test("VaultView memo does not depend on shellHistory prop identity", () => {
});
test("VaultView re-renders when proxy profiles change", () => {
const baseProps = {
hosts: [],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
snippetPackages: [],
notes: [],
noteGroups: [],
customGroups: [],
knownHosts: [],
connectionLogs: [],
sessions: [],
managedSources: [],
groupConfigs: {},
terminalThemeId: "default",
terminalFontSize: 14,
navigateToSection: null,
};
assert.equal(
vaultViewAreEqual(
baseProps as never,
@@ -105,38 +65,12 @@ test("VaultView re-renders when proxy profiles change", () => {
});
test("VaultView re-renders when host-key verification setting changes", () => {
const baseProps = {
hosts: [],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
snippetPackages: [],
notes: [],
noteGroups: [],
customGroups: [],
knownHosts: [],
connectionLogs: [],
sessions: [],
managedSources: [],
groupConfigs: {},
terminalThemeId: "default",
terminalFontSize: 14,
navigateToSection: null,
terminalSettings: {
verifyHostKeys: true,
keepaliveInterval: 30,
keepaliveCountMax: 10,
},
};
assert.equal(
vaultViewAreEqual(
baseProps as never,
{
...baseProps,
terminalSettings: {
...baseProps.terminalSettings,
verifyHostKeys: false,
},
} as never,
@@ -144,3 +78,35 @@ test("VaultView re-renders when host-key verification setting changes", () => {
false,
);
});
test("VaultView ignores connectionLogs identity when not on logs section", () => {
syncVaultViewMemoSection("hosts");
const nextLogs = [{ id: "log-1" }];
assert.equal(
vaultViewAreEqual(
baseProps as never,
{ ...baseProps, connectionLogs: nextLogs } as never,
),
true,
);
});
test("VaultView compares connectionLogs identity when on logs section", () => {
syncVaultViewMemoSection("logs");
const nextLogs = [{ id: "log-2" }];
assert.equal(
vaultViewAreEqual(
baseProps as never,
{ ...baseProps, connectionLogs: nextLogs } as never,
),
false,
);
assert.equal(
vaultViewAreEqual(
{ ...baseProps, connectionLogs: nextLogs } as never,
{ ...baseProps, connectionLogs: nextLogs } as never,
),
true,
);
syncVaultViewMemoSection("hosts");
});
+19 -2
View File
@@ -167,6 +167,16 @@ export type VaultSection =
| "knownhosts"
| "logs";
/** Memo section tracker: areEqual ignores connectionLogs unless on logs. */
let vaultViewMemoSection: VaultSection = "hosts";
/** Always stashed from areEqual so skipped updates stay available when opening logs. */
let vaultViewLatestConnectionLogs: ConnectionLog[] | null = null;
/** Test/helper: sync the section used by vaultViewAreEqual. */
export function syncVaultViewMemoSection(section: VaultSection): void {
vaultViewMemoSection = section;
}
const haveSameHostOrderResult = (previous: Host[], next: Host[]) => {
if (previous.length !== next.length) return false;
return next.every((host, index) => {
@@ -365,6 +375,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const hostsRef = useRef(hosts);
hostsRef.current = hosts;
const [currentSection, setCurrentSection] = useState<VaultSection>("hosts");
vaultViewMemoSection = currentSection;
const resolvedConnectionLogs = vaultViewLatestConnectionLogs ?? connectionLogs;
const [search, setSearch] = useState("");
const [selectedGroupPath, setSelectedGroupPath] = useState<string | null>(
null,
@@ -1414,7 +1426,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
Clock,
cn,
commitInlineGroupRename,
connectionLogs,
connectionLogs: resolvedConnectionLogs,
connectSelectedHosts,
ContextMenu,
ContextMenuContent,
@@ -1667,6 +1679,11 @@ export const vaultViewAreEqual = (
prev: VaultViewProps,
next: VaultViewProps,
): boolean => {
vaultViewLatestConnectionLogs = next.connectionLogs;
const connectionLogsEqual =
vaultViewMemoSection === "logs"
? prev.connectionLogs === next.connectionLogs
: true;
const isEqual =
prev.hosts === next.hosts &&
prev.keys === next.keys &&
@@ -1679,7 +1696,7 @@ export const vaultViewAreEqual = (
prev.customGroups === next.customGroups &&
prev.knownHosts === next.knownHosts &&
// shellHistory is not a VaultView prop; SnippetsManager reads shellHistoryStore.
prev.connectionLogs === next.connectionLogs &&
connectionLogsEqual &&
prev.sessionCount === next.sessionCount &&
prev.managedSources === next.managedSources &&
prev.groupConfigs === next.groupConfigs &&
@@ -300,6 +300,7 @@ function ensureTrayPanelWindow() {
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
spellcheck: false,
},
});
@@ -63,6 +63,7 @@ function createExternalWindowApi(ctx) {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
spellcheck: false,
webSecurity: true,
// Isolated session so users' browsing does not mix with main app state.
partition: "persist:netcatty-fallback-browser",
@@ -279,6 +280,7 @@ function createExternalWindowApi(ctx) {
nodeIntegration: false,
// Sandboxed because this window renders remote content and does not need a preload bridge.
sandbox: true,
spellcheck: false,
v8CacheOptions: V8_CACHE_OPTIONS,
},
},
@@ -143,6 +143,7 @@ function createMainWindowApi(ctx) {
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
spellcheck: false,
backgroundThrottling: false,
v8CacheOptions: V8_CACHE_OPTIONS,
},
@@ -156,6 +156,7 @@ function createSettingsWindowApi(ctx) {
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
spellcheck: false,
v8CacheOptions: V8_CACHE_OPTIONS,
},
});
@@ -96,6 +96,7 @@ function createTerminalPopupWindowApi(ctx) {
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
spellcheck: false,
backgroundThrottling: false,
v8CacheOptions: V8_CACHE_OPTIONS,
},
+31 -17
View File
@@ -185,11 +185,16 @@ const { queryDirtyEditors } = require("./bridges/dirtyEditorGuard.cjs");
if (process.env.NETCATTY_NO_SANDBOX === "1") {
app.commandLine.appendSwitch("no-sandbox");
}
// Force hardware acceleration even on blocklisted GPUs (macs sometimes fall back to software)
app.commandLine.appendSwitch("ignore-gpu-blocklist");
app.commandLine.appendSwitch("ignore-gpu-blacklist"); // Some Chromium builds use this alias; keep both for safety
app.commandLine.appendSwitch("enable-gpu-rasterization");
app.commandLine.appendSwitch("enable-zero-copy");
// Avoid Chromium spare renderers that inflate baseline memory for little gain.
app.commandLine.appendSwitch("disable-features", "SpareRendererForSitePerProcess");
// Aggressive GPU enablement can break some environments; opt out with NETCATTY_COMPAT_GPU=1.
if (process.env.NETCATTY_COMPAT_GPU !== "1") {
// Force hardware acceleration even on blocklisted GPUs (macs sometimes fall back to software)
app.commandLine.appendSwitch("ignore-gpu-blocklist");
app.commandLine.appendSwitch("ignore-gpu-blacklist"); // Some Chromium builds use this alias; keep both for safety
app.commandLine.appendSwitch("enable-gpu-rasterization");
app.commandLine.appendSwitch("enable-zero-copy");
}
// Silence noisy DevTools Autofill CDP errors (Electron's backend doesn't expose this domain)
app.on("web-contents-created", (_event, contents) => {
@@ -1021,6 +1026,13 @@ if (!gotLock) {
});
explorerContextMenuEnabled = initialExplorerContextMenuPreference.enabled === true;
// Spellcheck dictionaries/workers are unused in Netcatty and cost memory.
try {
session?.defaultSession?.setSpellCheckerEnabled?.(false);
} catch {
// ignore
}
// Grant only the Chromium permissions the app actually uses, and only
// to the app's own origin. The default session is shared with in-app
// OAuth pop-ups (accounts.google.com, login.microsoftonline.com, ...),
@@ -1133,18 +1145,20 @@ if (!gotLock) {
// startAutoCheck() is a no-op on unsupported platforms (Linux deb/rpm/snap).
getAutoUpdateBridge().startAutoCheck(5000);
// Pre-warm the settings window in the background so it opens instantly.
// Delay slightly to avoid competing with main window first-paint resources.
setTimeout(() => {
getWindowManager().prewarmSettingsWindow(electronModule, {
preload,
devServerUrl: effectiveDevServerUrl,
isDev,
appIcon: appIconManager.getAppIconPath(appPath),
isMac,
electronDir,
});
}, 3000);
// Settings prewarm is opt-in: a hidden BrowserWindow holds a full renderer.
// Enable with NETCATTY_PREWARM_SETTINGS=1 (delayed so first paint is undisturbed).
if (process.env.NETCATTY_PREWARM_SETTINGS === "1") {
setTimeout(() => {
getWindowManager().prewarmSettingsWindow(electronModule, {
preload,
devServerUrl: effectiveDevServerUrl,
isDev,
appIcon: appIconManager.getAppIconPath(appPath),
isMac,
electronDir,
});
}, 15000);
}
}).catch((err) => {
console.error("[Main] Failed to create main window:", err);
showStartupError(err);
@@ -67,6 +67,7 @@ function createIsolatedContributionIconRasterizer(options) {
webPreferences: {
partition,
sandbox: true,
spellcheck: false,
contextIsolation: true,
nodeIntegration: false,
webSecurity: true,
@@ -62,6 +62,7 @@ test("contribution icons rasterize in a disposable sandboxed network-denied wind
assert.equal(createdOptions.webPreferences.sandbox, true);
assert.equal(createdOptions.webPreferences.nodeIntegration, false);
assert.equal(createdOptions.webPreferences.contextIsolation, true);
assert.equal(createdOptions.webPreferences.spellcheck, false);
assert.deepEqual(requestFilter.filter.urls, ["http://*/*", "https://*/*", "file://*/*", "ftp://*/*"]);
let blocked;
requestFilter.listener({}, (decision) => { blocked = decision; });
+71
View File
@@ -0,0 +1,71 @@
const assert = require('node:assert/strict');
const { readFileSync } = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = __dirname;
function read(relativePath) {
return readFileSync(path.join(root, relativePath), 'utf8');
}
const WINDOW_SOURCES_WITH_SPELLCHECK = [
'bridges/windowManager/mainWindow.cjs',
'bridges/windowManager/settingsWindow.cjs',
'bridges/windowManager/terminalPopupWindow.cjs',
'bridges/globalShortcutBridge.cjs',
'bridges/windowManager/externalWindows.cjs',
'plugins/contributionIconRasterizer.cjs',
];
test('BrowserWindow webPreferences disable spellcheck for app windows', () => {
for (const relativePath of WINDOW_SOURCES_WITH_SPELLCHECK) {
const source = read(relativePath);
assert.match(
source,
/webPreferences:\s*\{[\s\S]*?spellcheck:\s*false/,
relativePath,
);
}
});
test('default session spell checker is disabled after ready', () => {
const source = read('main.cjs');
assert.match(source, /setSpellCheckerEnabled\?\.\(false\)/);
assert.match(source, /app\.whenReady\(\)\.then\(/);
});
test('SpareRendererForSitePerProcess is disabled via command-line switch', () => {
const source = read('main.cjs');
assert.match(
source,
/appendSwitch\(\s*["']disable-features["']\s*,\s*["']SpareRendererForSitePerProcess["']\s*\)/,
);
});
test('settings prewarm is off by default and only runs behind NETCATTY_PREWARM_SETTINGS=1', () => {
const source = read('main.cjs');
assert.match(source, /NETCATTY_PREWARM_SETTINGS\s*===\s*["']1["']/);
assert.match(source, /setTimeout\(\s*\(\)\s*=>\s*\{[\s\S]*?prewarmSettingsWindow[\s\S]*?\},\s*15000\s*\)/);
assert.doesNotMatch(source, /setTimeout\(\s*\(\)\s*=>\s*\{[\s\S]*?prewarmSettingsWindow[\s\S]*?\},\s*3000\s*\)/);
});
test('aggressive GPU switches are gated by NETCATTY_COMPAT_GPU and keep both ignore aliases', () => {
const source = read('main.cjs');
assert.match(source, /NETCATTY_COMPAT_GPU\s*!==\s*["']1["']/);
assert.match(source, /appendSwitch\(\s*["']ignore-gpu-blocklist["']\s*\)/);
assert.match(source, /appendSwitch\(\s*["']ignore-gpu-blacklist["']\s*\)/);
const gateIdx = source.indexOf('NETCATTY_COMPAT_GPU');
const blocklistIdx = source.indexOf('ignore-gpu-blocklist');
const blacklistIdx = source.indexOf('ignore-gpu-blacklist');
assert.ok(gateIdx >= 0 && blocklistIdx > gateIdx && blacklistIdx > gateIdx);
});
test('backgroundThrottling:false stays on terminal windows only, not settings', () => {
const main = read('bridges/windowManager/mainWindow.cjs');
const popup = read('bridges/windowManager/terminalPopupWindow.cjs');
const settings = read('bridges/windowManager/settingsWindow.cjs');
assert.match(main, /backgroundThrottling:\s*false/);
assert.match(popup, /backgroundThrottling:\s*false/);
assert.doesNotMatch(settings, /backgroundThrottling:\s*false/);
});