mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5308a573d | ||
|
|
d43885bdbc |
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 '';
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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
@@ -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; });
|
||||
|
||||
@@ -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