mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-12 01:00:54 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5308a573d | ||
|
|
d43885bdbc |
No files matched your search
@@ -291,15 +291,11 @@ function App({ settings }: { settings: SettingsState }) {
|
|||||||
draggingSessionId,
|
draggingSessionId,
|
||||||
setDraggingSessionId,
|
setDraggingSessionId,
|
||||||
sessionRenameTarget,
|
sessionRenameTarget,
|
||||||
sessionRenameValue,
|
|
||||||
setSessionRenameValue,
|
|
||||||
startSessionRename,
|
startSessionRename,
|
||||||
renameSessionInline,
|
renameSessionInline,
|
||||||
submitSessionRename,
|
submitSessionRename,
|
||||||
resetSessionRename,
|
resetSessionRename,
|
||||||
workspaceRenameTarget,
|
workspaceRenameTarget,
|
||||||
workspaceRenameValue,
|
|
||||||
setWorkspaceRenameValue,
|
|
||||||
startWorkspaceRename,
|
startWorkspaceRename,
|
||||||
submitWorkspaceRename,
|
submitWorkspaceRename,
|
||||||
resetWorkspaceRename,
|
resetWorkspaceRename,
|
||||||
@@ -1945,10 +1941,7 @@ function App({ settings }: { settings: SettingsState }) {
|
|||||||
resetWorkspaceRename,
|
resetWorkspaceRename,
|
||||||
resolvedTheme,
|
resolvedTheme,
|
||||||
sessionRenameTarget,
|
sessionRenameTarget,
|
||||||
sessionRenameValue,
|
|
||||||
setActiveTabId,
|
setActiveTabId,
|
||||||
setSessionRenameValue,
|
|
||||||
setWorkspaceRenameValue,
|
|
||||||
settings,
|
settings,
|
||||||
startSessionRename,
|
startSessionRename,
|
||||||
renameSessionInline,
|
renameSessionInline,
|
||||||
@@ -1960,8 +1953,7 @@ function App({ settings }: { settings: SettingsState }) {
|
|||||||
toggleConnectionLogSaved,
|
toggleConnectionLogSaved,
|
||||||
updateConnectionLog,
|
updateConnectionLog,
|
||||||
workspaceRenameTarget,
|
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]);
|
||||||
}), [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]);
|
|
||||||
|
|
||||||
const appDialogsDomain = useMemo(() => ({
|
const appDialogsDomain = useMemo(() => ({
|
||||||
addToWorkspaceDialog,
|
addToWorkspaceDialog,
|
||||||
|
|||||||
@@ -53,3 +53,8 @@ test('shared host tree forwards work-surface host management callbacks', () => {
|
|||||||
assert.match(hostTreeLayerSource, /onNewHost=\{onNewHost\}/);
|
assert.match(hostTreeLayerSource, /onNewHost=\{onNewHost\}/);
|
||||||
assert.match(hostTreeLayerSource, /onEditHost=\{onEditHost\}/);
|
assert.match(hostTreeLayerSource, /onEditHost=\{onEditHost\}/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shared host tree layer is memoized with a custom areEqual', () => {
|
||||||
|
assert.match(hostTreeLayerSource, /memo\(AppHostTreeLayerInner,\s*appHostTreeLayerAreEqual\)/);
|
||||||
|
});
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { memo, useMemo } from 'react';
|
||||||
|
|
||||||
import { useActiveTabId } from '../state/activeTabStore';
|
import { useActiveTabId } from '../state/activeTabStore';
|
||||||
import type { EditorTabChrome } from '../state/editorTabStore';
|
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,
|
enabled,
|
||||||
hosts,
|
hosts,
|
||||||
customGroups,
|
customGroups,
|
||||||
@@ -154,3 +180,6 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const AppHostTreeLayer = memo(AppHostTreeLayerInner, appHostTreeLayerAreEqual);
|
||||||
|
AppHostTreeLayer.displayName = 'AppHostTreeLayer';
|
||||||
+153
-108
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* 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 { AlertTriangle, Download, Trash2 } from 'lucide-react';
|
||||||
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
|
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
|
||||||
import { editorTabStore } from '../state/editorTabStore';
|
import { editorTabStore } from '../state/editorTabStore';
|
||||||
@@ -12,7 +12,7 @@ import { QuickScriptEditorDialog } from '../../components/scripts/QuickScriptEdi
|
|||||||
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
|
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
|
||||||
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
|
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
|
||||||
import { PassphraseModal } from '../../components/PassphraseModal';
|
import { PassphraseModal } from '../../components/PassphraseModal';
|
||||||
import { UnsavedChangesProvider } from '../../components/editor/UnsavedChangesDialog';
|
import { UnsavedChangesProvider, promptUnsavedChanges } from '../../components/editor/UnsavedChangesDialog';
|
||||||
import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider';
|
import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../components/ui/dialog';
|
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 = {
|
export type AppViewProps = {
|
||||||
domains: AppViewDomains;
|
domains: AppViewDomains;
|
||||||
};
|
};
|
||||||
@@ -127,14 +185,14 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||||
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||||
resolveEmptyVaultConflict, resolvedTheme, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
resolveEmptyVaultConflict, resolvedTheme, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||||
sessionRenameValue, sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
|
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
|
||||||
setNavigateToSection, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
||||||
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
setWorkspaceFocusedSession, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||||
sftpShowHiddenFiles, sftpUseCompressedUpload, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
|
sftpShowHiddenFiles, sftpUseCompressedUpload, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
|
||||||
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
|
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
|
||||||
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
|
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
|
||||||
readPersistedHosts, readPersistedManagedSources, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
|
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,
|
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
|
||||||
} = ctx;
|
} = ctx;
|
||||||
|
|
||||||
@@ -211,6 +269,68 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
pluginViewTabStore.close(tabId);
|
pluginViewTabStore.close(tabId);
|
||||||
}, [orderedTabsWithEditors]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const catalog = buildPluginSettingScopeCatalog({
|
const catalog = buildPluginSettingScopeCatalog({
|
||||||
hosts,
|
hosts,
|
||||||
@@ -224,54 +344,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
return (
|
return (
|
||||||
<SnippetExecutionProvider>
|
<SnippetExecutionProvider>
|
||||||
<UnsavedChangesProvider>
|
<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}>
|
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" data-terminal-appearance-root onContextMenu={handleRootContextMenu}>
|
||||||
<TopTabs
|
<TopTabs
|
||||||
theme={resolvedTheme}
|
theme={resolvedTheme}
|
||||||
@@ -715,59 +788,32 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
</LazyLoadBoundary>
|
</LazyLoadBoundary>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={!!sessionRenameTarget} onOpenChange={(open) => {
|
<RenameDraftDialog
|
||||||
if (!open) {
|
open={!!sessionRenameTarget}
|
||||||
resetSessionRename();
|
inputId="session-name"
|
||||||
}
|
initialName={sessionRenameTarget ? (sessionRenameTarget.customName || sessionRenameTarget.hostLabel) : ''}
|
||||||
}}>
|
title={t('dialog.renameSession.title')}
|
||||||
<DialogContent className="max-w-sm">
|
nameLabel={t('field.name')}
|
||||||
<DialogHeader>
|
placeholder={t('placeholder.sessionName')}
|
||||||
<DialogTitle>{t('dialog.renameSession.title')}</DialogTitle>
|
cancelLabel={t('common.cancel')}
|
||||||
</DialogHeader>
|
saveLabel={t('common.save')}
|
||||||
<div className="space-y-2 py-2">
|
onCancel={resetSessionRename}
|
||||||
<Label htmlFor="session-name">{t('field.name')}</Label>
|
onSave={handleSaveSessionRename}
|
||||||
<Input
|
/>
|
||||||
id="session-name"
|
|
||||||
value={sessionRenameValue}
|
<RenameDraftDialog
|
||||||
onChange={(e) => setSessionRenameValue(e.target.value)}
|
open={!!workspaceRenameTarget}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') submitSessionRename(); }}
|
inputId="workspace-name"
|
||||||
autoFocus
|
initialName={workspaceRenameTarget ? workspaceRenameTarget.title : ''}
|
||||||
placeholder={t('placeholder.sessionName')}
|
title={t('dialog.renameWorkspace.title')}
|
||||||
/>
|
nameLabel={t('field.name')}
|
||||||
</div>
|
placeholder={t('placeholder.workspaceName')}
|
||||||
<DialogFooter>
|
cancelLabel={t('common.cancel')}
|
||||||
<Button variant="ghost" onClick={resetSessionRename}>{t('common.cancel')}</Button>
|
saveLabel={t('common.save')}
|
||||||
<Button onClick={submitSessionRename} disabled={!sessionRenameValue.trim()}>{t('common.save')}</Button>
|
onCancel={resetWorkspaceRename}
|
||||||
</DialogFooter>
|
onSave={handleSaveWorkspaceRename}
|
||||||
</DialogContent>
|
/>
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<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 && (
|
{isCreateWorkspaceOpen && (
|
||||||
<LazyLoadBoundary name="Create workspace" resetKey="create-workspace">
|
<LazyLoadBoundary name="Create workspace" resetKey="create-workspace">
|
||||||
@@ -871,8 +917,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
}}
|
|
||||||
</UnsavedChangesProvider>
|
</UnsavedChangesProvider>
|
||||||
</SnippetExecutionProvider>
|
</SnippetExecutionProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 => {
|
setWorkspaceRenameValue(prevValue => {
|
||||||
const name = prevValue.trim();
|
const trimmed = prevValue.trim();
|
||||||
if (!name) return prevValue;
|
if (!trimmed) return prevValue;
|
||||||
|
|
||||||
setWorkspaceRenameTarget(prevTarget => {
|
setWorkspaceRenameTarget(prevTarget => {
|
||||||
if (!prevTarget) return 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 null;
|
||||||
});
|
});
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -1841,7 +1841,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
|||||||
// Opaque version that changes when any synced setting changes, used by useAutoSync.
|
// Opaque version that changes when any synced setting changes, used by useAutoSync.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
settingsVersion: useMemo(() => Math.random(), [
|
settingsVersion: useMemo(() => Math.random(), [
|
||||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
theme, lightUiThemeId, darkUiThemeId, accentMode,
|
||||||
uiFontFamilyId, uiLanguage, customCSS,
|
uiFontFamilyId, uiLanguage, customCSS,
|
||||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||||
customKeyBindings, editorWordWrap,
|
customKeyBindings, editorWordWrap,
|
||||||
|
|||||||
@@ -1375,9 +1375,12 @@ export const useVaultState = () => {
|
|||||||
|
|
||||||
const updateHostLastConnected = useCallback((hostId: string) => {
|
const updateHostLastConnected = useCallback((hostId: string) => {
|
||||||
setHosts((prev) => {
|
setHosts((prev) => {
|
||||||
const next = prev.map((h) =>
|
const idx = prev.findIndex((h) => h.id === hostId);
|
||||||
h.id === hostId ? { ...h, lastConnectedAt: Date.now() } : h,
|
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 ver = ++hostsWriteVersion.current;
|
||||||
const encryptPromise = encryptHosts(next);
|
const encryptPromise = encryptHosts(next);
|
||||||
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
|
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
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", () => {
|
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(
|
assert.equal(
|
||||||
vaultViewAreEqual(
|
vaultViewAreEqual(
|
||||||
baseProps as never,
|
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", () => {
|
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(
|
assert.equal(
|
||||||
vaultViewAreEqual(baseProps as never, { ...baseProps } as never),
|
vaultViewAreEqual(baseProps as never, { ...baseProps } as never),
|
||||||
true,
|
true,
|
||||||
@@ -65,26 +45,6 @@ test("VaultView memo does not depend on shellHistory prop identity", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("VaultView re-renders when proxy profiles change", () => {
|
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(
|
assert.equal(
|
||||||
vaultViewAreEqual(
|
vaultViewAreEqual(
|
||||||
baseProps as never,
|
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", () => {
|
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(
|
assert.equal(
|
||||||
vaultViewAreEqual(
|
vaultViewAreEqual(
|
||||||
baseProps as never,
|
baseProps as never,
|
||||||
{
|
{
|
||||||
...baseProps,
|
...baseProps,
|
||||||
terminalSettings: {
|
terminalSettings: {
|
||||||
...baseProps.terminalSettings,
|
|
||||||
verifyHostKeys: false,
|
verifyHostKeys: false,
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
@@ -144,3 +78,35 @@ test("VaultView re-renders when host-key verification setting changes", () => {
|
|||||||
false,
|
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");
|
||||||
|
});
|
||||||
@@ -167,6 +167,16 @@ export type VaultSection =
|
|||||||
| "knownhosts"
|
| "knownhosts"
|
||||||
| "logs";
|
| "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[]) => {
|
const haveSameHostOrderResult = (previous: Host[], next: Host[]) => {
|
||||||
if (previous.length !== next.length) return false;
|
if (previous.length !== next.length) return false;
|
||||||
return next.every((host, index) => {
|
return next.every((host, index) => {
|
||||||
@@ -365,6 +375,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
|||||||
const hostsRef = useRef(hosts);
|
const hostsRef = useRef(hosts);
|
||||||
hostsRef.current = hosts;
|
hostsRef.current = hosts;
|
||||||
const [currentSection, setCurrentSection] = useState<VaultSection>("hosts");
|
const [currentSection, setCurrentSection] = useState<VaultSection>("hosts");
|
||||||
|
vaultViewMemoSection = currentSection;
|
||||||
|
const resolvedConnectionLogs = vaultViewLatestConnectionLogs ?? connectionLogs;
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [selectedGroupPath, setSelectedGroupPath] = useState<string | null>(
|
const [selectedGroupPath, setSelectedGroupPath] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
@@ -1414,7 +1426,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
|||||||
Clock,
|
Clock,
|
||||||
cn,
|
cn,
|
||||||
commitInlineGroupRename,
|
commitInlineGroupRename,
|
||||||
connectionLogs,
|
connectionLogs: resolvedConnectionLogs,
|
||||||
connectSelectedHosts,
|
connectSelectedHosts,
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
@@ -1667,6 +1679,11 @@ export const vaultViewAreEqual = (
|
|||||||
prev: VaultViewProps,
|
prev: VaultViewProps,
|
||||||
next: VaultViewProps,
|
next: VaultViewProps,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
|
vaultViewLatestConnectionLogs = next.connectionLogs;
|
||||||
|
const connectionLogsEqual =
|
||||||
|
vaultViewMemoSection === "logs"
|
||||||
|
? prev.connectionLogs === next.connectionLogs
|
||||||
|
: true;
|
||||||
const isEqual =
|
const isEqual =
|
||||||
prev.hosts === next.hosts &&
|
prev.hosts === next.hosts &&
|
||||||
prev.keys === next.keys &&
|
prev.keys === next.keys &&
|
||||||
@@ -1679,7 +1696,7 @@ export const vaultViewAreEqual = (
|
|||||||
prev.customGroups === next.customGroups &&
|
prev.customGroups === next.customGroups &&
|
||||||
prev.knownHosts === next.knownHosts &&
|
prev.knownHosts === next.knownHosts &&
|
||||||
// shellHistory is not a VaultView prop; SnippetsManager reads shellHistoryStore.
|
// shellHistory is not a VaultView prop; SnippetsManager reads shellHistoryStore.
|
||||||
prev.connectionLogs === next.connectionLogs &&
|
connectionLogsEqual &&
|
||||||
prev.sessionCount === next.sessionCount &&
|
prev.sessionCount === next.sessionCount &&
|
||||||
prev.managedSources === next.managedSources &&
|
prev.managedSources === next.managedSources &&
|
||||||
prev.groupConfigs === next.groupConfigs &&
|
prev.groupConfigs === next.groupConfigs &&
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ function ensureTrayPanelWindow() {
|
|||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
sandbox: false,
|
sandbox: false,
|
||||||
|
spellcheck: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ function createExternalWindowApi(ctx) {
|
|||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
|
spellcheck: false,
|
||||||
webSecurity: true,
|
webSecurity: true,
|
||||||
// Isolated session so users' browsing does not mix with main app state.
|
// Isolated session so users' browsing does not mix with main app state.
|
||||||
partition: "persist:netcatty-fallback-browser",
|
partition: "persist:netcatty-fallback-browser",
|
||||||
@@ -279,6 +280,7 @@ function createExternalWindowApi(ctx) {
|
|||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
// Sandboxed because this window renders remote content and does not need a preload bridge.
|
// Sandboxed because this window renders remote content and does not need a preload bridge.
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
|
spellcheck: false,
|
||||||
v8CacheOptions: V8_CACHE_OPTIONS,
|
v8CacheOptions: V8_CACHE_OPTIONS,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ function createMainWindowApi(ctx) {
|
|||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
sandbox: false,
|
sandbox: false,
|
||||||
|
spellcheck: false,
|
||||||
backgroundThrottling: false,
|
backgroundThrottling: false,
|
||||||
v8CacheOptions: V8_CACHE_OPTIONS,
|
v8CacheOptions: V8_CACHE_OPTIONS,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ function createSettingsWindowApi(ctx) {
|
|||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
sandbox: false,
|
sandbox: false,
|
||||||
|
spellcheck: false,
|
||||||
v8CacheOptions: V8_CACHE_OPTIONS,
|
v8CacheOptions: V8_CACHE_OPTIONS,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ function createTerminalPopupWindowApi(ctx) {
|
|||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
sandbox: false,
|
sandbox: false,
|
||||||
|
spellcheck: false,
|
||||||
backgroundThrottling: false,
|
backgroundThrottling: false,
|
||||||
v8CacheOptions: V8_CACHE_OPTIONS,
|
v8CacheOptions: V8_CACHE_OPTIONS,
|
||||||
},
|
},
|
||||||
|
|||||||
+31
-17
@@ -185,11 +185,16 @@ const { queryDirtyEditors } = require("./bridges/dirtyEditorGuard.cjs");
|
|||||||
if (process.env.NETCATTY_NO_SANDBOX === "1") {
|
if (process.env.NETCATTY_NO_SANDBOX === "1") {
|
||||||
app.commandLine.appendSwitch("no-sandbox");
|
app.commandLine.appendSwitch("no-sandbox");
|
||||||
}
|
}
|
||||||
// Force hardware acceleration even on blocklisted GPUs (macs sometimes fall back to software)
|
// Avoid Chromium spare renderers that inflate baseline memory for little gain.
|
||||||
app.commandLine.appendSwitch("ignore-gpu-blocklist");
|
app.commandLine.appendSwitch("disable-features", "SpareRendererForSitePerProcess");
|
||||||
app.commandLine.appendSwitch("ignore-gpu-blacklist"); // Some Chromium builds use this alias; keep both for safety
|
// Aggressive GPU enablement can break some environments; opt out with NETCATTY_COMPAT_GPU=1.
|
||||||
app.commandLine.appendSwitch("enable-gpu-rasterization");
|
if (process.env.NETCATTY_COMPAT_GPU !== "1") {
|
||||||
app.commandLine.appendSwitch("enable-zero-copy");
|
// 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)
|
// Silence noisy DevTools Autofill CDP errors (Electron's backend doesn't expose this domain)
|
||||||
app.on("web-contents-created", (_event, contents) => {
|
app.on("web-contents-created", (_event, contents) => {
|
||||||
@@ -1021,6 +1026,13 @@ if (!gotLock) {
|
|||||||
});
|
});
|
||||||
explorerContextMenuEnabled = initialExplorerContextMenuPreference.enabled === true;
|
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
|
// 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
|
// to the app's own origin. The default session is shared with in-app
|
||||||
// OAuth pop-ups (accounts.google.com, login.microsoftonline.com, ...),
|
// 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).
|
// startAutoCheck() is a no-op on unsupported platforms (Linux deb/rpm/snap).
|
||||||
getAutoUpdateBridge().startAutoCheck(5000);
|
getAutoUpdateBridge().startAutoCheck(5000);
|
||||||
|
|
||||||
// Pre-warm the settings window in the background so it opens instantly.
|
// Settings prewarm is opt-in: a hidden BrowserWindow holds a full renderer.
|
||||||
// Delay slightly to avoid competing with main window first-paint resources.
|
// Enable with NETCATTY_PREWARM_SETTINGS=1 (delayed so first paint is undisturbed).
|
||||||
setTimeout(() => {
|
if (process.env.NETCATTY_PREWARM_SETTINGS === "1") {
|
||||||
getWindowManager().prewarmSettingsWindow(electronModule, {
|
setTimeout(() => {
|
||||||
preload,
|
getWindowManager().prewarmSettingsWindow(electronModule, {
|
||||||
devServerUrl: effectiveDevServerUrl,
|
preload,
|
||||||
isDev,
|
devServerUrl: effectiveDevServerUrl,
|
||||||
appIcon: appIconManager.getAppIconPath(appPath),
|
isDev,
|
||||||
isMac,
|
appIcon: appIconManager.getAppIconPath(appPath),
|
||||||
electronDir,
|
isMac,
|
||||||
});
|
electronDir,
|
||||||
}, 3000);
|
});
|
||||||
|
}, 15000);
|
||||||
|
}
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
console.error("[Main] Failed to create main window:", err);
|
console.error("[Main] Failed to create main window:", err);
|
||||||
showStartupError(err);
|
showStartupError(err);
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ function createIsolatedContributionIconRasterizer(options) {
|
|||||||
webPreferences: {
|
webPreferences: {
|
||||||
partition,
|
partition,
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
|
spellcheck: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
webSecurity: true,
|
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.sandbox, true);
|
||||||
assert.equal(createdOptions.webPreferences.nodeIntegration, false);
|
assert.equal(createdOptions.webPreferences.nodeIntegration, false);
|
||||||
assert.equal(createdOptions.webPreferences.contextIsolation, true);
|
assert.equal(createdOptions.webPreferences.contextIsolation, true);
|
||||||
|
assert.equal(createdOptions.webPreferences.spellcheck, false);
|
||||||
assert.deepEqual(requestFilter.filter.urls, ["http://*/*", "https://*/*", "file://*/*", "ftp://*/*"]);
|
assert.deepEqual(requestFilter.filter.urls, ["http://*/*", "https://*/*", "file://*/*", "ftp://*/*"]);
|
||||||
let blocked;
|
let blocked;
|
||||||
requestFilter.listener({}, (decision) => { blocked = decision; });
|
requestFilter.listener({}, (decision) => { blocked = decision; });
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user