mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 08:31:00 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e661169b60 | ||
|
|
95f39f11b7 | ||
|
|
642905043c | ||
|
|
3943b4869f | ||
|
|
ee8687af15 | ||
|
|
bf723e7348 | ||
|
|
15baefaa57 | ||
|
|
f0f858447e | ||
|
|
83bbf9c73d | ||
|
|
7ee5888795 | ||
|
|
1349df9c0c | ||
|
|
4259be6f57 | ||
|
|
24665eb532 | ||
|
|
7faa0db866 | ||
|
|
56a86b48bf | ||
|
|
53328ca50d | ||
|
|
a0abcc3131 | ||
|
|
8083aece66 | ||
|
|
6893b986ca | ||
|
|
599c8ff200 | ||
|
|
cd6578ba25 | ||
|
|
12835ab0e5 | ||
|
|
0913c6188f | ||
|
|
9ef868f7ed | ||
|
|
20b613d1bb | ||
|
|
12d7d6c730 | ||
|
|
07e837b21c | ||
|
|
825bd722d1 | ||
|
|
51f5fd9088 | ||
|
|
d9ed716b01 | ||
|
|
c1ebedcc1f | ||
|
|
7c707bf3b0 | ||
|
|
59f70af0c3 | ||
|
|
a15f677727 | ||
|
|
2cfbf235f7 | ||
|
|
3446749573 | ||
|
|
b709417e23 | ||
|
|
d79353942b | ||
|
|
0ceec3d45c | ||
|
|
fc287f9038 | ||
|
|
d071827a1e | ||
|
|
6b114f55eb | ||
|
|
3235f11870 | ||
|
|
6fad3f0b76 | ||
|
|
278f528deb | ||
|
|
b6da5e60c9 | ||
|
|
355488f4d0 | ||
|
|
61a2473a7c | ||
|
|
5e6fc5f721 | ||
|
|
b17835bf58 | ||
|
|
977c5c56e1 | ||
|
|
06ec9d6b1d | ||
|
|
a7c35f2813 | ||
|
|
0c01238a39 | ||
|
|
46d730f2fa | ||
|
|
c7de55007b | ||
|
|
4caa37c10d | ||
|
|
6feef21841 | ||
|
|
99003f9716 | ||
|
|
9180e6abb6 | ||
|
|
8b5135b008 | ||
|
|
306a91d1e3 | ||
|
|
4fa3eff976 | ||
|
|
db89462e00 | ||
|
|
f26c057128 | ||
|
|
7ca3974bf8 | ||
|
|
58bef9344a | ||
|
|
12a513606b | ||
|
|
89da4674cd | ||
|
|
3a540a5730 | ||
|
|
bd3491a3e4 | ||
|
|
8322a2aeb8 | ||
|
|
7ac21c3c33 | ||
|
|
5cd6149981 | ||
|
|
947cca5199 | ||
|
|
4108538dbb | ||
|
|
3beb909a09 | ||
|
|
4b874410f9 | ||
|
|
f237cfb4ab | ||
|
|
aa37b353f7 | ||
|
|
3d134abe94 | ||
|
|
218bf2183a | ||
|
|
a7cc2cbee3 | ||
|
|
c79f485270 | ||
|
|
8424471ff6 | ||
|
|
46262d18f3 | ||
|
|
6554f7bc8f | ||
|
|
010e2f356e | ||
|
|
d184819b61 | ||
|
|
f53d359d38 | ||
|
|
c6bdcffb89 | ||
|
|
369251099f | ||
|
|
65c3df2ff4 | ||
|
|
44a84b5046 | ||
|
|
0ea8269a78 | ||
|
|
d6b00df333 | ||
|
|
8e165a7101 | ||
|
|
8deb52842b | ||
|
|
a82e69865f | ||
|
|
6e4a1cf2a2 | ||
|
|
194691038c | ||
|
|
28762b3a73 | ||
|
|
a493fbfea8 | ||
|
|
6304608114 | ||
|
|
e4c7484181 | ||
|
|
11d5c269d1 | ||
|
|
8741d683ba | ||
|
|
affe197f26 | ||
|
|
40dd3d1a6a | ||
|
|
9b397053ca | ||
|
|
0380eef924 | ||
|
|
475e896cf0 | ||
|
|
256dbca4ba | ||
|
|
541e627702 | ||
|
|
96f0a9e559 | ||
|
|
dea182c38a | ||
|
|
77856252cf | ||
|
|
cff74b6e49 | ||
|
|
94464da004 | ||
|
|
055fa4bb97 | ||
|
|
d8ac9b081b | ||
|
|
b74b5a442a | ||
|
|
6f074d620e | ||
|
|
e2f8ca9c00 | ||
|
|
d1cb3e100b | ||
|
|
1e939c9b31 | ||
|
|
9bf846978d | ||
|
|
e897a3a30a | ||
|
|
a238a5ac2f | ||
|
|
b2c02559f1 | ||
|
|
f95cf73ca4 | ||
|
|
018067032e | ||
|
|
1d07879c11 | ||
|
|
eb1a39dbc2 | ||
|
|
982569532c | ||
|
|
554d1991df | ||
|
|
feb6f60db2 | ||
|
|
96101c0c73 | ||
|
|
fe297dbc55 | ||
|
|
e3580a45e5 | ||
|
|
df359f6a9a | ||
|
|
7bdad5e701 | ||
|
|
39f8bf194a | ||
|
|
36295e63ed | ||
|
|
257cda56e3 | ||
|
|
7587090bcd | ||
|
|
eba27d94b6 | ||
|
|
3289c0deca | ||
|
|
d1de0d39e8 | ||
|
|
25a2141bb3 | ||
|
|
b9c164aeb6 | ||
|
|
5afdbdf8fc | ||
|
|
14da682578 | ||
|
|
7008262762 | ||
|
|
7c2e1ef238 | ||
|
|
8eb3ee44b4 | ||
|
|
0b5c48282a | ||
|
|
45a757c0e0 | ||
|
|
b480eabb3c | ||
|
|
7477f5c47a | ||
|
|
4364522bf1 | ||
|
|
4d3fa2d225 | ||
|
|
cde2fa716c | ||
|
|
43063e1744 | ||
|
|
5b83b47845 | ||
|
|
a815e7d2e5 | ||
|
|
9674cd8ae6 | ||
|
|
d9334a8e73 | ||
|
|
ba9d9c1e58 | ||
|
|
73e615cb64 | ||
|
|
93079bb576 | ||
|
|
52bc48f73a | ||
|
|
46755465f9 | ||
|
|
04906a80ff | ||
|
|
193b2b73c9 | ||
|
|
b32310b073 | ||
|
|
5a27dbe955 | ||
|
|
2cbcebf45a | ||
|
|
644d55a0c0 | ||
|
|
1bb20026a2 | ||
|
|
932dd4f191 | ||
|
|
ecadc1fc2d | ||
|
|
79ccf47655 | ||
|
|
6ef0a4ad6b | ||
|
|
88142d2a92 | ||
|
|
f5c3302329 | ||
|
|
bb02f8e162 | ||
|
|
d57dd664a2 | ||
|
|
74ec6678bb | ||
|
|
b08ddc73b4 | ||
|
|
dccd872122 | ||
|
|
1e568011f8 | ||
|
|
2318eca54e | ||
|
|
ed8a04c84e | ||
|
|
3d669fffed |
No files matched your search
@@ -27,6 +27,11 @@ jobs:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install shell test dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y fish
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ coverage
|
||||
|
||||
# AI / Superpowers generated docs (local only)
|
||||
/docs/superpowers/
|
||||
/.superpowers/
|
||||
|
||||
# Dev-only electron-updater test config (not for production)
|
||||
/dev-app-update.yml
|
||||
|
||||
@@ -24,9 +24,11 @@ import { matchesKeyBinding } from './domain/models';
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
|
||||
import { upsertKnownHost } from './domain/knownHosts';
|
||||
import { materializeHostProxyProfile } from './domain/proxyProfiles';
|
||||
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from './domain/sshDeepLink';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
|
||||
import {
|
||||
getFollowAppTerminalThemeSelectionUpdate,
|
||||
mergeTerminalHostUpdate,
|
||||
type TerminalHostUpdate,
|
||||
} from './domain/terminalAppearance';
|
||||
@@ -36,7 +38,7 @@ import { resolveCloseIntent } from './application/state/resolveCloseIntent';
|
||||
import { resolveSnippetsShortcutIntent } from './application/state/resolveSnippetsShortcutIntent';
|
||||
import { resolveWindowCommandCloseIntent } from './application/state/windowCommandClose';
|
||||
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from './application/state/customThemeStore';
|
||||
import { customThemeStore, useCustomThemes } from './application/state/customThemeStore';
|
||||
import type { SyncPayload } from './domain/sync';
|
||||
import { applySyncPayload, buildLocalVaultPayload, hasMeaningfulSyncData } from './application/syncPayload';
|
||||
import {
|
||||
@@ -57,6 +59,7 @@ import { VaultSection } from './components/VaultView';
|
||||
import { KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
|
||||
import { PassphraseRequest } from './components/PassphraseModal';
|
||||
import { classifyLocalShellType } from './lib/localShell';
|
||||
import { getHostSearchMatch } from './lib/searchMatcher';
|
||||
import { useDiscoveredShells, resolveShellSetting } from './lib/useDiscoveredShells';
|
||||
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession } from './types';
|
||||
import { resolveSnippetCommand } from './components/SnippetExecutionProvider';
|
||||
@@ -100,11 +103,13 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null);
|
||||
// Navigation state for VaultView sections
|
||||
const [navigateToSection, setNavigateToSection] = useState<VaultSection | null>(null);
|
||||
const [deepLinkHostDraft, setDeepLinkHostDraft] = useState<Host | null>(null);
|
||||
// Keyboard-interactive authentication queue (2FA/MFA) - queue-based to handle multiple concurrent sessions
|
||||
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
|
||||
// Passphrase request queue for encrypted SSH keys
|
||||
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
|
||||
const [pendingNewWindowSession, setPendingNewWindowSession] = useState<OpenSessionInNewWindowPayload | null>(null);
|
||||
const isPeerSessionWindow = typeof window !== 'undefined' && window.location.hash.startsWith('#/session-window');
|
||||
|
||||
const {
|
||||
theme,
|
||||
@@ -114,6 +119,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
customAccent,
|
||||
terminalThemeId,
|
||||
setTerminalThemeId,
|
||||
setTerminalThemeDarkId,
|
||||
setTerminalThemeLightId,
|
||||
followAppTerminalTheme,
|
||||
currentTerminalTheme,
|
||||
terminalFontFamilyId,
|
||||
@@ -163,6 +170,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
notes,
|
||||
noteGroups,
|
||||
knownHosts,
|
||||
shellHistory,
|
||||
connectionLogs,
|
||||
@@ -174,6 +183,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
updateProxyProfiles,
|
||||
updateSnippets,
|
||||
updateSnippetPackages,
|
||||
updateNotes,
|
||||
updateNoteGroups,
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
@@ -216,6 +227,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessionRenameValue,
|
||||
setSessionRenameValue,
|
||||
startSessionRename,
|
||||
renameSessionInline,
|
||||
submitSessionRename,
|
||||
resetSessionRename,
|
||||
workspaceRenameTarget,
|
||||
@@ -235,6 +247,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
removeSessionFromWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendLocalTerminalToWorkspace,
|
||||
createWorkspaceFromTargets,
|
||||
@@ -256,7 +269,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
closeLogView,
|
||||
copySession,
|
||||
createSessionFromCloneSource,
|
||||
} = useSessionState();
|
||||
updateSessionRestoreCwd,
|
||||
updateSessionDynamicTitle,
|
||||
updateSessionCodingCliProvider,
|
||||
} = useSessionState({ persistSessionRestore: !isPeerSessionWindow });
|
||||
|
||||
const handleRunSnippet = useCallback(
|
||||
async (snippet: Snippet, targetHosts: Host[]) => {
|
||||
@@ -304,7 +320,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
if (!payload?.sourceSession) return;
|
||||
setPendingNewWindowSession(payload);
|
||||
});
|
||||
}, []);
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVaultInitialized || !pendingNewWindowSession?.sourceSession) return;
|
||||
@@ -353,6 +369,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
notes,
|
||||
noteGroups,
|
||||
knownHosts: getEffectiveKnownHosts(knownHosts),
|
||||
groupConfigs,
|
||||
},
|
||||
@@ -366,6 +384,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
keys,
|
||||
proxyProfiles,
|
||||
knownHosts,
|
||||
noteGroups,
|
||||
notes,
|
||||
portForwardingRulesForSync,
|
||||
snippetPackages,
|
||||
snippets,
|
||||
@@ -401,7 +421,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// hydrated enough to be backed up (or the user genuinely stays empty,
|
||||
// in which case the effect continues to no-op).
|
||||
useEffect(() => {
|
||||
if (!isVaultInitialized || versionBackupAttemptedRef.current) return;
|
||||
if (isPeerSessionWindow || !isVaultInitialized || versionBackupAttemptedRef.current) return;
|
||||
const payload = buildCurrentSyncPayloadRef.current();
|
||||
if (!hasMeaningfulSyncData(payload)) return;
|
||||
versionBackupAttemptedRef.current = true;
|
||||
@@ -425,7 +445,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isVaultInitialized, hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, knownHosts]);
|
||||
}, [isPeerSessionWindow, isVaultInitialized, hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, notes, noteGroups, knownHosts]);
|
||||
|
||||
// Memoized "apply a remote payload safely" callback. Stable identity
|
||||
// across renders so useAutoSync's `syncNow` useCallback doesn't rebuild
|
||||
@@ -455,6 +475,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
// Auto-sync hook for cloud sync
|
||||
const { syncNow: handleSyncNow, emptyVaultConflict, resolveEmptyVaultConflict } = useAutoSync({
|
||||
enabled: !isPeerSessionWindow,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
@@ -462,6 +483,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
notes,
|
||||
noteGroups,
|
||||
portForwardingRules: portForwardingRulesForSync,
|
||||
groupConfigs,
|
||||
settingsVersion: settings.settingsVersion,
|
||||
@@ -481,6 +504,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
// Update check hook - checks for new versions on startup
|
||||
const { updateState, dismissUpdate, installUpdate } = useUpdateCheck({
|
||||
enabled: !isPeerSessionWindow,
|
||||
// Install blocked because an editor has unsaved changes (#1215). The main
|
||||
// process broadcasts this; show an actionable toast telling the user to save
|
||||
// and click "Restart Now" again.
|
||||
@@ -495,9 +519,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleGlobalHotkeyKeyDownImpl(() => ({ HOTKEY_DEBUG, closeTabKeyStr, e, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding }), e); });
|
||||
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen }), e); });
|
||||
|
||||
useAppStartupEffects({ dismissUpdate, groupConfigs, hosts, identities, installUpdate, isVaultInitialized, keys, openSettingsWindow, portForwardingRules, proxyProfiles, sessions, setKeyboardInteractiveQueue, t, terminalSettings, updateState, workspaces });
|
||||
useAppStartupEffects({ dismissUpdate, enabled: !isPeerSessionWindow, groupConfigs, hosts, identities, installUpdate, isVaultInitialized, keys, openSettingsWindow, portForwardingRules, proxyProfiles, sessions, setKeyboardInteractiveQueue, t, terminalSettings, updateState, workspaces });
|
||||
|
||||
useEffect(() => {
|
||||
if (isPeerSessionWindow) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onTrayFocusSession || !bridge?.onTrayTogglePortForward) return;
|
||||
|
||||
@@ -512,9 +537,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
unsubscribeFocus?.();
|
||||
unsubscribeToggle?.();
|
||||
};
|
||||
}, []);
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPeerSessionWindow) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onTrayPanelJumpToSession || !bridge?.onTrayPanelConnectToHost) return;
|
||||
|
||||
@@ -528,7 +554,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
unsubscribeJump?.();
|
||||
unsubscribeConnect?.();
|
||||
};
|
||||
}, []);
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
// Handle keyboard-interactive submit
|
||||
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, updateHosts }), requestId, responses, savePassword); }, [keyboardInteractiveQueue, sessions, hosts, updateHosts]);
|
||||
@@ -669,6 +695,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const toggleScriptsSidePanelRef = useRef<(() => void) | null>(null);
|
||||
const toggleSidePanelRef = useRef<(() => void) | null>(null);
|
||||
const openNoteRequestIdRef = useRef(0);
|
||||
const [openNoteRequest, setOpenNoteRequest] = useState<{
|
||||
tabId: string;
|
||||
noteId: string;
|
||||
requestId: number;
|
||||
} | null>(null);
|
||||
// Populated below so the hotkey dispatcher can open the Settings window
|
||||
// even though `handleOpenSettings` is declared further down in the file.
|
||||
const handleOpenSettingsRef = useRef<() => void>(() => {});
|
||||
@@ -728,7 +760,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
);
|
||||
|
||||
// Shared hotkey action handler - used by both global handler and terminal callback
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => { return executeHotkeyActionImpl(() => ({ IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, action, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, e, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces }), action, e); }, [orderedTabs, editorTabs, sessions, workspaces, isQuickSwitcherOpen, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings, confirmIfBusyLocalTerminal]);
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => { return executeHotkeyActionImpl(() => ({ IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, action, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, e, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, workspaces }), action, e); }, [orderedTabs, editorTabs, sessions, workspaces, isQuickSwitcherOpen, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, toggleWorkspaceViewMode, settings, confirmIfBusyLocalTerminal]);
|
||||
|
||||
const handleWindowCommandCloseRequest = useCallback(async () => {
|
||||
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
|
||||
@@ -794,15 +826,18 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const quickResults = useMemo(() => {
|
||||
if (!isQuickSwitcherOpen) return [];
|
||||
const term = quickSearch.trim().toLowerCase();
|
||||
const filtered = term
|
||||
? hosts.filter(h =>
|
||||
h.label.toLowerCase().includes(term) ||
|
||||
h.hostname.toLowerCase().includes(term) ||
|
||||
(h.group || '').toLowerCase().includes(term)
|
||||
)
|
||||
: hosts;
|
||||
return filtered;
|
||||
const term = quickSearch.trim();
|
||||
if (!term) return hosts;
|
||||
return hosts
|
||||
.map((host) => ({ host, match: getHostSearchMatch(term, host) }))
|
||||
.filter((entry) => entry.match.matched)
|
||||
.sort((left, right) => {
|
||||
if (left.match.score !== right.match.score) {
|
||||
return right.match.score - left.match.score;
|
||||
}
|
||||
return left.host.label.localeCompare(right.host.label);
|
||||
})
|
||||
.map((entry) => entry.host);
|
||||
}, [quickSearch, hosts, isQuickSwitcherOpen]);
|
||||
|
||||
const handleDeleteHost = useCallback((hostId: string) => {
|
||||
@@ -864,6 +899,59 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host) => { return handleConnectToHostImpl(() => ({ addConnectionLog, connectToHost, host, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef }), host); }, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
|
||||
|
||||
const _handleSshDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const rawUrl = payload?.url || '';
|
||||
const target = parseSshDeepLink(rawUrl);
|
||||
if (!target) {
|
||||
toast.warning(t('deepLink.ssh.invalid'));
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveHosts = hosts.map((host) => {
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
|
||||
return {
|
||||
...effectiveHost,
|
||||
username: resolvedAuth.username || effectiveHost.username,
|
||||
};
|
||||
});
|
||||
const matchedEffectiveHost = findSshDeepLinkHost(effectiveHosts, target);
|
||||
if (matchedEffectiveHost) {
|
||||
const originalHost = hosts.find((host) => host.id === matchedEffectiveHost.id) ?? matchedEffectiveHost;
|
||||
handleConnectToHost(buildSshDeepLinkConnectionHost(originalHost));
|
||||
return;
|
||||
}
|
||||
|
||||
setDeepLinkHostDraft(buildSshDeepLinkHostDraft(target, {
|
||||
id: crypto.randomUUID(),
|
||||
now: Date.now(),
|
||||
}));
|
||||
setNavigateToSection('hosts');
|
||||
setActiveTabId('vault');
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isPeerSessionWindow) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onSshDeepLink) return;
|
||||
return bridge.onSshDeepLink((payload) => {
|
||||
_handleSshDeepLink(payload);
|
||||
});
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const handleOpenHostFromVaultNote = useCallback((host: Host, source?: { noteId?: string }) => {
|
||||
const tabId = handleConnectToHost(host);
|
||||
if (source?.noteId && typeof tabId === 'string' && tabId) {
|
||||
openNoteRequestIdRef.current += 1;
|
||||
setOpenNoteRequest({
|
||||
tabId,
|
||||
noteId: source.noteId,
|
||||
requestId: openNoteRequestIdRef.current,
|
||||
});
|
||||
}
|
||||
return tabId;
|
||||
}, [handleConnectToHost]);
|
||||
|
||||
// Wrap updateSessionStatus to track lastConnectedAt on successful connection
|
||||
const handleSessionStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
|
||||
updateSessionStatus(sessionId, status);
|
||||
@@ -914,6 +1002,19 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const handleToggleTheme = useCallback(() => { return handleToggleThemeImpl(() => ({ openSettingsWindow, resolvedTheme, setTheme, t, theme, toast })); }, [openSettingsWindow, resolvedTheme, setTheme, t, theme]);
|
||||
|
||||
const handleFollowAppTerminalThemeChange = useCallback((themeId: string) => {
|
||||
const selectedTheme = customThemeStore.getThemeById(themeId);
|
||||
if (!selectedTheme) return;
|
||||
const update = getFollowAppTerminalThemeSelectionUpdate(selectedTheme);
|
||||
if (update.terminalThemeDarkId) {
|
||||
setTerminalThemeDarkId(update.terminalThemeDarkId);
|
||||
}
|
||||
if (update.terminalThemeLightId) {
|
||||
setTerminalThemeLightId(update.terminalThemeLightId);
|
||||
}
|
||||
setTheme(update.appTheme);
|
||||
}, [setTerminalThemeDarkId, setTerminalThemeLightId, setTheme]);
|
||||
|
||||
const handleOpenQuickSwitcher = useCallback(() => {
|
||||
setIsQuickSwitcherOpen(true);
|
||||
}, []);
|
||||
@@ -988,13 +1089,17 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
logViews={logViews}
|
||||
t={t}
|
||||
/>
|
||||
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
|
||||
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleOpenHostFromVaultNote, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AppWithProviders() {
|
||||
const settings = useSettingsState();
|
||||
const isPeerSessionWindow = typeof window !== 'undefined' && window.location.hash.startsWith('#/session-window');
|
||||
const settings = useSettingsState({
|
||||
enableSettingsSync: !isPeerSessionWindow,
|
||||
enableSystemEffects: !isPeerSessionWindow,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { handleConnectToHostImpl } from './app/AppHandlers.ts';
|
||||
import type { Host } from '../types';
|
||||
|
||||
const baseHost: Host = {
|
||||
id: 'host-1',
|
||||
label: '10.2.0.32',
|
||||
hostname: '10.2.0.32',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
protocol: 'ssh',
|
||||
};
|
||||
|
||||
test('connect host handler returns the created terminal tab id', () => {
|
||||
const logs: unknown[] = [];
|
||||
const connectedHosts: Host[] = [];
|
||||
const result = handleConnectToHostImpl(
|
||||
() => ({
|
||||
addConnectionLog: (entry: unknown) => logs.push(entry),
|
||||
connectToHost: (host: Host) => {
|
||||
connectedHosts.push(host);
|
||||
return 'session-from-connect';
|
||||
},
|
||||
identities: [],
|
||||
keys: [],
|
||||
resolveEffectiveHost: (host: Host) => host,
|
||||
resolveHostAuth: () => ({ username: 'root' }),
|
||||
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
|
||||
}),
|
||||
baseHost,
|
||||
);
|
||||
|
||||
assert.equal(result, 'session-from-connect');
|
||||
assert.equal(connectedHosts.length, 1);
|
||||
assert.equal(logs.length, 1);
|
||||
});
|
||||
|
||||
test('connect serial host handler returns the created terminal tab id', () => {
|
||||
const serialHost: Host = {
|
||||
...baseHost,
|
||||
id: 'serial-1',
|
||||
label: '',
|
||||
hostname: '/dev/tty.usbserial',
|
||||
protocol: 'serial',
|
||||
};
|
||||
|
||||
const result = handleConnectToHostImpl(
|
||||
() => ({
|
||||
addConnectionLog: () => {},
|
||||
connectToHost: () => 'serial-session',
|
||||
identities: [],
|
||||
keys: [],
|
||||
resolveEffectiveHost: (host: Host) => host,
|
||||
resolveHostAuth: () => ({ username: 'root' }),
|
||||
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
|
||||
}),
|
||||
serialHost,
|
||||
);
|
||||
|
||||
assert.equal(result, 'serial-session');
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { executeHotkeyActionImpl, handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
|
||||
import { executeHotkeyActionImpl, getLogHostVisualSnapshot, handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
|
||||
import { matchesKeyBinding } from '../domain/models.ts';
|
||||
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
|
||||
|
||||
@@ -169,3 +169,28 @@ test('quick switch hotkey toggles the quick switcher open state', () => {
|
||||
executeHotkeyActionImpl(() => ({ ...baseCtx, isQuickSwitcherOpen: true }), 'quickSwitch', event);
|
||||
assert.equal(isQuickSwitcherOpen, false);
|
||||
});
|
||||
|
||||
test('connection log host snapshot includes custom host icon fields', () => {
|
||||
assert.deepEqual(
|
||||
getLogHostVisualSnapshot({
|
||||
id: 'host-1',
|
||||
label: 'Database',
|
||||
hostname: 'db.example.com',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
distro: 'ubuntu',
|
||||
iconMode: 'custom',
|
||||
iconId: 'database',
|
||||
iconColor: 'blue',
|
||||
}),
|
||||
{
|
||||
hostOs: 'linux',
|
||||
hostDistro: 'ubuntu',
|
||||
hostIconMode: 'custom',
|
||||
hostIconId: 'database',
|
||||
hostIconColorMode: 'manual',
|
||||
hostIconColor: 'blue',
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("follow-app terminal theme selection reads the latest custom theme store", () => {
|
||||
assert.match(source, /customThemeStore\.getThemeById\(themeId\)/);
|
||||
assert.doesNotMatch(source, /const selectedTheme = themeById\.get\(themeId\)/);
|
||||
});
|
||||
@@ -3,16 +3,25 @@ import type React from 'react';
|
||||
import type { Host, HostProtocol } from '../../types';
|
||||
import type { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import { getEffectiveHostDistro } from '../../domain/host';
|
||||
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||
|
||||
type AppContextGetter = () => Record<string, any>;
|
||||
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
|
||||
|
||||
const getLogHostVisualSnapshot = (host: Host) => ({
|
||||
hostOs: host.os,
|
||||
hostDistro: getEffectiveHostDistro(host) || undefined,
|
||||
});
|
||||
export const getLogHostVisualSnapshot = (host: Host) => {
|
||||
const icon = sanitizeHostIconFields(host);
|
||||
return {
|
||||
hostOs: host.os,
|
||||
hostDistro: getEffectiveHostDistro(host) || undefined,
|
||||
hostIconMode: icon.iconMode,
|
||||
hostIconId: icon.iconId,
|
||||
...(icon.iconColorMode ? { hostIconColorMode: icon.iconColorMode } : {}),
|
||||
...(icon.iconColor ? { hostIconColor: icon.iconColor } : {}),
|
||||
...(icon.iconColorCustom ? { hostIconColorCustom: icon.iconColorCustom } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export function handleTrayJumpToSessionImpl(getCtx: AppContextGetter, sessionId: string) {
|
||||
const { sessions, setActiveTabId, setWorkspaceFocusedSession } = getCtx();
|
||||
@@ -78,7 +87,7 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
return;
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
const protocol = effectiveHost.etEnabled ? 'et' : effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
|
||||
@@ -97,6 +106,7 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
return sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,7 +450,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
|
||||
}
|
||||
|
||||
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, workspaces } = getCtx();
|
||||
{
|
||||
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces + editor tabs.
|
||||
// Hiding the SFTP tab must also remove it from keyboard cycling so nextTab
|
||||
@@ -539,6 +549,40 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
|
||||
break;
|
||||
}
|
||||
case 'closeSession': {
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
if (!currentId || currentId === 'vault' || currentId === 'sftp') break;
|
||||
if (closeTabInFlightRef.current) break;
|
||||
|
||||
const session = sessions.find((s) => s.id === currentId) ?? null;
|
||||
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
|
||||
|
||||
closeTabInFlightRef.current = true;
|
||||
(async () => {
|
||||
try {
|
||||
// If active tab is a workspace, close the focused session (pane)
|
||||
if (workspace) {
|
||||
// Validate focusedSessionId is still valid — it can become stale
|
||||
// if the previously focused session was already closed
|
||||
const aliveIds = collectSessionIds(workspace.root);
|
||||
const focusedId = aliveIds.includes(workspace.focusedSessionId)
|
||||
? workspace.focusedSessionId
|
||||
: aliveIds[0];
|
||||
if (focusedId) {
|
||||
const ok = await confirmIfBusyLocalTerminal([focusedId]);
|
||||
if (ok) closeSession(focusedId);
|
||||
}
|
||||
} else if (session) {
|
||||
// Standalone session tab — close the session
|
||||
const ok = await confirmIfBusyLocalTerminal([session.id]);
|
||||
if (ok) closeSession(session.id);
|
||||
}
|
||||
} finally {
|
||||
closeTabInFlightRef.current = false;
|
||||
}
|
||||
})();
|
||||
break;
|
||||
}
|
||||
case 'newTab':
|
||||
case 'openLocal':
|
||||
// Add connection log for local terminal
|
||||
@@ -644,6 +688,15 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'togglePaneZoom': {
|
||||
// Toggle workspace between split and focus (zoom) mode
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const activeWs = workspaces.find(w => w.id === currentId);
|
||||
if (activeWs) {
|
||||
toggleWorkspaceViewMode(activeWs.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'moveFocus': {
|
||||
// Debounce to prevent double-triggering when focus switches between terminals
|
||||
const now = Date.now();
|
||||
@@ -735,7 +788,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host) {
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
return;
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
const protocol = effectiveHost.etEnabled ? 'et' : effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
|
||||
@@ -754,6 +807,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host) {
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
return sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { GroupConfig, Host, TerminalSession, TerminalTheme, Workspace } fro
|
||||
import {
|
||||
isHostTreeWorkTabSurface,
|
||||
resolveWorkTabActiveHostId,
|
||||
resolveWorkTabHostTreeTheme,
|
||||
} from './workTabSurface';
|
||||
|
||||
interface AppHostTreeLayerProps {
|
||||
@@ -20,7 +21,12 @@ interface AppHostTreeLayerProps {
|
||||
editorTabs: readonly EditorTab[];
|
||||
logViews: readonly LogView[];
|
||||
orderedTabs: readonly string[];
|
||||
resolvedPreviewTheme: TerminalTheme;
|
||||
accentMode: 'theme' | 'custom';
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
customAccent: string;
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: ReadonlyMap<string, Host>;
|
||||
themeById: ReadonlyMap<string, TerminalTheme>;
|
||||
onConnect: (host: Host) => void;
|
||||
onCreateLocalTerminal?: () => void;
|
||||
}
|
||||
@@ -43,7 +49,12 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
editorTabs,
|
||||
logViews,
|
||||
orderedTabs,
|
||||
resolvedPreviewTheme,
|
||||
accentMode,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
onConnect,
|
||||
onCreateLocalTerminal,
|
||||
}) => {
|
||||
@@ -67,6 +78,24 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
workspaces,
|
||||
}), [activeTabId, editorTabs, sessions, workspaces]);
|
||||
|
||||
const hostTreeTheme = useMemo(() => resolveWorkTabHostTreeTheme({
|
||||
activeHostId,
|
||||
accentMode,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
}), [
|
||||
activeHostId,
|
||||
accentMode,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 flex min-h-0"
|
||||
@@ -79,7 +108,7 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
groupConfigs={groupConfigs}
|
||||
resolvedPreviewTheme={resolvedPreviewTheme}
|
||||
resolvedPreviewTheme={hostTreeTheme}
|
||||
activeHostId={activeHostId}
|
||||
onConnect={onConnect}
|
||||
onCreateLocalTerminal={onCreateLocalTerminal}
|
||||
|
||||
@@ -14,6 +14,9 @@ Object.defineProperty(globalThis, 'localStorage', {
|
||||
|
||||
const { getLogViewWrapperStyle, shouldRenderTerminalLayerMount } = await import('./AppMounts.tsx');
|
||||
const activeTabChromeSource = readFileSync(new URL('./AppActiveTabChrome.tsx', import.meta.url), 'utf8');
|
||||
const appViewSource = readFileSync(new URL('./AppView.tsx', import.meta.url), 'utf8');
|
||||
const appMountsSource = readFileSync(new URL('./AppMounts.tsx', import.meta.url), 'utf8');
|
||||
const globalCssSource = readFileSync(new URL('../../index.css', import.meta.url), 'utf8');
|
||||
|
||||
test('visible log view leaves room for the terminal host sidebar', () => {
|
||||
assert.deepEqual(getLogViewWrapperStyle(true, 220), {
|
||||
@@ -37,6 +40,25 @@ test('terminal layer renders only after terminal content is visible or mounted',
|
||||
assert.equal(shouldRenderTerminalLayerMount(false, false), false);
|
||||
});
|
||||
|
||||
test('inactive app surfaces suppress background color transitions', () => {
|
||||
assert.match(appMountsSource, /data-inactive-app-surface=\{isActive \? undefined : "true"\}/);
|
||||
assert.match(appMountsSource, /data-inactive-app-surface=\{isVisible \? undefined : "true"\}/);
|
||||
assert.match(globalCssSource, /\[data-inactive-app-surface\][\s\S]*transition: none !important;/);
|
||||
});
|
||||
|
||||
test('vault activation suppresses inherited text color transitions', () => {
|
||||
assert.match(appMountsSource, /data-app-surface-transition-suppressed/);
|
||||
assert.match(appMountsSource, /setSuppressActiveTransition\(false\)/);
|
||||
assert.match(globalCssSource, /\[data-app-surface-transition-suppressed\][\s\S]*transition: none !important;/);
|
||||
});
|
||||
|
||||
test('vault surface carries app theme vars while terminal chrome is active', () => {
|
||||
assert.match(appMountsSource, /appThemeStyle\?: React\.CSSProperties/);
|
||||
assert.match(appMountsSource, /style=\{\{ \.\.\.appThemeStyle, \.\.\.containerStyle \}\}/);
|
||||
assert.match(appViewSource, /buildAppThemeCssVars\(tokens, accentMode, customAccent\)/);
|
||||
assert.match(appViewSource, /<VaultViewContainer appThemeStyle=\{appThemeStyle\}>/);
|
||||
});
|
||||
|
||||
test('active tab chrome keeps removed theme side effects unmounted', () => {
|
||||
const removedThemeHook = ['use', 'Im', 'mersive', 'Mode'].join('');
|
||||
const removedThemeStoreSetter = ['set', 'Im', 'mersive', 'Active'].join('');
|
||||
|
||||
@@ -1,22 +1,56 @@
|
||||
import React, { Suspense, lazy, useEffect, useMemo, useState } from 'react';
|
||||
import React, { Suspense, lazy, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useActiveTabId, useIsSftpActive, useIsVaultActive } from '../state/activeTabStore';
|
||||
import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
|
||||
import { isTerminalContentTabSurface } from './workTabSurface';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ConnectionLog, TerminalTheme } from '../../types';
|
||||
import { LazyLoadBoundary } from '../../components/ui/lazy-load-boundary';
|
||||
import type { LogView as LogViewType } from '../state/logViewState';
|
||||
import type { SftpView as SftpViewComponent } from '../../components/SftpView';
|
||||
import type { TerminalLayer as TerminalLayerComponent } from '../../components/TerminalLayer';
|
||||
|
||||
// Visibility container for VaultView - isolates isActive subscription
|
||||
export const VaultViewContainer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
export const VaultViewContainer: React.FC<{
|
||||
children: React.ReactNode;
|
||||
appThemeStyle?: React.CSSProperties;
|
||||
}> = ({ children, appThemeStyle }) => {
|
||||
const isActive = useIsVaultActive();
|
||||
const wasActiveRef = useRef(isActive);
|
||||
const [suppressActiveTransition, setSuppressActiveTransition] = useState(false);
|
||||
const isActivating = isActive && !wasActiveRef.current;
|
||||
const shouldSuppressTransition = isActivating || suppressActiveTransition;
|
||||
const containerStyle: React.CSSProperties = isActive
|
||||
? {}
|
||||
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1 };
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const wasActive = wasActiveRef.current;
|
||||
wasActiveRef.current = isActive;
|
||||
if (!isActive || wasActive) return;
|
||||
|
||||
setSuppressActiveTransition(true);
|
||||
const view = window;
|
||||
let firstFrame = 0;
|
||||
let secondFrame = 0;
|
||||
firstFrame = view.requestAnimationFrame(() => {
|
||||
secondFrame = view.requestAnimationFrame(() => {
|
||||
setSuppressActiveTransition(false);
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
view.cancelAnimationFrame(firstFrame);
|
||||
view.cancelAnimationFrame(secondFrame);
|
||||
};
|
||||
}, [isActive]);
|
||||
|
||||
return (
|
||||
<div className={cn("absolute inset-0", isActive ? "z-20" : "")} style={containerStyle}>
|
||||
<div
|
||||
className={cn("absolute inset-0", isActive ? "z-20" : "")}
|
||||
data-inactive-app-surface={isActive ? undefined : "true"}
|
||||
data-app-surface-transition-suppressed={shouldSuppressTransition ? "true" : undefined}
|
||||
style={{ ...appThemeStyle, ...containerStyle }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -51,17 +85,23 @@ export const LogViewWrapper: React.FC<LogViewWrapperProps> = ({ logView, default
|
||||
const containerStyle = getLogViewWrapperStyle(isVisible, hostTreeLayoutWidth);
|
||||
|
||||
return (
|
||||
<div className={cn("absolute inset-0", isVisible ? "z-20" : "")} style={containerStyle}>
|
||||
<Suspense fallback={null}>
|
||||
<LazyLogView
|
||||
log={logView.log}
|
||||
defaultTerminalTheme={defaultTerminalTheme}
|
||||
defaultFontSize={defaultFontSize}
|
||||
isVisible={isVisible}
|
||||
onClose={onClose}
|
||||
onUpdateLog={onUpdateLog}
|
||||
/>
|
||||
</Suspense>
|
||||
<div
|
||||
className={cn("absolute inset-0", isVisible ? "z-20" : "")}
|
||||
data-inactive-app-surface={isVisible ? undefined : "true"}
|
||||
style={containerStyle}
|
||||
>
|
||||
<LazyLoadBoundary name="Log view" resetKey={logView.id}>
|
||||
<Suspense fallback={<LogViewFallback />}>
|
||||
<LazyLogView
|
||||
log={logView.log}
|
||||
defaultTerminalTheme={defaultTerminalTheme}
|
||||
defaultFontSize={defaultFontSize}
|
||||
isVisible={isVisible}
|
||||
onClose={onClose}
|
||||
onUpdateLog={onUpdateLog}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -79,6 +119,24 @@ const LazyTerminalLayer = lazy(() =>
|
||||
type SftpViewProps = React.ComponentProps<typeof SftpViewComponent>;
|
||||
type TerminalLayerProps = React.ComponentProps<typeof TerminalLayerComponent>;
|
||||
|
||||
const LogViewFallback = () => (
|
||||
<div className="netcatty-lazy-fade-in h-full min-h-0 bg-background" aria-hidden="true" />
|
||||
);
|
||||
|
||||
const SftpViewFallback = ({ visible }: { visible: boolean }) => {
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<div className="netcatty-lazy-fade-in absolute inset-0 z-20 bg-background" aria-hidden="true" />
|
||||
);
|
||||
};
|
||||
|
||||
const TerminalLayerFallback = ({ visible }: { visible: boolean }) => {
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<div className="netcatty-lazy-fade-in absolute inset-0 z-20 bg-background" aria-hidden="true" />
|
||||
);
|
||||
};
|
||||
|
||||
export function shouldRenderTerminalLayerMount(
|
||||
isVisible: boolean,
|
||||
shouldMount: boolean,
|
||||
@@ -97,9 +155,11 @@ export const SftpViewMount: React.FC<SftpViewProps> = (props) => {
|
||||
if (!shouldMount) return null;
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LazySftpView {...props} />
|
||||
</Suspense>
|
||||
<LazyLoadBoundary name="SFTP" resetKey={isActive ? "active" : "idle"}>
|
||||
<Suspense fallback={<SftpViewFallback visible={isActive} />}>
|
||||
<LazySftpView {...props} />
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -138,8 +198,10 @@ export const TerminalLayerMount: React.FC<TerminalLayerProps> = (props) => {
|
||||
if (!shouldRender) return null;
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LazyTerminalLayer {...props} />
|
||||
</Suspense>
|
||||
<LazyLoadBoundary name="Terminal" resetKey={activeTabId}>
|
||||
<Suspense fallback={<TerminalLayerFallback visible={isVisible} />}>
|
||||
<LazyTerminalLayer {...props} />
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
);
|
||||
};
|
||||
+142
-69
@@ -1,24 +1,27 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import React, { Suspense, lazy, useMemo } from 'react';
|
||||
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
|
||||
import { activeTabStore, toEditorTabId } from '../state/activeTabStore';
|
||||
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
|
||||
import { editorTabStore } from '../state/editorTabStore';
|
||||
import { releaseEditorTabSaveCoordinator, saveEditorTab } from '../state/editorTabSave';
|
||||
import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
|
||||
import { TopTabs } from '../../components/TopTabs';
|
||||
import { VaultView } from '../../components/VaultView';
|
||||
import { QuickAddSnippetDialog } from '../../components/QuickAddSnippetDialog';
|
||||
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
|
||||
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
|
||||
import { PassphraseModal } from '../../components/PassphraseModal';
|
||||
import { TextEditorTabView } from '../../components/editor/TextEditorTabView';
|
||||
import { UnsavedChangesProvider } 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';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { LazyLoadBoundary } from '../../components/ui/lazy-load-boundary';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
import { AppHostTreeLayer } from './AppHostTreeLayer';
|
||||
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { buildAppThemeCssVars } from '../state/settingsStateDefaults';
|
||||
|
||||
const LazyProtocolSelectDialog = lazy(() => import('../../components/ProtocolSelectDialog'));
|
||||
const LazyQuickSwitcher = lazy(() =>
|
||||
@@ -27,6 +30,25 @@ const LazyQuickSwitcher = lazy(() =>
|
||||
const LazyCreateWorkspaceDialog = lazy(() =>
|
||||
import('../../components/CreateWorkspaceDialog').then((m) => ({ default: m.CreateWorkspaceDialog })),
|
||||
);
|
||||
const LazyTextEditorTabView = lazy(() =>
|
||||
import('../../components/editor/TextEditorTabView').then((m) => ({ default: m.TextEditorTabView })),
|
||||
);
|
||||
|
||||
const TextEditorTabFallback = ({ tabId }: { tabId: string }) => {
|
||||
const isVisible = useIsEditorTabActive(tabId);
|
||||
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...(isVisible ? null : { pointerEvents: 'none', visibility: 'hidden' }),
|
||||
zIndex: 20,
|
||||
left: hostTreeLayoutWidth,
|
||||
}}
|
||||
className="netcatty-lazy-fade-in absolute top-0 right-0 bottom-0 min-h-0 flex flex-col bg-background"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type AppViewContext = Record<string, any>;
|
||||
|
||||
@@ -35,26 +57,37 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell,
|
||||
connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent,
|
||||
customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
|
||||
customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
|
||||
followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost,
|
||||
handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
|
||||
handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
|
||||
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
|
||||
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
|
||||
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal,
|
||||
hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename,
|
||||
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
|
||||
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, updateSessionFontSize, clearSessionFontSizeOverride,
|
||||
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
|
||||
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
||||
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
|
||||
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId,
|
||||
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
|
||||
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
|
||||
updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
|
||||
updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces,
|
||||
updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces,
|
||||
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
|
||||
} = ctx;
|
||||
|
||||
const appThemeStyle = useMemo(() => {
|
||||
const tokens = getUiThemeById(
|
||||
resolvedTheme,
|
||||
resolvedTheme === 'dark' ? settings.darkUiThemeId : settings.lightUiThemeId,
|
||||
).tokens;
|
||||
return {
|
||||
...buildAppThemeCssVars(tokens, accentMode, customAccent),
|
||||
colorScheme: resolvedTheme,
|
||||
} as React.CSSProperties;
|
||||
}, [accentMode, customAccent, resolvedTheme, settings.darkUiThemeId, settings.lightUiThemeId]);
|
||||
|
||||
return (
|
||||
<SnippetExecutionProvider>
|
||||
<UnsavedChangesProvider>
|
||||
@@ -134,6 +167,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onStartSessionDrag={setDraggingSessionId}
|
||||
onEndSessionDrag={handleEndSessionDrag}
|
||||
onReorderTabs={reorderWorkTabs}
|
||||
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||
editorTabs={editorTabs}
|
||||
@@ -152,12 +186,17 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
editorTabs={editorTabs}
|
||||
logViews={logViews}
|
||||
orderedTabs={orderedTabsWithEditors}
|
||||
resolvedPreviewTheme={currentTerminalTheme}
|
||||
accentMode={accentMode}
|
||||
currentTerminalTheme={currentTerminalTheme}
|
||||
customAccent={customAccent}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
hostById={hostById}
|
||||
themeById={themeById}
|
||||
onConnect={handleConnectToHost}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
/>
|
||||
|
||||
<VaultViewContainer>
|
||||
<VaultViewContainer appThemeStyle={appThemeStyle}>
|
||||
<VaultView
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
@@ -165,6 +204,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
proxyProfiles={proxyProfiles}
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
notes={notes}
|
||||
noteGroups={noteGroups}
|
||||
customGroups={customGroups}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
shellHistory={shellHistory}
|
||||
@@ -181,6 +222,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onConnectSerial={handleConnectSerial}
|
||||
onDeleteHost={handleDeleteHost}
|
||||
onConnect={handleConnectToHost}
|
||||
onOpenHostFromNote={handleOpenHostFromVaultNote}
|
||||
groupConfigs={groupConfigs}
|
||||
onUpdateGroupConfigs={updateGroupConfigs}
|
||||
onUpdateHosts={updateHosts}
|
||||
@@ -190,6 +232,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onUpdateProxyProfiles={updateProxyProfiles}
|
||||
onUpdateSnippets={updateSnippets}
|
||||
onUpdateSnippetPackages={updateSnippetPackages}
|
||||
onUpdateNotes={updateNotes}
|
||||
onUpdateNoteGroups={updateNoteGroups}
|
||||
onUpdateCustomGroups={updateCustomGroups}
|
||||
onUpdateKnownHosts={updateKnownHosts}
|
||||
onUpdateManagedSources={updateManagedSources}
|
||||
@@ -206,6 +250,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
|
||||
navigateToSection={navigateToSection}
|
||||
onNavigateToSectionHandled={() => setNavigateToSection(null)}
|
||||
deepLinkHostDraft={deepLinkHostDraft}
|
||||
onDeepLinkHostDraftHandled={() => setDeepLinkHostDraft(null)}
|
||||
terminalSettings={terminalSettings}
|
||||
/>
|
||||
</VaultViewContainer>
|
||||
@@ -240,11 +286,14 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
identities={identities}
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
notes={notes}
|
||||
noteGroups={noteGroups}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
knownHosts={effectiveKnownHosts}
|
||||
draggingSessionId={draggingSessionId}
|
||||
terminalTheme={currentTerminalTheme}
|
||||
terminalThemeId={terminalThemeId}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
accentMode={accentMode}
|
||||
customAccent={customAccent}
|
||||
@@ -253,12 +302,17 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
fontSize={terminalFontSize}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
disableTerminalFontZoom={settings.disableTerminalFontZoom}
|
||||
restoreTerminalCwd={settings.restoreTerminalCwd}
|
||||
keyBindings={keyBindings}
|
||||
onHotkeyAction={handleHotkeyAction}
|
||||
onUpdateTerminalThemeId={setTerminalThemeId}
|
||||
onUpdateFollowAppTerminalThemeId={handleFollowAppTerminalThemeChange}
|
||||
onUpdateTerminalFontFamilyId={setTerminalFontFamilyId}
|
||||
onUpdateTerminalFontSize={setTerminalFontSize}
|
||||
onUpdateSessionFontSize={updateSessionFontSize}
|
||||
onUpdateSessionRestoreCwd={updateSessionRestoreCwd}
|
||||
onUpdateSessionDynamicTitle={updateSessionDynamicTitle}
|
||||
onUpdateSessionCodingCliProvider={updateSessionCodingCliProvider}
|
||||
onClearSessionFontSizeOverride={clearSessionFontSizeOverride}
|
||||
onUpdateTerminalFontWeight={(w) => updateTerminalSetting('fontWeight', w)}
|
||||
onCloseSession={closeSession}
|
||||
@@ -281,14 +335,20 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
||||
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
|
||||
onReorderWorkspaceSessions={reorderWorkspaceSessions}
|
||||
onReorderTabs={reorderWorkTabs}
|
||||
onCopySession={copySessionWithCurrentShell}
|
||||
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
|
||||
onSplitSession={splitSessionWithCurrentShell}
|
||||
onConnectToHost={handleConnectToHost}
|
||||
openNoteRequest={openNoteRequest}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
updateHosts={updateHosts}
|
||||
updateSnippets={updateSnippets}
|
||||
updateSnippetPackages={updateSnippetPackages}
|
||||
updateNotes={updateNotes}
|
||||
updateNoteGroups={updateNoteGroups}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
@@ -307,6 +367,9 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
||||
toggleSidePanelRef={toggleSidePanelRef}
|
||||
onStartSessionRename={startSessionRename}
|
||||
onSubmitSessionRename={submitSessionRename}
|
||||
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
||||
/>
|
||||
|
||||
{/* Log Views - readonly terminal replays */}
|
||||
@@ -327,14 +390,17 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
|
||||
{/* Editor Tabs — kept mounted for Monaco instance persistence; visibility toggled via CSS */}
|
||||
{editorTabs.map((tab) => (
|
||||
<TextEditorTabView
|
||||
key={tab.id}
|
||||
tabId={tab.id}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
hostById={hostById}
|
||||
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
|
||||
/>
|
||||
<LazyLoadBoundary key={tab.id} name="Editor" resetKey={tab.id}>
|
||||
<Suspense fallback={<TextEditorTabFallback tabId={tab.id} />}>
|
||||
<LazyTextEditorTabView
|
||||
tabId={tab.id}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
hostById={hostById}
|
||||
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -398,38 +464,40 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
)}
|
||||
|
||||
{isQuickSwitcherOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<LazyQuickSwitcher
|
||||
isOpen={isQuickSwitcherOpen}
|
||||
query={quickSearch}
|
||||
results={quickResults}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
onQueryChange={setQuickSearch}
|
||||
onSelect={handleHostConnectWithProtocolCheck}
|
||||
onSelectTab={(tabId) => {
|
||||
setActiveTabId(tabId);
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
}}
|
||||
onCreateLocalTerminal={(shell) => {
|
||||
handleCreateLocalTerminal(shell);
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
}}
|
||||
onCreateWorkspace={() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
setAddToWorkspaceDialog({ mode: 'create' });
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
}}
|
||||
keyBindings={keyBindings}
|
||||
/>
|
||||
</Suspense>
|
||||
<LazyLoadBoundary name="Quick switcher" resetKey={quickSearch}>
|
||||
<Suspense fallback={null}>
|
||||
<LazyQuickSwitcher
|
||||
isOpen={isQuickSwitcherOpen}
|
||||
query={quickSearch}
|
||||
results={quickResults}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
onQueryChange={setQuickSearch}
|
||||
onSelect={handleHostConnectWithProtocolCheck}
|
||||
onSelectTab={(tabId) => {
|
||||
setActiveTabId(tabId);
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
}}
|
||||
onCreateLocalTerminal={(shell) => {
|
||||
handleCreateLocalTerminal(shell);
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
}}
|
||||
onCreateWorkspace={() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
setAddToWorkspaceDialog({ mode: 'create' });
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
}}
|
||||
keyBindings={keyBindings}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
)}
|
||||
|
||||
<Dialog open={!!sessionRenameTarget} onOpenChange={(open) => {
|
||||
@@ -487,25 +555,29 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
</Dialog>
|
||||
|
||||
{isCreateWorkspaceOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<LazyCreateWorkspaceDialog
|
||||
isOpen={isCreateWorkspaceOpen}
|
||||
onClose={() => setIsCreateWorkspaceOpen(false)}
|
||||
hosts={hosts}
|
||||
onCreate={createWorkspaceWithHosts}
|
||||
/>
|
||||
</Suspense>
|
||||
<LazyLoadBoundary name="Create workspace" resetKey="create-workspace">
|
||||
<Suspense fallback={null}>
|
||||
<LazyCreateWorkspaceDialog
|
||||
isOpen={isCreateWorkspaceOpen}
|
||||
onClose={() => setIsCreateWorkspaceOpen(false)}
|
||||
hosts={hosts}
|
||||
onCreate={createWorkspaceWithHosts}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
)}
|
||||
|
||||
{/* Protocol Select Dialog for QuickSwitcher */}
|
||||
{protocolSelectHost && (
|
||||
<Suspense fallback={null}>
|
||||
<LazyProtocolSelectDialog
|
||||
host={protocolSelectHost}
|
||||
onSelect={handleProtocolSelect}
|
||||
onCancel={() => setProtocolSelectHost(null)}
|
||||
/>
|
||||
</Suspense>
|
||||
<LazyLoadBoundary name="Protocol selector" resetKey={protocolSelectHost.id}>
|
||||
<Suspense fallback={null}>
|
||||
<LazyProtocolSelectDialog
|
||||
host={protocolSelectHost}
|
||||
onSelect={handleProtocolSelect}
|
||||
onCancel={() => setProtocolSelectHost(null)}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
)}
|
||||
|
||||
{/* Global Keyboard-Interactive Authentication Modal (2FA/MFA) - processes queue */}
|
||||
@@ -553,6 +625,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
hosts: emptyVaultConflict.hostCount,
|
||||
keys: emptyVaultConflict.keyCount,
|
||||
snippets: emptyVaultConflict.snippetCount,
|
||||
notes: emptyVaultConflict.noteCount,
|
||||
proxyProfiles: emptyVaultConflict.proxyProfileCount,
|
||||
})}</div>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@ const baseInput = {
|
||||
workspaceById: new Map<string, Workspace>(),
|
||||
};
|
||||
|
||||
test("editor tabs use the theme from their owning host", () => {
|
||||
test("editor tabs use the owning host terminal theme when follow-app terminal theme is off", () => {
|
||||
const editorTab = {
|
||||
id: "editor-1",
|
||||
hostId: "host-1",
|
||||
@@ -58,6 +58,26 @@ test("editor tabs use the theme from their owning host", () => {
|
||||
assert.equal(resolved?.id, hostTheme.id);
|
||||
});
|
||||
|
||||
test("editor tabs use the followed terminal theme when follow-app terminal theme is on", () => {
|
||||
const editorTab = {
|
||||
id: "editor-1",
|
||||
hostId: "host-1",
|
||||
sessionId: "sftp-1",
|
||||
};
|
||||
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: toEditorTabId(editorTab.id),
|
||||
editorTabs: [editorTab as unknown as EditorTab],
|
||||
followAppTerminalTheme: true,
|
||||
hostById: new Map([
|
||||
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
|
||||
]),
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, currentTheme.id);
|
||||
});
|
||||
|
||||
test("log tabs use the saved log theme when available", () => {
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
|
||||
@@ -54,22 +54,21 @@ export function resolveActiveChromeTheme({
|
||||
}: ResolveActiveChromeThemeInput): TerminalTheme | null {
|
||||
if (activeTabId === "vault" || activeTabId === "sftp") return null;
|
||||
|
||||
const resolveSessionTheme = (session: TerminalSession): TerminalTheme => {
|
||||
const resolveHostTheme = (hostId: string): TerminalTheme => {
|
||||
if (followAppTerminalTheme) return currentTerminalTheme;
|
||||
const host = hostById.get(session.hostId) ?? null;
|
||||
const host = hostById.get(hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
};
|
||||
|
||||
const resolveSessionTheme = (session: TerminalSession): TerminalTheme => resolveHostTheme(session.hostId);
|
||||
|
||||
if (isEditorTabId(activeTabId)) {
|
||||
const editorTabId = fromEditorTabId(activeTabId);
|
||||
const editorTab = editorTabs.find((tab) => tab.id === editorTabId);
|
||||
if (!editorTab) return null;
|
||||
const host = hostById.get(editorTab.hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
return resolveHostTheme(editorTab.hostId);
|
||||
}
|
||||
|
||||
const logView = logViews.find((item) => item.id === activeTabId);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { shouldQueueKeyboardInteractiveRequest } from "./useAppStartupEffects.ts";
|
||||
|
||||
const sessions = [{ id: "terminal-1" }, { id: "terminal-2" }];
|
||||
|
||||
test("terminal-scoped keyboard-interactive requests are limited to owned sessions", () => {
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest({ scope: "terminal", sessionId: "terminal-1" }, sessions),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest({ scope: "terminal", sessionId: "foreign-terminal" }, sessions),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("external keyboard-interactive requests are not filtered by terminal session ids", () => {
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "sftp-conn-1" }, sessions),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "tunnel-1" }, sessions),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("disabled peer windows still queue sender-targeted external keyboard-interactive requests", () => {
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "sftp-conn-1" }, sessions),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("disabled peer windows can still queue owned terminal keyboard-interactive requests", () => {
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest({ scope: "terminal", sessionId: "terminal-1" }, sessions),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("legacy unscoped keyboard-interactive requests remain visible", () => {
|
||||
assert.equal(
|
||||
shouldQueueKeyboardInteractiveRequest({ sessionId: "legacy-conn" }, sessions),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ test("active chrome theme applies top tab vars and clears them before vault rest
|
||||
const effectsSource = readFileSync(new URL("../../components/terminalLayer/useTerminalLayerEffects.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(chromeThemeSource, /applyTopTabsChromeThemeVars\(theme\)/);
|
||||
assert.match(chromeThemeSource, /resolveReadableForegroundForHsl\(cursor\)/);
|
||||
const restoreBlock = chromeThemeSource.match(
|
||||
/clearTopTabsChromeThemeVars\(\);\s*runThemeTransition\(\(\) => \{\s*removeActiveChromeTheme\(\);/,
|
||||
)?.[0] ?? "";
|
||||
@@ -16,3 +17,14 @@ test("active chrome theme applies top tab vars and clears them before vault rest
|
||||
assert.match(syncSource, /activeTabId === 'vault' \|\| activeTabId === 'sftp'\)[\s\S]*clearTopTabsChromeThemeVars\(\)/);
|
||||
assert.match(effectsSource, /if \(!isTerminalLayerVisible\) \{[\s\S]*clearTopTabsPreviewVars\(\)/);
|
||||
});
|
||||
|
||||
test("top tabs chrome theme keeps accent foreground in sync", () => {
|
||||
const source = readFileSync(new URL("./topTabsChromeTheme.ts", import.meta.url), "utf8");
|
||||
const supportSource = readFileSync(new URL("../../components/terminalLayer/TerminalLayerSupport.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /--primary-foreground/);
|
||||
assert.match(source, /--accent-foreground/);
|
||||
assert.match(source, /resolveReadableForegroundForHsl\(accent\)/);
|
||||
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--primary-foreground'\)/);
|
||||
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--accent-foreground'\)/);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TerminalTheme } from '../../types';
|
||||
import { resolveReadableForegroundForHsl } from '../../domain/colorContrast';
|
||||
|
||||
function hexToHslToken(hex: string): string {
|
||||
const normalized = hex.startsWith('#') ? hex : `#${hex}`;
|
||||
@@ -61,7 +62,9 @@ const TOP_TABS_THEME_PROPERTIES = [
|
||||
'--background',
|
||||
'--foreground',
|
||||
'--accent',
|
||||
'--accent-foreground',
|
||||
'--primary',
|
||||
'--primary-foreground',
|
||||
'--secondary',
|
||||
'--border',
|
||||
'--muted-foreground',
|
||||
@@ -84,6 +87,7 @@ export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
|
||||
const bg = hexToHslToken(theme.colors.background);
|
||||
const fg = hexToHslToken(theme.colors.foreground);
|
||||
const accent = hexToHslToken(theme.colors.cursor);
|
||||
const accentForeground = resolveReadableForegroundForHsl(accent);
|
||||
const isDark = theme.type === 'dark';
|
||||
const secondary = adjustLightnessToken(bg, isDark ? 6 : -5);
|
||||
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
|
||||
@@ -92,7 +96,9 @@ export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
|
||||
setStylePropertyIfChanged(tabsRoot, '--background', bg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
|
||||
setStylePropertyIfChanged(tabsRoot, '--border', border);
|
||||
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
|
||||
|
||||
@@ -8,14 +8,35 @@ import { toast } from '../../components/ui/toast';
|
||||
|
||||
type StartupEffectsContext = Record<string, any>;
|
||||
|
||||
type KeyboardInteractiveScope = "terminal" | "external";
|
||||
type KeyboardInteractiveRequestLike = {
|
||||
scope?: KeyboardInteractiveScope;
|
||||
sessionId?: string;
|
||||
};
|
||||
type SessionIdLike = { id: string };
|
||||
|
||||
export function shouldQueueKeyboardInteractiveRequest(
|
||||
request: KeyboardInteractiveRequestLike,
|
||||
sessions: SessionIdLike[],
|
||||
): boolean {
|
||||
if (request.scope !== "terminal") return true;
|
||||
if (!request.sessionId) return false;
|
||||
return sessions.some((session) => session.id === request.sessionId);
|
||||
}
|
||||
|
||||
export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
const {dismissUpdate, groupConfigs, hosts, identities,
|
||||
const {dismissUpdate, enabled = true, groupConfigs, hosts, identities,
|
||||
installUpdate, isVaultInitialized, keys, openSettingsWindow, portForwardingRules, proxyProfiles, sessions, setKeyboardInteractiveQueue,
|
||||
t, terminalSettings, updateState, workspaces,
|
||||
} = ctx;
|
||||
const sessionsRef = useRef(sessions);
|
||||
useEffect(() => {
|
||||
sessionsRef.current = sessions;
|
||||
}, [sessions]);
|
||||
|
||||
// Show toast notification when update is available (only when auto-download is idle)
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
// Skip "update available" toast if auto-download has already started or completed
|
||||
if (updateState.autoDownloadStatus !== 'idle') return;
|
||||
// Don't show automatic notification when auto-update is disabled
|
||||
@@ -41,12 +62,13 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [updateState.hasUpdate, updateState.latestRelease, updateState.autoDownloadStatus, t, openSettingsWindow, dismissUpdate]);
|
||||
}, [enabled, updateState.hasUpdate, updateState.latestRelease, updateState.autoDownloadStatus, t, openSettingsWindow, dismissUpdate]);
|
||||
|
||||
// Track previous autoDownloadStatus so toast effects fire only on actual transitions,
|
||||
// not when unrelated deps (installUpdate, openSettingsWindow) change their reference.
|
||||
const prevAutoDownloadStatusRef = useRef(updateState.autoDownloadStatus);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const prev = prevAutoDownloadStatusRef.current;
|
||||
prevAutoDownloadStatusRef.current = updateState.autoDownloadStatus;
|
||||
if (prev === updateState.autoDownloadStatus) return;
|
||||
@@ -72,10 +94,11 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openSettingsWindow]);
|
||||
}, [enabled, updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openSettingsWindow]);
|
||||
|
||||
// Auto-start port forwarding rules on app launch
|
||||
usePortForwardingAutoStart({
|
||||
enabled,
|
||||
isVaultInitialized,
|
||||
hosts,
|
||||
keys,
|
||||
@@ -87,6 +110,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
|
||||
// Sync tray menu data + handle tray actions
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.updateTrayMenuData) return;
|
||||
|
||||
@@ -116,7 +140,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [sessions, portForwardingRules, workspaces]);
|
||||
}, [enabled, sessions, portForwardingRules, workspaces]);
|
||||
|
||||
// Quit guard: block app exit while any editor tab has unsaved changes.
|
||||
// Main process sends "app:query-dirty-editors"; we respond with the result.
|
||||
@@ -146,7 +170,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
}, [t]);
|
||||
}, [enabled, t]);
|
||||
|
||||
// Keyboard-interactive authentication (2FA/MFA) event listener
|
||||
useEffect(() => {
|
||||
@@ -154,6 +178,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
if (!bridge?.onKeyboardInteractive) return;
|
||||
|
||||
const unsubscribe = bridge.onKeyboardInteractive((request) => {
|
||||
if (!shouldQueueKeyboardInteractiveRequest(request, sessionsRef.current)) return;
|
||||
console.log('[App] Keyboard-interactive request received:', request);
|
||||
// Add to queue instead of replacing - supports multiple concurrent sessions
|
||||
setKeyboardInteractiveQueue(prev => [...prev, {
|
||||
@@ -170,7 +195,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [setKeyboardInteractiveQueue]);
|
||||
}, [enabled, setKeyboardInteractiveQueue]);
|
||||
|
||||
|
||||
}
|
||||
@@ -6,10 +6,40 @@ import {
|
||||
isHostTreeWorkTabSurface,
|
||||
isRootPageTabId,
|
||||
isTerminalContentTabSurface,
|
||||
reorderWorkTabIds,
|
||||
resolveWorkTabActiveHostId,
|
||||
resolveWorkTabHostTreeTheme,
|
||||
} from './workTabSurface';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
import type { TerminalSession, Workspace } from '../../types';
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
|
||||
const makeTheme = (id: string, type: TerminalTheme['type'], background: string): TerminalTheme => ({
|
||||
id,
|
||||
name: id,
|
||||
type,
|
||||
colors: {
|
||||
background,
|
||||
foreground: type === 'dark' ? '#ffffff' : '#000000',
|
||||
cursor: '#888888',
|
||||
selection: '#555555',
|
||||
black: '#000000',
|
||||
red: '#ff0000',
|
||||
green: '#00ff00',
|
||||
yellow: '#ffff00',
|
||||
blue: '#0000ff',
|
||||
magenta: '#ff00ff',
|
||||
cyan: '#00ffff',
|
||||
white: '#ffffff',
|
||||
brightBlack: '#444444',
|
||||
brightRed: '#ff5555',
|
||||
brightGreen: '#55ff55',
|
||||
brightYellow: '#ffff55',
|
||||
brightBlue: '#5555ff',
|
||||
brightMagenta: '#ff55ff',
|
||||
brightCyan: '#55ffff',
|
||||
brightWhite: '#ffffff',
|
||||
},
|
||||
});
|
||||
|
||||
test('work tab order keeps custom positions and appends new tabs', () => {
|
||||
assert.deepEqual(
|
||||
@@ -18,6 +48,29 @@ test('work tab order keeps custom positions and appends new tabs', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('work tab order removes duplicate ids before rendering', () => {
|
||||
assert.deepEqual(
|
||||
buildOrderedWorkTabIds(
|
||||
['session-2', 'session-1', 'session-2', 'session-1'],
|
||||
['session-1', 'session-2', 'session-3', 'session-3'],
|
||||
),
|
||||
['session-2', 'session-1', 'session-3'],
|
||||
);
|
||||
});
|
||||
|
||||
test('work tab order reorders with newly materialized tabs', () => {
|
||||
assert.deepEqual(
|
||||
reorderWorkTabIds(
|
||||
['session-1', 'session-2', 'session-3'],
|
||||
['session-1', 'session-2', 'session-3'],
|
||||
'session-1',
|
||||
'session-3',
|
||||
'after',
|
||||
),
|
||||
['session-2', 'session-3', 'session-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('root pages are not work tab surfaces', () => {
|
||||
assert.equal(isRootPageTabId('vault'), true);
|
||||
assert.equal(isRootPageTabId('sftp'), true);
|
||||
@@ -80,3 +133,73 @@ test('shared host tree resolves active host ids across work tab types', () => {
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'editor:file-1', sessions, workspaces, editorTabs }), 'host-3');
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'log-1', sessions, workspaces, editorTabs }), null);
|
||||
});
|
||||
|
||||
test('shared host tree uses the active host theme when follow-app terminal theme is off', () => {
|
||||
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
|
||||
const hostTheme = makeTheme('host-light', 'light', '#fafafa');
|
||||
const host = {
|
||||
id: 'host-1',
|
||||
label: 'Host',
|
||||
hostname: 'host.local',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
theme: hostTheme.id,
|
||||
themeOverride: true,
|
||||
} as Host;
|
||||
|
||||
const resolved = resolveWorkTabHostTreeTheme({
|
||||
activeHostId: host.id,
|
||||
accentMode: 'theme',
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: '#8b5cf6',
|
||||
followAppTerminalTheme: false,
|
||||
hostById: new Map([[host.id, host]]),
|
||||
themeById: new Map([[currentTheme.id, currentTheme], [hostTheme.id, hostTheme]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved.id, hostTheme.id);
|
||||
});
|
||||
|
||||
test('shared host tree uses the followed terminal theme when follow-app terminal theme is on', () => {
|
||||
const currentTheme = makeTheme('app-light', 'light', '#ffffff');
|
||||
const hostTheme = makeTheme('host-dark', 'dark', '#050505');
|
||||
const host = {
|
||||
id: 'host-1',
|
||||
label: 'Host',
|
||||
hostname: 'host.local',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
theme: hostTheme.id,
|
||||
themeOverride: true,
|
||||
} as Host;
|
||||
|
||||
const resolved = resolveWorkTabHostTreeTheme({
|
||||
activeHostId: host.id,
|
||||
accentMode: 'theme',
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: '#8b5cf6',
|
||||
followAppTerminalTheme: true,
|
||||
hostById: new Map([[host.id, host]]),
|
||||
themeById: new Map([[currentTheme.id, currentTheme], [hostTheme.id, hostTheme]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved.id, currentTheme.id);
|
||||
});
|
||||
|
||||
test('shared host tree falls back to the current terminal theme without an active host', () => {
|
||||
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
|
||||
|
||||
const resolved = resolveWorkTabHostTreeTheme({
|
||||
activeHostId: null,
|
||||
accentMode: 'theme',
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: '#8b5cf6',
|
||||
followAppTerminalTheme: false,
|
||||
hostById: new Map(),
|
||||
themeById: new Map([[currentTheme.id, currentTheme]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved.id, currentTheme.id);
|
||||
});
|
||||
@@ -2,8 +2,20 @@ import {
|
||||
fromEditorTabId,
|
||||
isEditorTabId,
|
||||
} from '../state/activeTabStore';
|
||||
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from '../../domain/terminalAppearance';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
import type { TerminalSession, Workspace } from '../../types';
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
|
||||
function uniqueTabIds(tabIds: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const uniqueIds: string[] = [];
|
||||
for (const tabId of tabIds) {
|
||||
if (!tabId || seen.has(tabId)) continue;
|
||||
seen.add(tabId);
|
||||
uniqueIds.push(tabId);
|
||||
}
|
||||
return uniqueIds;
|
||||
}
|
||||
|
||||
export function isRootPageTabId(activeTabId: string): boolean {
|
||||
return activeTabId === 'vault' || activeTabId === 'sftp';
|
||||
@@ -13,13 +25,42 @@ export function buildOrderedWorkTabIds(
|
||||
tabOrder: readonly string[],
|
||||
allTabIds: readonly string[],
|
||||
): string[] {
|
||||
const allTabIdSet = new Set(allTabIds);
|
||||
const orderedIds = tabOrder.filter((id) => allTabIdSet.has(id));
|
||||
const uniqueAllTabIds = uniqueTabIds(allTabIds);
|
||||
const allTabIdSet = new Set(uniqueAllTabIds);
|
||||
const orderedIds = uniqueTabIds(tabOrder.filter((id) => allTabIdSet.has(id)));
|
||||
const orderedIdSet = new Set(orderedIds);
|
||||
const newIds = allTabIds.filter((id) => !orderedIdSet.has(id));
|
||||
const newIds = uniqueAllTabIds.filter((id) => !orderedIdSet.has(id));
|
||||
return [...orderedIds, ...newIds];
|
||||
}
|
||||
|
||||
export function reorderWorkTabIds(
|
||||
tabOrder: readonly string[],
|
||||
allTabIds: readonly string[],
|
||||
draggedId: string,
|
||||
targetId: string,
|
||||
position: 'before' | 'after' = 'before',
|
||||
): string[] {
|
||||
if (draggedId === targetId) return buildOrderedWorkTabIds(tabOrder, allTabIds);
|
||||
|
||||
const currentOrder = buildOrderedWorkTabIds(tabOrder, allTabIds);
|
||||
const draggedIndex = currentOrder.indexOf(draggedId);
|
||||
const targetIndex = currentOrder.indexOf(targetId);
|
||||
if (draggedIndex === -1 || targetIndex === -1) return [...tabOrder];
|
||||
|
||||
currentOrder.splice(draggedIndex, 1);
|
||||
|
||||
let nextTargetIndex = targetIndex;
|
||||
if (draggedIndex < targetIndex) {
|
||||
nextTargetIndex -= 1;
|
||||
}
|
||||
if (position === 'after') {
|
||||
nextTargetIndex += 1;
|
||||
}
|
||||
|
||||
currentOrder.splice(nextTargetIndex, 0, draggedId);
|
||||
return currentOrder;
|
||||
}
|
||||
|
||||
export function isHostTreeWorkTabSurface({
|
||||
enabled,
|
||||
activeTabId,
|
||||
@@ -85,3 +126,28 @@ export function resolveWorkTabActiveHostId({
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveWorkTabHostTreeTheme({
|
||||
activeHostId,
|
||||
accentMode,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
}: {
|
||||
activeHostId: string | null;
|
||||
accentMode: 'theme' | 'custom';
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
customAccent: string;
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: ReadonlyMap<string, Host>;
|
||||
themeById: ReadonlyMap<string, TerminalTheme>;
|
||||
}): TerminalTheme {
|
||||
if (!activeHostId || followAppTerminalTheme) return currentTerminalTheme;
|
||||
|
||||
const host = hostById.get(activeHostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}
|
||||
@@ -71,6 +71,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.codex.notFoundHint': 'Could not find codex in PATH. Install it or specify the executable path below.',
|
||||
'ai.codex.customPathPlaceholder': 'e.g. /usr/local/bin/codex',
|
||||
'ai.codex.check': 'Check',
|
||||
'ai.codex.resetPath': 'Reset',
|
||||
'ai.codex.openLogin': 'Open Login',
|
||||
'ai.codex.logout': 'Logout',
|
||||
'ai.codex.connectChatGPT': 'Connect ChatGPT',
|
||||
@@ -96,6 +97,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': 'One KEY=VALUE per line, passed to the Claude agent. Stored locally in plaintext — for API keys / credentials, prefer the config directory above (a `claude` login).',
|
||||
'ai.claude.check': 'Check',
|
||||
'ai.claude.resetPath': 'Reset',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
@@ -107,6 +109,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.copilot.notFoundHint': 'Could not find copilot in PATH. Install it or specify the executable path below.',
|
||||
'ai.copilot.customPathPlaceholder': 'e.g. /usr/local/bin/copilot',
|
||||
'ai.copilot.check': 'Check',
|
||||
'ai.copilot.resetPath': 'Reset',
|
||||
|
||||
// AI Cursor SDK
|
||||
'ai.cursor.title': 'Cursor',
|
||||
@@ -146,6 +149,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.codebuddy.notFoundHint': 'Could not find codebuddy in PATH. Install it or specify the executable path below.',
|
||||
'ai.codebuddy.customPathPlaceholder': 'e.g. /usr/local/bin/codebuddy',
|
||||
'ai.codebuddy.check': 'Check',
|
||||
'ai.codebuddy.resetPath': 'Reset',
|
||||
'ai.codebuddy.configSection': 'Authentication & config (optional)',
|
||||
'ai.codebuddy.internetEnv': 'Internet Environment',
|
||||
'ai.codebuddy.internetEnv.default': 'Default (overseas)',
|
||||
@@ -156,6 +160,18 @@ export const enAiMessages: Messages = {
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': 'One KEY=VALUE per line, passed to the CodeBuddy agent. Set CODEBUDDY_API_KEY or CODEBUDDY_AUTH_TOKEN here for authentication. Stored locally in plaintext.',
|
||||
|
||||
// AI OpenCode
|
||||
'ai.opencode.title': 'OpenCode',
|
||||
'ai.opencode.description': 'Uses OpenCode via the official SDK. Configure providers and keys in OpenCode, then select it as an external coding agent.',
|
||||
'ai.opencode.detecting': 'Detecting...',
|
||||
'ai.opencode.detected': 'Detected',
|
||||
'ai.opencode.notFound': 'Not found',
|
||||
'ai.opencode.path': 'Path:',
|
||||
'ai.opencode.notFoundHint': 'Could not find opencode in PATH. Install it or specify the executable path below.',
|
||||
'ai.opencode.customPathPlaceholder': 'e.g. /usr/local/bin/opencode',
|
||||
'ai.opencode.check': 'Check',
|
||||
'ai.opencode.resetPath': 'Reset',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': 'Default Agent',
|
||||
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
|
||||
@@ -316,6 +332,7 @@ export const enAiMessages: Messages = {
|
||||
'terminal.layer.scripts': 'Scripts',
|
||||
'terminal.layer.history': 'History',
|
||||
'terminal.layer.theme': 'Theme',
|
||||
'terminal.layer.notes': 'Notes',
|
||||
'terminal.layer.aiChat': 'AI Chat',
|
||||
'terminal.layer.movePanelLeft': 'Move panel to left',
|
||||
'terminal.layer.movePanelRight': 'Move panel to right',
|
||||
|
||||
@@ -144,6 +144,17 @@ export const enCoreMessages: Messages = {
|
||||
'settings.update.autoUpdateEnabled': 'Automatic Updates',
|
||||
'settings.update.autoUpdateEnabledDesc': 'Automatically check and download updates when available.',
|
||||
|
||||
// Settings > Session Restore
|
||||
'settings.sessionRestore.title': 'Session Restore',
|
||||
'settings.sessionRestore.restorePreviousSession': 'Restore previous tabs and workspaces',
|
||||
'settings.sessionRestore.restorePreviousSessionDesc': 'Reopen terminal tabs and workspace layouts from the last app session.',
|
||||
'settings.sessionRestore.restoreTerminalCwd': 'Restore terminal working directory',
|
||||
'settings.sessionRestore.restoreTerminalCwdDesc': 'When reconnecting a restored terminal, try to return to its last known directory without blocking the connection.',
|
||||
'terminal.restore.placeholder.title': 'Restored session',
|
||||
'terminal.restore.placeholder.desc': 'This terminal is disconnected. Reconnect when you are ready to start a new shell for this tab.',
|
||||
'terminal.restore.placeholder.reconnect': 'Reconnect',
|
||||
'terminal.restore.cwdLog': 'Restoring working directory: {cwd}',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': 'Session Logs',
|
||||
'settings.sessionLogs.description': 'Configure session log export and auto-save settings.',
|
||||
@@ -312,8 +323,19 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.font.size.desc': 'Terminal text size',
|
||||
'settings.terminal.font.weight': 'Font weight',
|
||||
'settings.terminal.font.weight.desc': 'Weight for regular text (100-900)',
|
||||
'settings.terminal.font.weight.thin': 'Thin',
|
||||
'settings.terminal.font.weight.extraLight': 'Extra Light',
|
||||
'settings.terminal.font.weight.light': 'Light',
|
||||
'settings.terminal.font.weight.normal': 'Normal',
|
||||
'settings.terminal.font.weight.medium': 'Medium',
|
||||
'settings.terminal.font.weight.semiBold': 'Semi Bold',
|
||||
'settings.terminal.font.weight.bold': 'Bold',
|
||||
'settings.terminal.font.weight.extraBold': 'Extra Bold',
|
||||
'settings.terminal.font.weight.black': 'Black',
|
||||
'settings.terminal.font.weightBold': 'Bold font weight',
|
||||
'settings.terminal.font.weightBold.desc': 'Weight for bold text (100-900)',
|
||||
'settings.terminal.font.smoothing': 'Font smoothing',
|
||||
'settings.terminal.font.smoothing.desc': 'Use native macOS font anti-aliasing',
|
||||
'settings.terminal.font.linePadding': 'Line padding',
|
||||
'settings.terminal.font.linePadding.desc': 'Additional space between lines (0-10)',
|
||||
'settings.terminal.font.emulationType': 'Terminal emulation type',
|
||||
@@ -457,6 +479,18 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.rendering.renderer': 'Renderer',
|
||||
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use DOM on low-memory devices. Changes take effect on new terminal sessions.',
|
||||
'settings.terminal.rendering.auto': 'Auto',
|
||||
'settings.terminal.rendering.hibernateHiddenTabs': 'Hibernate hidden tabs',
|
||||
'settings.terminal.rendering.hibernateHiddenTabs.desc': 'Dispose the terminal renderer for off-screen tabs to save memory while keeping the SSH session connected. Skipped during file transfers.',
|
||||
'settings.terminal.rendering.hibernateHiddenTabsDelay': 'Hibernate delay',
|
||||
'settings.terminal.rendering.hibernateHiddenTabsDelay.desc': 'How long a tab must stay off-screen before its renderer is released (5–600 seconds).',
|
||||
'settings.terminal.rendering.hibernateSkipAltScreen': 'Skip hibernate on full-screen apps',
|
||||
'settings.terminal.rendering.hibernateSkipAltScreen.desc': 'Keep the terminal renderer alive while vim, htop, or agent TUIs own the alternate screen buffer.',
|
||||
'settings.terminal.rendering.hibernateKeepRendererCount': 'Keep hidden renderers',
|
||||
'settings.terminal.rendering.hibernateKeepRendererCount.desc': 'How many off-screen tabs keep their renderer alive (WebGL suspended) before full hibernate.',
|
||||
'settings.terminal.rendering.hibernateReplayChunkBytes': 'Wake replay chunk size',
|
||||
'settings.terminal.rendering.hibernateReplayChunkBytes.desc': 'Bytes replayed per animation frame when restoring a hibernated terminal (4–64 KB).',
|
||||
'settings.terminal.rendering.hibernatePreferWasmSerialize': 'Prefer WASM serialize',
|
||||
'settings.terminal.rendering.hibernatePreferWasmSerialize.desc': 'Use WASM terminal serialization when available; falls back to the JS serializer.',
|
||||
|
||||
// Settings > Terminal > Workspace Focus Indicator
|
||||
'settings.terminal.section.workspaceFocus': 'Workspace Focus Indicator',
|
||||
@@ -552,7 +586,7 @@ export const enCoreMessages: Messages = {
|
||||
'sync.autoSync.emptyVaultConflict.restoreDesc': 'Recommended — recover your hosts, keys, and snippets from the cloud backup',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmpty': 'Keep Empty',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': 'Start fresh with an empty vault',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} hosts, {keys} keys, {snippets} snippets, {proxyProfiles} proxies',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} hosts, {keys} keys, {snippets} snippets, {notes} notes, {proxyProfiles} proxies',
|
||||
'sync.autoSync.emptyVaultManual': 'Cannot sync: the local vault is empty. Restore from a local backup or enable Force Push in the sync panel first.',
|
||||
|
||||
'sync.blocked.title': 'Sync paused',
|
||||
@@ -589,6 +623,7 @@ export const enCoreMessages: Messages = {
|
||||
'vault.nav.proxies': 'Proxies',
|
||||
'vault.nav.portForwarding': 'Port Forwarding',
|
||||
'vault.nav.snippets': 'Snippets',
|
||||
'vault.nav.notes': 'Notes',
|
||||
'vault.nav.knownHosts': 'Known Hosts',
|
||||
'vault.nav.logs': 'Logs',
|
||||
|
||||
@@ -689,5 +724,6 @@ export const enCoreMessages: Messages = {
|
||||
'vault.hosts.errors.nameRequired': 'Host name is required.',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
|
||||
'deepLink.ssh.invalid': 'Invalid ssh:// link',
|
||||
|
||||
};
|
||||
@@ -11,6 +11,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.toolbar.more': 'More actions',
|
||||
'terminal.toolbar.scripts': 'Scripts',
|
||||
'terminal.toolbar.history': 'Command history',
|
||||
'terminal.toolbar.configureOsc7': 'Configure directory tracking',
|
||||
'history.scope.label': 'History scope',
|
||||
'history.tab.host': 'Host',
|
||||
'history.tab.global': 'Global',
|
||||
@@ -51,6 +52,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.composeBar.snippetClickHint': 'Click to insert · Shift+Click to send',
|
||||
'terminal.toolbar.focus': 'Focus',
|
||||
'terminal.toolbar.focusMode': 'Focus Mode',
|
||||
'terminal.toolbar.detach': 'Detach to standalone tab',
|
||||
'terminal.toolbar.encoding': 'Terminal Encoding',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
@@ -101,6 +103,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.search.nextMatch': 'Next match (Enter)',
|
||||
'terminal.menu.copy': 'Copy',
|
||||
'terminal.menu.paste': 'Paste',
|
||||
'terminal.menu.uploadClipboardImage': 'Upload clipboard image',
|
||||
'terminal.menu.addSelectionToAI': 'Add to Conversation',
|
||||
'terminal.menu.pasteSelection': 'Paste Selection',
|
||||
'terminal.menu.selectAll': 'Select All',
|
||||
@@ -111,6 +114,17 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.menu.splitVertical': 'Split Vertical',
|
||||
'terminal.menu.clearBuffer': 'Clear Buffer',
|
||||
'terminal.menu.closeTerminal': 'Close terminal',
|
||||
'terminal.menu.rename': 'Rename',
|
||||
'terminal.menu.detach': 'Detach from workspace',
|
||||
'terminal.menu.detachSession': 'Detach {name}',
|
||||
'terminal.clipboardImageUpload.noImage': 'Clipboard does not contain an image',
|
||||
'terminal.clipboardImageUpload.failed': 'Could not upload clipboard image',
|
||||
'terminal.osc7Setup.title': 'Configure directory tracking',
|
||||
'terminal.osc7Setup.desc': 'Netcatty will add OSC 7 prompt hooks for the current remote user. This helps SFTP follow the terminal directory after sudo or su.',
|
||||
'terminal.osc7Setup.targets': 'Possible files to update',
|
||||
'terminal.osc7Setup.command': 'Command to run',
|
||||
'terminal.osc7Setup.run': 'Run setup',
|
||||
'terminal.osc7Setup.sent': 'Directory tracking setup sent to terminal',
|
||||
'terminal.ymodem.selectFile': 'Select file to send',
|
||||
'terminal.ymodem.allFiles': 'All files',
|
||||
'terminal.ymodem.started': 'YMODEM sending {fileName}',
|
||||
@@ -330,7 +344,7 @@ export const enTerminalMessages: Messages = {
|
||||
'cloudSync.localBackups.reason.appVersionChange': 'Before app version change',
|
||||
'cloudSync.localBackups.reason.beforeRestore': 'Before restore',
|
||||
'cloudSync.localBackups.versionChange': '{from} -> {to}',
|
||||
'cloudSync.localBackups.counts': '{hosts} hosts, {keys} keys, {snippets} snippets',
|
||||
'cloudSync.localBackups.counts': '{hosts} hosts, {keys} keys, {snippets} snippets, {notes} notes',
|
||||
'cloudSync.localBackups.restore': 'Restore',
|
||||
'cloudSync.localBackups.restoreSuccess': 'Local backup restored.',
|
||||
'cloudSync.localBackups.restoreFailedTitle': 'Restore failed',
|
||||
@@ -355,6 +369,7 @@ export const enTerminalMessages: Messages = {
|
||||
'cloudSync.revisionHistory.hosts': 'Hosts',
|
||||
'cloudSync.revisionHistory.keys': 'Keys',
|
||||
'cloudSync.revisionHistory.snippets': 'Snippets',
|
||||
'cloudSync.revisionHistory.notes': 'Notes',
|
||||
'cloudSync.revisionHistory.identities': 'Identities',
|
||||
'cloudSync.revisionHistory.restoreButton': 'Restore This Version',
|
||||
'cloudSync.revisionHistory.restored': 'Vault restored from selected revision.',
|
||||
@@ -408,6 +423,8 @@ export const enTerminalMessages: Messages = {
|
||||
'cloudSync.conflict.entity.identities': 'Identities',
|
||||
'cloudSync.conflict.entity.proxyProfiles': 'Proxy profiles',
|
||||
'cloudSync.conflict.entity.snippets': 'Snippets',
|
||||
'cloudSync.conflict.entity.notes': 'Notes',
|
||||
'cloudSync.conflict.entity.noteGroups': 'Note groups',
|
||||
'cloudSync.conflict.entity.customGroups': 'Groups',
|
||||
'cloudSync.conflict.entity.snippetPackages': 'Snippet packages',
|
||||
'cloudSync.conflict.entity.portForwardingRules': 'Port forwarding',
|
||||
@@ -559,6 +576,8 @@ export const enTerminalMessages: Messages = {
|
||||
'snippets.searchPlaceholder': 'Search snippets...',
|
||||
'snippets.action.newSnippet': 'New Snippet',
|
||||
'snippets.action.newPackage': 'New Package',
|
||||
'snippets.action.import': 'Import',
|
||||
'snippets.action.selectSnippets': 'Select snippets',
|
||||
'snippets.panel.newTitle': 'New Snippet',
|
||||
'snippets.panel.editTitle': 'Edit Snippet',
|
||||
'snippets.field.description': 'Action description',
|
||||
@@ -596,6 +615,34 @@ export const enTerminalMessages: Messages = {
|
||||
'snippets.commandFallback': 'Command',
|
||||
'snippets.view.grid': 'Grid',
|
||||
'snippets.view.list': 'List',
|
||||
'snippets.selection.selected': '{count} selected',
|
||||
'snippets.selection.selectVisible': 'Select visible',
|
||||
'snippets.selection.deselectAll': 'Deselect all',
|
||||
'snippets.selection.exportSelected': 'Export selected ({count})',
|
||||
'snippets.export.snippet': 'Export snippet',
|
||||
'snippets.export.package': 'Export package',
|
||||
'snippets.export.toast.empty': 'No snippets to export.',
|
||||
'snippets.export.toast.successTitle': 'Export ready',
|
||||
'snippets.export.toast.success': 'Exported {count} snippet(s).',
|
||||
'snippets.import.toast.empty': 'No importable snippets found.',
|
||||
'snippets.import.toast.failedTitle': 'Import failed',
|
||||
'snippets.import.toast.invalidDesc': 'This is not a valid Netcatty snippets file.',
|
||||
'snippets.import.toast.successTitle': 'Import completed',
|
||||
'snippets.import.toast.summary': 'Imported {imported}, overwritten {overwritten}, skipped {skipped}.',
|
||||
'snippets.import.modal.title': 'Import snippets',
|
||||
'snippets.import.modal.desc': 'Choose one or more Netcatty snippets JSON files. Netcatty will preview them before anything is imported.',
|
||||
'snippets.import.modal.exampleTitle': 'Example JSON',
|
||||
'snippets.import.modal.noFile': 'No file selected yet. Use the example format below, or a plain JSON array of snippets, then choose one or more files.',
|
||||
'snippets.import.modal.chooseFile': 'Choose file',
|
||||
'snippets.import.modal.downloadExamples': 'Download samples',
|
||||
'snippets.import.modal.multipleFiles': '{count} files selected',
|
||||
'snippets.import.modal.parsedSummary': '{files} file(s), {total} snippet(s), {packages} package(s), {conflicts} duplicate command(s). Host bindings are ignored.',
|
||||
'snippets.import.modal.confirm': 'Confirm import',
|
||||
'snippets.import.conflict.title': 'Import snippets?',
|
||||
'snippets.import.conflict.desc': '{file} contains {total} snippet(s). {conflicts} duplicate command(s) already exist.',
|
||||
'snippets.import.conflict.hostBindingsNote': 'Host bindings are not imported or exported with snippets.',
|
||||
'snippets.import.conflict.skip': 'Skip duplicates',
|
||||
'snippets.import.conflict.overwrite': 'Overwrite duplicates',
|
||||
'snippets.packageDialog.title': 'New Package',
|
||||
'snippets.packageDialog.parent': 'Parent: {parent}',
|
||||
'snippets.packageDialog.root': 'Root',
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enVaultMessages: Messages = {
|
||||
// Notes
|
||||
'notes.search.placeholder': 'Search notes, hosts, tags...',
|
||||
'notes.action.newNote': 'New Note',
|
||||
'notes.action.newGroup': 'New Folder',
|
||||
'notes.action.importMarkdown': 'Import Markdown',
|
||||
'notes.import.toast.success': 'Imported {count} note(s)',
|
||||
'notes.import.toast.noNotes': 'No Markdown files were imported.',
|
||||
'notes.import.toast.failed': 'Failed to import Markdown files.',
|
||||
'notes.import.toast.inProgress': 'A Markdown import is already in progress.',
|
||||
'notes.import.toast.skipped': 'Skipped {count} unsupported file(s).',
|
||||
'notes.codeBlock.copied': 'Copied',
|
||||
'notes.codeBlock.copyFailed': 'Failed to copy code block.',
|
||||
'notes.group.prompt': 'Folder path',
|
||||
'notes.group.all': 'Notebook',
|
||||
'notes.group.ungrouped': 'Ungrouped',
|
||||
'notes.section.notebooks': 'Notebook',
|
||||
'notes.section.notes': 'Notes',
|
||||
'notes.mode.edit': 'Edit',
|
||||
'notes.mode.split': 'Split',
|
||||
'notes.mode.preview': 'Preview',
|
||||
'notes.title.placeholder': 'Note title',
|
||||
'notes.editor.placeholder': 'Write Markdown notes here...',
|
||||
'notes.preview.empty': 'Nothing to preview yet.',
|
||||
'notes.empty.title': 'No notes yet',
|
||||
'notes.empty.desc': 'Create Markdown notes for runbooks, reminders, and server handoff details.',
|
||||
'notes.empty.group': 'No notes in this notebook.',
|
||||
'notes.search.noResults': 'No notes found.',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': 'Add data to your vault',
|
||||
'vault.import.desc':
|
||||
@@ -101,8 +129,9 @@ export const enVaultMessages: Messages = {
|
||||
'pf.form.openWizardTitle': 'Open Port Forwarding Wizard',
|
||||
'pf.view.grid': 'Grid',
|
||||
'pf.view.list': 'List',
|
||||
'pf.rule.summary.local': 'Local {bindAddress}:{localPort} -> SSH tunnel -> {remoteHost}:{remotePort}',
|
||||
'pf.rule.summary.remote': 'Remote {bindAddress}:{localPort} -> SSH tunnel -> local {remoteHost}:{remotePort}',
|
||||
'pf.rule.summary.dynamic': 'SOCKS on {bindAddress}:{localPort}',
|
||||
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
|
||||
'pf.tooltip.relayHost': 'Relay Host',
|
||||
'pf.tooltip.hostLabel': 'Host',
|
||||
'pf.tooltip.hostAddress': 'Address',
|
||||
@@ -151,6 +180,9 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.moveTo.pathNotFound': 'Directory not found or inaccessible',
|
||||
'sftp.context.download': 'Download',
|
||||
'sftp.context.copyToOtherPane': 'Copy to other pane',
|
||||
'sftp.copyCurrentPath': 'Copy current path',
|
||||
'sftp.copyCurrentPath.success': 'Current path copied',
|
||||
'sftp.copyCurrentPath.error': 'Could not copy current path',
|
||||
'sftp.viewMode.label': 'View mode',
|
||||
'sftp.viewMode.list': 'List view',
|
||||
'sftp.viewMode.tree': 'Tree view',
|
||||
@@ -344,6 +376,9 @@ export const enVaultMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transfer': 'Transfer to other pane',
|
||||
'settings.sftp.doubleClickBehavior.openDesc': 'Open the file in the default application',
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host',
|
||||
'settings.sshDeepLink.title': 'SSH links',
|
||||
'settings.sshDeepLink.enable': 'Open ssh:// links with Netcatty',
|
||||
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// links from browsers and other apps.',
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': 'Auto-sync to remote',
|
||||
@@ -465,6 +500,63 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.section.appearance': 'Appearance',
|
||||
'hostDetails.distro.title': 'Linux Distribution',
|
||||
'hostDetails.distro.desc': 'Auto-detect on connect, or override the distro icon manually.',
|
||||
'hostDetails.icon.title': 'Host Icon',
|
||||
'hostDetails.icon.sectionTitle': 'Color & Icon',
|
||||
'hostDetails.icon.desc': 'Choose the icon source and color for this host.',
|
||||
'hostDetails.icon.source': 'Source',
|
||||
'hostDetails.icon.manualLabel': 'Manual icon',
|
||||
'hostDetails.icon.tab.brand': 'Brand',
|
||||
'hostDetails.icon.tab.type': 'Type',
|
||||
'hostDetails.icon.mode.auto': 'Automatic',
|
||||
'hostDetails.icon.mode.custom': 'Custom',
|
||||
'hostDetails.icon.reset': 'Reset host icon',
|
||||
'hostDetails.icon.colorLabel': 'Icon color',
|
||||
'hostDetails.icon.colorDefault': 'Default',
|
||||
'hostDetails.icon.colorAuto': 'Automatic',
|
||||
'hostDetails.icon.colorManual': 'Manual',
|
||||
'hostDetails.icon.colorChoice': 'Color',
|
||||
'hostDetails.icon.autoColorLabel': 'Current color',
|
||||
'hostDetails.icon.showLibrary': 'Show icon library',
|
||||
'hostDetails.icon.hideLibrary': 'Hide icon library',
|
||||
'hostDetails.icon.autoUsesDistro': 'Use Linux Distribution icon and selected color for this host.',
|
||||
'hostDetails.icon.customOverridesDistro': 'Built-in icon replaces Linux Distribution for this host.',
|
||||
'hostDetails.icon.option.server': 'Server',
|
||||
'hostDetails.icon.option.terminal': 'Terminal',
|
||||
'hostDetails.icon.option.database': 'Database',
|
||||
'hostDetails.icon.option.cloud': 'Cloud',
|
||||
'hostDetails.icon.option.router': 'Router',
|
||||
'hostDetails.icon.option.shield': 'Shield',
|
||||
'hostDetails.icon.option.code': 'Code',
|
||||
'hostDetails.icon.option.box': 'Box',
|
||||
'hostDetails.icon.option.globe': 'Globe',
|
||||
'hostDetails.icon.option.cpu': 'CPU',
|
||||
'hostDetails.icon.option.hard-drive': 'Storage',
|
||||
'hostDetails.icon.option.network': 'Network',
|
||||
'hostDetails.icon.option.wifi': 'Wireless',
|
||||
'hostDetails.icon.option.lock': 'Lock',
|
||||
'hostDetails.icon.option.key': 'Key',
|
||||
'hostDetails.icon.option.monitor': 'Monitor',
|
||||
'hostDetails.icon.option.container': 'Container',
|
||||
'hostDetails.icon.option.activity': 'Activity',
|
||||
'hostDetails.icon.option.zap': 'Fast',
|
||||
'hostDetails.icon.option.server-cog': 'Server settings',
|
||||
'hostDetails.icon.color.blue': 'Blue',
|
||||
'hostDetails.icon.color.green': 'Green',
|
||||
'hostDetails.icon.color.red': 'Red',
|
||||
'hostDetails.icon.color.amber': 'Amber',
|
||||
'hostDetails.icon.color.purple': 'Purple',
|
||||
'hostDetails.icon.color.cyan': 'Cyan',
|
||||
'hostDetails.icon.color.orange': 'Orange',
|
||||
'hostDetails.icon.color.slate': 'Slate',
|
||||
'hostDetails.icon.color.violet': 'Violet',
|
||||
'hostDetails.icon.color.pink': 'Pink',
|
||||
'hostDetails.icon.color.rose': 'Rose',
|
||||
'hostDetails.icon.color.lime': 'Lime',
|
||||
'hostDetails.icon.color.teal': 'Teal',
|
||||
'hostDetails.icon.color.sky': 'Sky',
|
||||
'hostDetails.icon.color.indigo': 'Indigo',
|
||||
'hostDetails.icon.color.zinc': 'Zinc',
|
||||
'hostDetails.icon.color.custom': 'Custom color',
|
||||
'hostDetails.distro.mode': 'Source',
|
||||
'hostDetails.distro.mode.auto': 'Auto-detect',
|
||||
'hostDetails.distro.mode.manual': 'Manual override',
|
||||
@@ -485,12 +577,14 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
|
||||
'hostDetails.distro.option.openeuler': 'openEuler',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': 'Cisco',
|
||||
'hostDetails.distro.option.juniper': 'Juniper Networks',
|
||||
'hostDetails.distro.option.huawei': 'Huawei',
|
||||
'hostDetails.distro.option.hpe': 'HPE / H3C',
|
||||
'hostDetails.distro.option.h3c': 'H3C',
|
||||
'hostDetails.distro.option.hpe': 'HPE',
|
||||
'hostDetails.distro.option.mikrotik': 'MikroTik',
|
||||
'hostDetails.distro.option.fortinet': 'Fortinet',
|
||||
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
|
||||
@@ -534,6 +628,8 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.section.terminalBehavior': 'Terminal Behavior',
|
||||
'hostDetails.lineTimestamps': 'Show output timestamps',
|
||||
'hostDetails.lineTimestamps.desc': 'Show local time beside visible output lines for this host without changing terminal text.',
|
||||
'hostDetails.disableDynamicTabTitle': 'Disable dynamic tab title',
|
||||
'hostDetails.disableDynamicTabTitle.desc': 'Keep the connection name on session tabs instead of following the shell-reported window title.',
|
||||
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
|
||||
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
|
||||
|
||||
@@ -71,6 +71,7 @@ export const ruAiMessages: Messages = {
|
||||
'ai.codex.notFoundHint': 'Не удалось найти codex в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
|
||||
'ai.codex.customPathPlaceholder': 'например, /usr/local/bin/codex',
|
||||
'ai.codex.check': 'Проверить',
|
||||
'ai.codex.resetPath': 'Сбросить',
|
||||
'ai.codex.openLogin': 'Открыть вход',
|
||||
'ai.codex.logout': 'Выйти',
|
||||
'ai.codex.connectChatGPT': 'Подключить ChatGPT',
|
||||
@@ -96,6 +97,7 @@ export const ruAiMessages: Messages = {
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': 'По одному KEY=VALUE в строке, передаётся агенту Claude. Хранится локально в открытом виде — для API-ключей и учётных данных используйте «Каталог конфигурации» выше (вход `claude`).',
|
||||
'ai.claude.check': 'Проверить',
|
||||
'ai.claude.resetPath': 'Сбросить',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
@@ -107,6 +109,7 @@ export const ruAiMessages: Messages = {
|
||||
'ai.copilot.notFoundHint': 'Не удалось найти copilot в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
|
||||
'ai.copilot.customPathPlaceholder': 'например, /usr/local/bin/copilot',
|
||||
'ai.copilot.check': 'Проверить',
|
||||
'ai.copilot.resetPath': 'Сбросить',
|
||||
|
||||
// AI Cursor SDK
|
||||
'ai.cursor.title': 'Cursor',
|
||||
@@ -146,6 +149,7 @@ export const ruAiMessages: Messages = {
|
||||
'ai.codebuddy.notFoundHint': 'Не удалось найти codebuddy в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
|
||||
'ai.codebuddy.customPathPlaceholder': 'например, /usr/local/bin/codebuddy',
|
||||
'ai.codebuddy.check': 'Проверить',
|
||||
'ai.codebuddy.resetPath': 'Сбросить',
|
||||
'ai.codebuddy.configSection': 'Аутентификация и конфигурация (необязательно)',
|
||||
'ai.codebuddy.internetEnv': 'Сетевая среда',
|
||||
'ai.codebuddy.internetEnv.default': 'По умолчанию (зарубежная)',
|
||||
@@ -316,6 +320,7 @@ export const ruAiMessages: Messages = {
|
||||
'terminal.layer.scripts': 'Скрипты',
|
||||
'terminal.layer.history': 'История',
|
||||
'terminal.layer.theme': 'Тема',
|
||||
'terminal.layer.notes': 'Заметки',
|
||||
'terminal.layer.aiChat': 'AI-чат',
|
||||
'terminal.layer.movePanelLeft': 'Переместить панель влево',
|
||||
'terminal.layer.movePanelRight': 'Переместить панель вправо',
|
||||
|
||||
@@ -312,8 +312,19 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.font.size.desc': 'Размер текста терминала',
|
||||
'settings.terminal.font.weight': 'Толщина шрифта',
|
||||
'settings.terminal.font.weight.desc': 'Толщина обычного текста (100-900)',
|
||||
'settings.terminal.font.weight.thin': 'Тонкий',
|
||||
'settings.terminal.font.weight.extraLight': 'Очень светлый',
|
||||
'settings.terminal.font.weight.light': 'Светлый',
|
||||
'settings.terminal.font.weight.normal': 'Обычный',
|
||||
'settings.terminal.font.weight.medium': 'Средний',
|
||||
'settings.terminal.font.weight.semiBold': 'Полужирный',
|
||||
'settings.terminal.font.weight.bold': 'Жирный',
|
||||
'settings.terminal.font.weight.extraBold': 'Очень жирный',
|
||||
'settings.terminal.font.weight.black': 'Максимально жирный',
|
||||
'settings.terminal.font.weightBold': 'Толщина жирного шрифта',
|
||||
'settings.terminal.font.weightBold.desc': 'Толщина жирного текста (100-900)',
|
||||
'settings.terminal.font.smoothing': 'Сглаживание шрифта',
|
||||
'settings.terminal.font.smoothing.desc': 'Использовать родное сглаживание шрифтов macOS',
|
||||
'settings.terminal.font.linePadding': 'Межстрочный отступ',
|
||||
'settings.terminal.font.linePadding.desc': 'Дополнительное пространство между строками (0-10)',
|
||||
'settings.terminal.font.emulationType': 'Тип эмуляции терминала',
|
||||
@@ -500,6 +511,7 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.shortcuts.binding.next-tab': 'Следующая вкладка',
|
||||
'settings.shortcuts.binding.prev-tab': 'Предыдущая вкладка',
|
||||
'settings.shortcuts.binding.close-tab': 'Закрыть вкладку',
|
||||
'settings.shortcuts.binding.close-session': 'Закрыть панель сессии',
|
||||
'settings.shortcuts.binding.new-tab': 'Новая локальная вкладка',
|
||||
'settings.shortcuts.binding.copy': 'Копировать из терминала',
|
||||
'settings.shortcuts.binding.paste': 'Вставить в терминал',
|
||||
@@ -507,9 +519,13 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.shortcuts.binding.select-all': 'Выделить всё содержимое терминала',
|
||||
'settings.shortcuts.binding.clear-buffer': 'Очистить буфер терминала',
|
||||
'settings.shortcuts.binding.search-terminal': 'Открыть поиск по терминалу',
|
||||
'settings.shortcuts.binding.increase-terminal-font-size': 'Увеличить размер шрифта терминала',
|
||||
'settings.shortcuts.binding.decrease-terminal-font-size': 'Уменьшить размер шрифта терминала',
|
||||
'settings.shortcuts.binding.reset-terminal-font-size': 'Сбросить размер шрифта терминала',
|
||||
'settings.shortcuts.binding.move-focus': 'Переместить фокус между разделёнными окнами',
|
||||
'settings.shortcuts.binding.split-horizontal': 'Горизонтальное разделение',
|
||||
'settings.shortcuts.binding.split-vertical': 'Вертикальное разделение',
|
||||
'settings.shortcuts.binding.toggle-pane-zoom': 'Переключить масштаб панели',
|
||||
'settings.shortcuts.binding.open-hosts': 'Открыть список хостов',
|
||||
'settings.shortcuts.binding.open-local': 'Открыть локальный терминал',
|
||||
'settings.shortcuts.binding.open-sftp': 'Открыть SFTP',
|
||||
@@ -589,7 +605,7 @@ export const ruCoreMessages: Messages = {
|
||||
'sync.autoSync.emptyVaultConflict.restoreDesc': 'Рекомендуется — восстановить ваши хосты, ключи и сниппеты из облачной резервной копии',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmpty': 'Оставить пустым',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': 'Начать заново с пустым хранилищем',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} хостов, {keys} ключей, {snippets} сниппетов, {proxyProfiles} прокси',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} хостов, {keys} ключей, {snippets} сниппетов, {notes} заметок, {proxyProfiles} прокси',
|
||||
'sync.autoSync.emptyVaultManual': 'Синхронизация невозможна: локальное хранилище пусто. Сначала восстановите его из локальной резервной копии или включите принудительную отправку в панели синхронизации.',
|
||||
|
||||
'sync.blocked.title': 'Синхронизация приостановлена',
|
||||
@@ -626,6 +642,7 @@ export const ruCoreMessages: Messages = {
|
||||
'vault.nav.proxies': 'Прокси',
|
||||
'vault.nav.portForwarding': 'Проброс портов',
|
||||
'vault.nav.snippets': 'Сниппеты',
|
||||
'vault.nav.notes': 'Заметки',
|
||||
'vault.nav.knownHosts': 'Известные хосты',
|
||||
'vault.nav.logs': 'Журналы',
|
||||
|
||||
@@ -689,5 +706,6 @@ export const ruCoreMessages: Messages = {
|
||||
|
||||
'vault.hosts.header.entries': 'Записей: {count}',
|
||||
'vault.hosts.header.live': 'Активных: {count}',
|
||||
'deepLink.ssh.invalid': 'Недопустимая ссылка ssh://',
|
||||
|
||||
};
|
||||
@@ -32,6 +32,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.toolbar.more': 'Другие действия',
|
||||
'terminal.toolbar.scripts': 'Скрипты',
|
||||
'terminal.toolbar.history': 'История команд',
|
||||
'terminal.toolbar.configureOsc7': 'Настроить отслеживание каталога',
|
||||
'history.scope.label': 'Область истории',
|
||||
'history.tab.host': 'Хост',
|
||||
'history.tab.global': 'Глобальная',
|
||||
@@ -72,6 +73,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.composeBar.snippetClickHint': 'Клик — вставить · Shift+клик — отправить',
|
||||
'terminal.toolbar.focus': 'Фокус',
|
||||
'terminal.toolbar.focusMode': 'Режим фокуса',
|
||||
'terminal.toolbar.detach': 'Открепить в отдельную вкладку',
|
||||
'terminal.toolbar.encoding': 'Кодировка терминала',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
@@ -122,6 +124,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.search.nextMatch': 'Следующее совпадение (Enter)',
|
||||
'terminal.menu.copy': 'Копировать',
|
||||
'terminal.menu.paste': 'Вставить',
|
||||
'terminal.menu.uploadClipboardImage': 'Загрузить изображение из буфера',
|
||||
'terminal.menu.addSelectionToAI': 'Добавить в чат',
|
||||
'terminal.menu.pasteSelection': 'Вставить выделенное',
|
||||
'terminal.menu.selectAll': 'Выбрать всё',
|
||||
@@ -132,6 +135,15 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.menu.splitVertical': 'Разделить по вертикали',
|
||||
'terminal.menu.clearBuffer': 'Очистить буфер',
|
||||
'terminal.menu.closeTerminal': 'Закрыть терминал',
|
||||
'terminal.menu.rename': 'Переименовать',
|
||||
'terminal.menu.detach': 'Открепить из рабочей области',
|
||||
'terminal.menu.detachSession': 'Открепить {name}',
|
||||
'terminal.osc7Setup.title': 'Настроить отслеживание каталога',
|
||||
'terminal.osc7Setup.desc': 'Netcatty добавит хуки OSC 7 для текущего удалённого пользователя. Это помогает SFTP следовать за каталогом терминала после sudo или su.',
|
||||
'terminal.osc7Setup.targets': 'Возможные файлы для обновления',
|
||||
'terminal.osc7Setup.command': 'Команда для запуска',
|
||||
'terminal.osc7Setup.run': 'Запустить настройку',
|
||||
'terminal.osc7Setup.sent': 'Настройка отслеживания каталога отправлена в терминал',
|
||||
'terminal.ymodem.selectFile': 'Выберите файл для отправки',
|
||||
'terminal.ymodem.allFiles': 'Все файлы',
|
||||
'terminal.ymodem.started': 'YMODEM отправляет {fileName}',
|
||||
@@ -144,6 +156,8 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.ymodem.receiveEmpty': 'Файлы YMODEM не получены',
|
||||
'terminal.ymodem.receiveFailed': 'Не удалось получить через YMODEM',
|
||||
'terminal.ymodem.unavailable': 'YMODEM недоступен',
|
||||
'terminal.clipboardImageUpload.noImage': 'В буфере обмена нет изображения',
|
||||
'terminal.clipboardImageUpload.failed': 'Не удалось загрузить изображение из буфера обмена',
|
||||
'terminal.selection.addToAI': 'Добавить в чат',
|
||||
'terminal.selection.addToAIDesc': 'Прикрепить выбранный вывод терминала к черновику AI',
|
||||
'terminal.auth.password': 'Пароль',
|
||||
@@ -348,7 +362,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'cloudSync.localBackups.reason.appVersionChange': 'Перед сменой версии приложения',
|
||||
'cloudSync.localBackups.reason.beforeRestore': 'Перед восстановлением',
|
||||
'cloudSync.localBackups.versionChange': '{from} -> {to}',
|
||||
'cloudSync.localBackups.counts': '{hosts} хостов, {keys} ключей, {snippets} сниппетов',
|
||||
'cloudSync.localBackups.counts': '{hosts} хостов, {keys} ключей, {snippets} сниппетов, {notes} заметок',
|
||||
'cloudSync.localBackups.restore': 'Восстановить',
|
||||
'cloudSync.localBackups.restoreSuccess': 'Локальная резервная копия восстановлена.',
|
||||
'cloudSync.localBackups.restoreFailedTitle': 'Ошибка восстановления',
|
||||
@@ -373,6 +387,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'cloudSync.revisionHistory.hosts': 'Хосты',
|
||||
'cloudSync.revisionHistory.keys': 'Ключи',
|
||||
'cloudSync.revisionHistory.snippets': 'Сниппеты',
|
||||
'cloudSync.revisionHistory.notes': 'Заметки',
|
||||
'cloudSync.revisionHistory.identities': 'Идентификаторы',
|
||||
'cloudSync.revisionHistory.restoreButton': 'Восстановить эту версию',
|
||||
'cloudSync.revisionHistory.restored': 'Хранилище восстановлено из выбранной ревизии.',
|
||||
@@ -426,6 +441,8 @@ export const ruTerminalMessages: Messages = {
|
||||
'cloudSync.conflict.entity.identities': 'Идентификаторы',
|
||||
'cloudSync.conflict.entity.proxyProfiles': 'Профили прокси',
|
||||
'cloudSync.conflict.entity.snippets': 'Сниппеты',
|
||||
'cloudSync.conflict.entity.notes': 'Заметки',
|
||||
'cloudSync.conflict.entity.noteGroups': 'Группы заметок',
|
||||
'cloudSync.conflict.entity.customGroups': 'Группы',
|
||||
'cloudSync.conflict.entity.snippetPackages': 'Пакеты сниппетов',
|
||||
'cloudSync.conflict.entity.portForwardingRules': 'Проброс портов',
|
||||
@@ -574,6 +591,8 @@ export const ruTerminalMessages: Messages = {
|
||||
'snippets.searchPlaceholder': 'Поиск сниппетов...',
|
||||
'snippets.action.newSnippet': 'Новый сниппет',
|
||||
'snippets.action.newPackage': 'Новый пакет',
|
||||
'snippets.action.import': 'Импорт',
|
||||
'snippets.action.selectSnippets': 'Выбрать сниппеты',
|
||||
'snippets.panel.newTitle': 'Новый сниппет',
|
||||
'snippets.panel.editTitle': 'Редактировать сниппет',
|
||||
'snippets.field.description': 'Описание действия',
|
||||
@@ -622,6 +641,34 @@ export const ruTerminalMessages: Messages = {
|
||||
'snippets.commandFallback': 'Команда',
|
||||
'snippets.view.grid': 'Сетка',
|
||||
'snippets.view.list': 'Список',
|
||||
'snippets.selection.selected': 'Выбрано: {count}',
|
||||
'snippets.selection.selectVisible': 'Выбрать видимые',
|
||||
'snippets.selection.deselectAll': 'Снять выбор',
|
||||
'snippets.selection.exportSelected': 'Экспорт выбранных ({count})',
|
||||
'snippets.export.snippet': 'Экспорт сниппета',
|
||||
'snippets.export.package': 'Экспорт пакета',
|
||||
'snippets.export.toast.empty': 'Нет сниппетов для экспорта.',
|
||||
'snippets.export.toast.successTitle': 'Экспорт готов',
|
||||
'snippets.export.toast.success': 'Экспортировано сниппетов: {count}.',
|
||||
'snippets.import.toast.empty': 'Не найдено сниппетов для импорта.',
|
||||
'snippets.import.toast.failedTitle': 'Импорт не удался',
|
||||
'snippets.import.toast.invalidDesc': 'Это невалидный файл сниппетов Netcatty.',
|
||||
'snippets.import.toast.successTitle': 'Импорт завершён',
|
||||
'snippets.import.toast.summary': 'Импортировано: {imported}, перезаписано: {overwritten}, пропущено: {skipped}.',
|
||||
'snippets.import.modal.title': 'Импорт сниппетов',
|
||||
'snippets.import.modal.desc': 'Выберите один или несколько JSON-файлов сниппетов Netcatty. Netcatty сначала покажет результат разбора и ничего не импортирует без подтверждения.',
|
||||
'snippets.import.modal.exampleTitle': 'Пример JSON',
|
||||
'snippets.import.modal.noFile': 'Файл ещё не выбран. Используйте пример ниже или обычный JSON-массив сниппетов, затем выберите один или несколько файлов.',
|
||||
'snippets.import.modal.chooseFile': 'Выбрать файл',
|
||||
'snippets.import.modal.downloadExamples': 'Скачать примеры',
|
||||
'snippets.import.modal.multipleFiles': 'Выбрано файлов: {count}',
|
||||
'snippets.import.modal.parsedSummary': '{files} файл(ов), {total} сниппет(ов), {packages} пакет(ов), повторяющихся команд: {conflicts}. Привязки к хостам игнорируются.',
|
||||
'snippets.import.modal.confirm': 'Подтвердить импорт',
|
||||
'snippets.import.conflict.title': 'Импортировать сниппеты?',
|
||||
'snippets.import.conflict.desc': 'Файл «{file}» содержит {total} сниппет(ов), из них повторяющихся команд: {conflicts}.',
|
||||
'snippets.import.conflict.hostBindingsNote': 'Привязки сниппетов к хостам не импортируются и не экспортируются.',
|
||||
'snippets.import.conflict.skip': 'Пропустить повторы',
|
||||
'snippets.import.conflict.overwrite': 'Перезаписать повторы',
|
||||
'snippets.packageDialog.title': 'Новый пакет',
|
||||
'snippets.packageDialog.parent': 'Родитель: {parent}',
|
||||
'snippets.packageDialog.root': 'Корень',
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruVaultMessages: Messages = {
|
||||
// Notes
|
||||
'notes.search.placeholder': 'Поиск заметок, хостов, тегов...',
|
||||
'notes.action.newNote': 'Новая заметка',
|
||||
'notes.action.newGroup': 'Новая папка',
|
||||
'notes.action.importMarkdown': 'Импорт Markdown',
|
||||
'notes.import.toast.success': 'Импортировано заметок: {count}',
|
||||
'notes.import.toast.noNotes': 'Markdown-файлы не были импортированы.',
|
||||
'notes.import.toast.failed': 'Не удалось импортировать Markdown-файлы.',
|
||||
'notes.import.toast.inProgress': 'Импорт Markdown уже выполняется.',
|
||||
'notes.import.toast.skipped': 'Пропущено неподдерживаемых файлов: {count}.',
|
||||
'notes.codeBlock.copied': 'Скопировано',
|
||||
'notes.codeBlock.copyFailed': 'Не удалось скопировать блок кода.',
|
||||
'notes.group.prompt': 'Путь папки',
|
||||
'notes.group.all': 'Блокнот',
|
||||
'notes.group.ungrouped': 'Без группы',
|
||||
'notes.section.notebooks': 'Блокнот',
|
||||
'notes.section.notes': 'Заметки',
|
||||
'notes.mode.edit': 'Правка',
|
||||
'notes.mode.split': 'Разделить',
|
||||
'notes.mode.preview': 'Просмотр',
|
||||
'notes.title.placeholder': 'Заголовок заметки',
|
||||
'notes.editor.placeholder': 'Пишите Markdown-заметки здесь...',
|
||||
'notes.preview.empty': 'Пока нечего просматривать.',
|
||||
'notes.empty.title': 'Заметок пока нет',
|
||||
'notes.empty.desc': 'Создавайте Markdown-заметки для инструкций, напоминаний и передачи серверов.',
|
||||
'notes.empty.group': 'В этом блокноте пока нет заметок.',
|
||||
'notes.search.noResults': 'Заметки не найдены.',
|
||||
|
||||
// Vault hosts header/actions
|
||||
'vault.hosts.search.placeholder': 'Найти хост или ssh user@hostname / ssh -p 2222 user@hostname...',
|
||||
'vault.hosts.connect': 'Подключиться',
|
||||
@@ -136,8 +164,9 @@ export const ruVaultMessages: Messages = {
|
||||
'pf.form.openWizardTitle': 'Открыть мастер проброса портов',
|
||||
'pf.view.grid': 'Сетка',
|
||||
'pf.view.list': 'Список',
|
||||
'pf.rule.summary.local': 'Локально {bindAddress}:{localPort} -> SSH-туннель -> удалённо {remoteHost}:{remotePort}',
|
||||
'pf.rule.summary.remote': 'Удалённо {bindAddress}:{localPort} -> SSH-туннель -> локально {remoteHost}:{remotePort}',
|
||||
'pf.rule.summary.dynamic': 'SOCKS на {bindAddress}:{localPort}',
|
||||
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
|
||||
'pf.tooltip.relayHost': 'Промежуточный хост',
|
||||
'pf.tooltip.hostLabel': 'Хост',
|
||||
'pf.tooltip.hostAddress': 'Адрес',
|
||||
@@ -186,6 +215,9 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.moveTo.pathNotFound': 'Каталог не найден или недоступен',
|
||||
'sftp.context.download': 'Скачать',
|
||||
'sftp.context.copyToOtherPane': 'Копировать в другую панель',
|
||||
'sftp.copyCurrentPath': 'Копировать текущий путь',
|
||||
'sftp.copyCurrentPath.success': 'Текущий путь скопирован',
|
||||
'sftp.copyCurrentPath.error': 'Не удалось скопировать текущий путь',
|
||||
'sftp.viewMode.label': 'Режим просмотра',
|
||||
'sftp.viewMode.list': 'Список',
|
||||
'sftp.viewMode.tree': 'Дерево',
|
||||
@@ -379,6 +411,9 @@ export const ruVaultMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transfer': 'Передать в другую панель',
|
||||
'settings.sftp.doubleClickBehavior.openDesc': 'Открыть файл в приложении по умолчанию',
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': 'Передать файл на активный хост другой панели',
|
||||
'settings.sshDeepLink.title': 'SSH-ссылки',
|
||||
'settings.sshDeepLink.enable': 'Открывать ssh:// ссылки в Netcatty',
|
||||
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// ссылки из браузеров и других приложений.',
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': 'Автосинхронизация с удалённым сервером',
|
||||
@@ -499,7 +534,64 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.section.portCredentials': 'Порт и учётные данные',
|
||||
'hostDetails.section.appearance': 'Внешний вид',
|
||||
'hostDetails.distro.title': 'Дистрибутив Linux',
|
||||
'hostDetails.distro.desc': 'Автоопределение при подключении или ручное переопределение значка дистрибутива.',
|
||||
'hostDetails.distro.desc': 'Автоопределение при подключении или ручная замена значка дистрибутива.',
|
||||
'hostDetails.icon.title': 'Значок хоста',
|
||||
'hostDetails.icon.sectionTitle': 'Цвет и значок',
|
||||
'hostDetails.icon.desc': 'Выберите источник значка и цвет для этого хоста.',
|
||||
'hostDetails.icon.source': 'Источник',
|
||||
'hostDetails.icon.manualLabel': 'Значок вручную',
|
||||
'hostDetails.icon.tab.brand': 'Бренд',
|
||||
'hostDetails.icon.tab.type': 'Тип',
|
||||
'hostDetails.icon.mode.auto': 'Авто',
|
||||
'hostDetails.icon.mode.custom': 'Свой',
|
||||
'hostDetails.icon.reset': 'Сбросить значок',
|
||||
'hostDetails.icon.colorLabel': 'Цвет значка',
|
||||
'hostDetails.icon.colorDefault': 'По умолчанию',
|
||||
'hostDetails.icon.colorAuto': 'Авто',
|
||||
'hostDetails.icon.colorManual': 'Вручную',
|
||||
'hostDetails.icon.colorChoice': 'Цвет',
|
||||
'hostDetails.icon.autoColorLabel': 'Текущий цвет',
|
||||
'hostDetails.icon.showLibrary': 'Показать библиотеку значков',
|
||||
'hostDetails.icon.hideLibrary': 'Скрыть библиотеку значков',
|
||||
'hostDetails.icon.autoUsesDistro': 'Использует значок дистрибутива Linux и выбранный цвет для этого хоста.',
|
||||
'hostDetails.icon.customOverridesDistro': 'Встроенный значок заменяет значок дистрибутива Linux для этого хоста.',
|
||||
'hostDetails.icon.option.server': 'Сервер',
|
||||
'hostDetails.icon.option.terminal': 'Терминал',
|
||||
'hostDetails.icon.option.database': 'База данных',
|
||||
'hostDetails.icon.option.cloud': 'Облако',
|
||||
'hostDetails.icon.option.router': 'Маршрутизатор',
|
||||
'hostDetails.icon.option.shield': 'Защита',
|
||||
'hostDetails.icon.option.code': 'Код',
|
||||
'hostDetails.icon.option.box': 'Узел',
|
||||
'hostDetails.icon.option.globe': 'Глобус',
|
||||
'hostDetails.icon.option.cpu': 'CPU',
|
||||
'hostDetails.icon.option.hard-drive': 'Хранилище',
|
||||
'hostDetails.icon.option.network': 'Сеть',
|
||||
'hostDetails.icon.option.wifi': 'Wi-Fi',
|
||||
'hostDetails.icon.option.lock': 'Замок',
|
||||
'hostDetails.icon.option.key': 'Ключ',
|
||||
'hostDetails.icon.option.monitor': 'Монитор',
|
||||
'hostDetails.icon.option.container': 'Контейнер',
|
||||
'hostDetails.icon.option.activity': 'Активность',
|
||||
'hostDetails.icon.option.zap': 'Быстрый',
|
||||
'hostDetails.icon.option.server-cog': 'Настройки сервера',
|
||||
'hostDetails.icon.color.blue': 'Синий',
|
||||
'hostDetails.icon.color.green': 'Зеленый',
|
||||
'hostDetails.icon.color.red': 'Красный',
|
||||
'hostDetails.icon.color.amber': 'Янтарный',
|
||||
'hostDetails.icon.color.purple': 'Фиолетовый',
|
||||
'hostDetails.icon.color.cyan': 'Голубой',
|
||||
'hostDetails.icon.color.orange': 'Оранжевый',
|
||||
'hostDetails.icon.color.slate': 'Серый',
|
||||
'hostDetails.icon.color.violet': 'Фиолетово-синий',
|
||||
'hostDetails.icon.color.pink': 'Розовый',
|
||||
'hostDetails.icon.color.rose': 'Розово-красный',
|
||||
'hostDetails.icon.color.lime': 'Лаймовый',
|
||||
'hostDetails.icon.color.teal': 'Бирюзовый',
|
||||
'hostDetails.icon.color.sky': 'Небесный',
|
||||
'hostDetails.icon.color.indigo': 'Индиго',
|
||||
'hostDetails.icon.color.zinc': 'Цинковый',
|
||||
'hostDetails.icon.color.custom': 'Свой цвет',
|
||||
'hostDetails.distro.mode': 'Источник',
|
||||
'hostDetails.distro.mode.auto': 'Автоопределение',
|
||||
'hostDetails.distro.mode.manual': 'Ручное переопределение',
|
||||
@@ -520,12 +612,14 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
|
||||
'hostDetails.distro.option.openeuler': 'openEuler',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': 'Cisco',
|
||||
'hostDetails.distro.option.juniper': 'Juniper Networks',
|
||||
'hostDetails.distro.option.huawei': 'Huawei',
|
||||
'hostDetails.distro.option.hpe': 'HPE / H3C',
|
||||
'hostDetails.distro.option.h3c': 'H3C',
|
||||
'hostDetails.distro.option.hpe': 'HPE',
|
||||
'hostDetails.distro.option.mikrotik': 'MikroTik',
|
||||
'hostDetails.distro.option.fortinet': 'Fortinet',
|
||||
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
|
||||
@@ -566,6 +660,8 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.section.terminalBehavior': 'Поведение терминала',
|
||||
'hostDetails.lineTimestamps': 'Показывать время вывода',
|
||||
'hostDetails.lineTimestamps.desc': 'Показывать локальное время рядом с видимыми строками вывода для этого хоста, не изменяя текст терминала.',
|
||||
'hostDetails.disableDynamicTabTitle': 'Отключить динамический заголовок вкладки',
|
||||
'hostDetails.disableDynamicTabTitle.desc': 'Показывать имя подключения на вкладках сессии вместо заголовка окна от shell.',
|
||||
'hostDetails.legacyAlgorithms': 'Разрешить устаревшие алгоритмы',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Включить устаревшие SSH-алгоритмы (diffie-hellman-group1, ssh-dss, 3des-cbc и т. д.) для подключения к старому сетевому оборудованию.',
|
||||
'hostDetails.legacyAlgorithms.warning': 'У этих алгоритмов есть известные слабые места безопасности. Включайте только для устаревших устройств, которые не поддерживают современную криптографию.',
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
|
||||
import { HOST_ICON_COLORS, HOST_ICON_IDS } from "../../../domain/hostIcon.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import ru from "./ru.ts";
|
||||
|
||||
const LOCALIZED_SETTINGS_LOCALES = [
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "ru", messages: ru },
|
||||
];
|
||||
|
||||
test("localized settings include names for every default shortcut", () => {
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = DEFAULT_KEY_BINDINGS
|
||||
.map((binding) => `settings.shortcuts.binding.${binding.id}`)
|
||||
.filter((key) => !locale.messages[key]);
|
||||
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing shortcut labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include workspace focus indicator labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.section.workspaceFocus",
|
||||
"settings.terminal.workspaceFocus.style",
|
||||
"settings.terminal.workspaceFocus.style.desc",
|
||||
"settings.terminal.workspaceFocus.dim",
|
||||
"settings.terminal.workspaceFocus.border",
|
||||
];
|
||||
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing workspace focus labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include terminal font weight option labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.font.weight.thin",
|
||||
"settings.terminal.font.weight.extraLight",
|
||||
"settings.terminal.font.weight.light",
|
||||
"settings.terminal.font.weight.normal",
|
||||
"settings.terminal.font.weight.medium",
|
||||
"settings.terminal.font.weight.semiBold",
|
||||
"settings.terminal.font.weight.bold",
|
||||
"settings.terminal.font.weight.extraBold",
|
||||
"settings.terminal.font.weight.black",
|
||||
];
|
||||
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing font weight labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized vault messages include host icon labels", () => {
|
||||
const keys = [
|
||||
"hostDetails.icon.title",
|
||||
"hostDetails.icon.desc",
|
||||
"hostDetails.icon.mode.auto",
|
||||
"hostDetails.icon.mode.custom",
|
||||
"hostDetails.icon.reset",
|
||||
"hostDetails.icon.showLibrary",
|
||||
"hostDetails.icon.hideLibrary",
|
||||
"hostDetails.icon.autoUsesDistro",
|
||||
"hostDetails.icon.customOverridesDistro",
|
||||
...HOST_ICON_IDS.map((id) => `hostDetails.icon.option.${id}`),
|
||||
...HOST_ICON_COLORS.map((color) => `hostDetails.icon.color.${color.id}`),
|
||||
];
|
||||
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing host icon labels`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import en from "./en.ts";
|
||||
import ru from "./ru.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
|
||||
const osc7Keys = [
|
||||
"terminal.toolbar.configureOsc7",
|
||||
"terminal.osc7Setup.title",
|
||||
"terminal.osc7Setup.desc",
|
||||
"terminal.osc7Setup.targets",
|
||||
"terminal.osc7Setup.command",
|
||||
"terminal.osc7Setup.run",
|
||||
"terminal.osc7Setup.sent",
|
||||
] as const;
|
||||
|
||||
test("OSC 7 setup copy exists in every bundled locale", () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, zhCN })) {
|
||||
for (const key of osc7Keys) {
|
||||
assert.equal(typeof messages[key], "string", `${locale} is missing ${key}`);
|
||||
assert.notEqual(messages[key], "", `${locale} has empty ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -71,6 +71,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.codex.notFoundHint': '在 PATH 中未找到 codex。请安装或在下方指定可执行文件路径。',
|
||||
'ai.codex.customPathPlaceholder': '例如 /usr/local/bin/codex',
|
||||
'ai.codex.check': '检查',
|
||||
'ai.codex.resetPath': '重置',
|
||||
'ai.codex.openLogin': '打开登录',
|
||||
'ai.codex.logout': '退出登录',
|
||||
'ai.codex.connectChatGPT': '连接 ChatGPT',
|
||||
@@ -96,6 +97,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': '每行一个 KEY=VALUE,传给 Claude agent。明文存在本地——API key/凭据建议用上面的「配置目录」(claude 登录),不要放这里。',
|
||||
'ai.claude.check': '检查',
|
||||
'ai.claude.resetPath': '重置',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
@@ -107,6 +109,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.copilot.notFoundHint': '在 PATH 中未找到 copilot。请安装或在下方指定可执行文件路径。',
|
||||
'ai.copilot.customPathPlaceholder': '例如 /usr/local/bin/copilot',
|
||||
'ai.copilot.check': '检查',
|
||||
'ai.copilot.resetPath': '重置',
|
||||
|
||||
// AI Cursor SDK
|
||||
'ai.cursor.title': 'Cursor',
|
||||
@@ -146,6 +149,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.codebuddy.notFoundHint': '在 PATH 中未找到 codebuddy。请安装或在下方指定可执行文件路径。',
|
||||
'ai.codebuddy.customPathPlaceholder': '例如 /usr/local/bin/codebuddy',
|
||||
'ai.codebuddy.check': '检查',
|
||||
'ai.codebuddy.resetPath': '重置',
|
||||
'ai.codebuddy.configSection': '认证与配置(可选)',
|
||||
'ai.codebuddy.internetEnv': '网络环境',
|
||||
'ai.codebuddy.internetEnv.default': '默认(海外)',
|
||||
@@ -156,6 +160,18 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': '每行一个 KEY=VALUE,传给 CodeBuddy agent。可在此设置 CODEBUDDY_API_KEY 或 CODEBUDDY_AUTH_TOKEN 完成认证。明文存在本地。',
|
||||
|
||||
// AI OpenCode
|
||||
'ai.opencode.title': 'OpenCode',
|
||||
'ai.opencode.description': '通过官方 SDK 接入 OpenCode。先在 OpenCode 里配置 provider 和密钥,检测到后即可作为外部编程 Agent 使用。',
|
||||
'ai.opencode.detecting': '检测中...',
|
||||
'ai.opencode.detected': '已检测到',
|
||||
'ai.opencode.notFound': '未找到',
|
||||
'ai.opencode.path': '路径:',
|
||||
'ai.opencode.notFoundHint': '在 PATH 中未找到 opencode。请安装或在下方指定可执行文件路径。',
|
||||
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
|
||||
'ai.opencode.check': '检查',
|
||||
'ai.opencode.resetPath': '重置',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': '默认 Agent',
|
||||
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
|
||||
@@ -316,6 +332,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'terminal.layer.scripts': '脚本',
|
||||
'terminal.layer.history': '命令历史',
|
||||
'terminal.layer.theme': '主题',
|
||||
'terminal.layer.notes': '笔记',
|
||||
'terminal.layer.aiChat': 'AI 助手',
|
||||
'terminal.layer.movePanelLeft': '面板移至左侧',
|
||||
'terminal.layer.movePanelRight': '面板移至右侧',
|
||||
|
||||
@@ -128,6 +128,17 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.update.autoUpdateEnabled': '自动更新',
|
||||
'settings.update.autoUpdateEnabledDesc': '有新版本时自动检查并下载更新。',
|
||||
|
||||
// Settings > Session Restore
|
||||
'settings.sessionRestore.title': '会话恢复',
|
||||
'settings.sessionRestore.restorePreviousSession': '恢复上次的标签页和工作区',
|
||||
'settings.sessionRestore.restorePreviousSessionDesc': '启动时重新打开上次的终端标签页和工作区布局。',
|
||||
'settings.sessionRestore.restoreTerminalCwd': '恢复终端工作目录',
|
||||
'settings.sessionRestore.restoreTerminalCwdDesc': '重新连接恢复的终端时,尝试回到上次记录的目录;失败时不会阻塞连接。',
|
||||
'terminal.restore.placeholder.title': '已恢复的会话',
|
||||
'terminal.restore.placeholder.desc': '此终端当前已断开。准备好后可以重新连接,为这个标签页启动新的 shell。',
|
||||
'terminal.restore.placeholder.reconnect': '重新连接',
|
||||
'terminal.restore.cwdLog': '正在恢复工作目录:{cwd}',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': '会话日志',
|
||||
'settings.sessionLogs.description': '配置会话日志导出和自动保存设置。',
|
||||
@@ -310,7 +321,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sync.autoSync.emptyVaultConflict.restoreDesc': '推荐 — 从云端备份恢复主机、密钥和代码片段',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmpty': '保持为空',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': '从头开始,使用空的主机库',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段,{proxyProfiles} 个代理',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段,{notes} 条笔记,{proxyProfiles} 个代理',
|
||||
'sync.autoSync.emptyVaultManual': '无法同步:本地 vault 为空。请先从本地备份恢复,或在同步面板里使用"强制推送"。',
|
||||
|
||||
'sync.blocked.title': '同步已暂停',
|
||||
@@ -347,6 +358,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.nav.proxies': '代理',
|
||||
'vault.nav.portForwarding': '端口转发',
|
||||
'vault.nav.snippets': '代码片段',
|
||||
'vault.nav.notes': '笔记',
|
||||
'vault.nav.knownHosts': '已知主机',
|
||||
'vault.nav.logs': '日志',
|
||||
|
||||
@@ -447,6 +459,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
|
||||
'deepLink.ssh.invalid': '无效的 ssh:// 链接',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': '添加数据到你的 Vault',
|
||||
@@ -573,6 +586,9 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.moveTo.pathNotFound': '目录不存在或无法访问',
|
||||
'sftp.context.download': '下载',
|
||||
'sftp.context.copyToOtherPane': '复制到另一侧',
|
||||
'sftp.copyCurrentPath': '复制当前路径',
|
||||
'sftp.copyCurrentPath.success': '已复制当前路径',
|
||||
'sftp.copyCurrentPath.error': '无法复制当前路径',
|
||||
'sftp.viewMode.label': '视图模式',
|
||||
'sftp.viewMode.list': '列表视图',
|
||||
'sftp.viewMode.tree': '树形视图',
|
||||
|
||||
@@ -2,6 +2,19 @@ import type { Messages } from '../types';
|
||||
|
||||
export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': '按 Enter 粘贴 sudo 密码',
|
||||
'terminal.menu.rename': '重命名',
|
||||
'terminal.menu.uploadClipboardImage': '上传剪贴板图片',
|
||||
'terminal.clipboardImageUpload.noImage': '剪贴板中没有图片',
|
||||
'terminal.clipboardImageUpload.failed': '无法上传剪贴板图片',
|
||||
'terminal.toolbar.configureOsc7': '配置目录追踪',
|
||||
'terminal.toolbar.detach': '移出到独立标签',
|
||||
'terminal.menu.detach': '从工作区移出',
|
||||
'terminal.osc7Setup.title': '配置目录追踪',
|
||||
'terminal.osc7Setup.desc': 'Netcatty 会为当前远端用户添加 OSC 7 提示符配置。这样在 sudo 或 su 之后,SFTP 也能继续跟随终端目录。',
|
||||
'terminal.osc7Setup.targets': '可能写入的文件',
|
||||
'terminal.osc7Setup.command': '将执行的命令',
|
||||
'terminal.osc7Setup.run': '执行配置',
|
||||
'terminal.osc7Setup.sent': '目录追踪配置已发送到终端',
|
||||
'terminal.toolbar.timestampsEnable': '显示时间戳',
|
||||
'terminal.toolbar.timestampsDisable': '隐藏时间戳',
|
||||
'terminal.connection.protocol.et': 'EternalTerminal',
|
||||
@@ -85,6 +98,9 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transfer': '传输到另一侧',
|
||||
'settings.sftp.doubleClickBehavior.openDesc': '使用默认应用程序打开文件',
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
|
||||
'settings.sshDeepLink.title': 'SSH 链接',
|
||||
'settings.sshDeepLink.enable': '用 Netcatty 打开 ssh:// 链接',
|
||||
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh:// 链接。',
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': '自动同步到远程',
|
||||
@@ -187,8 +203,19 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.font.size.desc': '终端文字大小',
|
||||
'settings.terminal.font.weight': '字重',
|
||||
'settings.terminal.font.weight.desc': '常规文本字重 (100-900)',
|
||||
'settings.terminal.font.weight.thin': '极细',
|
||||
'settings.terminal.font.weight.extraLight': '特细',
|
||||
'settings.terminal.font.weight.light': '细',
|
||||
'settings.terminal.font.weight.normal': '常规',
|
||||
'settings.terminal.font.weight.medium': '中等',
|
||||
'settings.terminal.font.weight.semiBold': '半粗',
|
||||
'settings.terminal.font.weight.bold': '粗',
|
||||
'settings.terminal.font.weight.extraBold': '特粗',
|
||||
'settings.terminal.font.weight.black': '黑体',
|
||||
'settings.terminal.font.weightBold': '粗体字重',
|
||||
'settings.terminal.font.weightBold.desc': '粗体文本字重 (100-900)',
|
||||
'settings.terminal.font.smoothing': '字体平滑',
|
||||
'settings.terminal.font.smoothing.desc': '使用 macOS 原生字体抗锯齿',
|
||||
'settings.terminal.font.linePadding': '行间距',
|
||||
'settings.terminal.font.linePadding.desc': '行之间的额外间距 (0-10)',
|
||||
'settings.terminal.font.emulationType': '终端仿真类型',
|
||||
@@ -324,6 +351,25 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.rendering.renderer': '渲染器',
|
||||
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 DOM 渲染。更改将在新终端会话中生效。',
|
||||
'settings.terminal.rendering.auto': '自动',
|
||||
'settings.terminal.rendering.hibernateHiddenTabs': '隐藏标签页休眠',
|
||||
'settings.terminal.rendering.hibernateHiddenTabs.desc': '标签页离开视野后释放终端渲染器以节省内存,SSH 连接保持不断。文件传输期间不会休眠。',
|
||||
'settings.terminal.rendering.hibernateHiddenTabsDelay': '休眠延迟',
|
||||
'settings.terminal.rendering.hibernateHiddenTabsDelay.desc': '标签页离开视野多久后释放渲染器(5–600 秒)。',
|
||||
'settings.terminal.rendering.hibernateSkipAltScreen': '全屏 TUI 跳过休眠',
|
||||
'settings.terminal.rendering.hibernateSkipAltScreen.desc': 'vim、htop 或 Agent TUI 占用 alternate screen 时保持终端渲染器,避免慢恢复。',
|
||||
'settings.terminal.rendering.hibernateKeepRendererCount': '保留隐藏渲染器数量',
|
||||
'settings.terminal.rendering.hibernateKeepRendererCount.desc': '多少个不可见标签页仅暂停 WebGL、不销毁 xterm,超出后才完整休眠。',
|
||||
'settings.terminal.rendering.hibernateReplayChunkBytes': '唤醒分片大小',
|
||||
'settings.terminal.rendering.hibernateReplayChunkBytes.desc': '恢复休眠终端时每帧回放的 ANSI 字节数(4–64 KB)。',
|
||||
'settings.terminal.rendering.hibernatePreferWasmSerialize': '优先 WASM 序列化',
|
||||
'settings.terminal.rendering.hibernatePreferWasmSerialize.desc': '在可用时使用 WASM 终端序列化,否则回退到 JS SerializeAddon。',
|
||||
|
||||
// Settings > Terminal > Workspace Focus Indicator
|
||||
'settings.terminal.section.workspaceFocus': '工作区焦点提示',
|
||||
'settings.terminal.workspaceFocus.style': '焦点提示样式',
|
||||
'settings.terminal.workspaceFocus.style.desc': '在分屏视图中如何标识当前聚焦的窗格。',
|
||||
'settings.terminal.workspaceFocus.dim': '淡化未聚焦窗格',
|
||||
'settings.terminal.workspaceFocus.border': '为聚焦窗格显示边框',
|
||||
|
||||
// Settings > Terminal > Autocomplete
|
||||
'settings.terminal.section.autocomplete': '自动补全',
|
||||
@@ -359,18 +405,25 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.shortcuts.binding.next-tab': '下一个标签页',
|
||||
'settings.shortcuts.binding.prev-tab': '上一个标签页',
|
||||
'settings.shortcuts.binding.close-tab': '关闭标签页',
|
||||
'settings.shortcuts.binding.close-session': '关闭会话窗格',
|
||||
'settings.shortcuts.binding.new-tab': '新建本地标签页',
|
||||
'settings.shortcuts.binding.copy': '从终端复制',
|
||||
'settings.shortcuts.binding.paste': '粘贴到终端',
|
||||
'settings.shortcuts.binding.paste-selection': '将选区粘贴到终端',
|
||||
'settings.shortcuts.binding.select-all': '全选终端内容',
|
||||
'settings.shortcuts.binding.clear-buffer': '清空终端缓冲区',
|
||||
'settings.shortcuts.binding.search-terminal': '打开终端搜索',
|
||||
'settings.shortcuts.binding.increase-terminal-font-size': '增大终端字号',
|
||||
'settings.shortcuts.binding.decrease-terminal-font-size': '减小终端字号',
|
||||
'settings.shortcuts.binding.reset-terminal-font-size': '重置终端字号',
|
||||
'settings.shortcuts.binding.move-focus': '在分屏间移动焦点',
|
||||
'settings.shortcuts.binding.split-horizontal': '水平分屏',
|
||||
'settings.shortcuts.binding.split-vertical': '垂直分屏',
|
||||
'settings.shortcuts.binding.toggle-pane-zoom': '切换窗格缩放',
|
||||
'settings.shortcuts.binding.open-hosts': '打开主机列表',
|
||||
'settings.shortcuts.binding.open-local': '打开本地终端',
|
||||
'settings.shortcuts.binding.open-sftp': '打开 SFTP',
|
||||
'settings.shortcuts.binding.open-settings': '打开设置',
|
||||
'settings.shortcuts.binding.port-forwarding': '打开端口转发',
|
||||
'settings.shortcuts.binding.command-palette': '打开命令面板',
|
||||
'settings.shortcuts.binding.quick-switch': '快速切换',
|
||||
@@ -386,6 +439,9 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.shortcuts.binding.sftp-delete': '删除文件',
|
||||
'settings.shortcuts.binding.sftp-refresh': '刷新',
|
||||
'settings.shortcuts.binding.sftp-new-folder': '新建文件夹',
|
||||
'settings.shortcuts.binding.sftp-open': '打开文件 / 进入目录',
|
||||
'settings.shortcuts.binding.sftp-go-parent': '转到上级目录',
|
||||
'settings.shortcuts.binding.sftp-navigate-to': '转到选中的目录',
|
||||
|
||||
// Host Details (sub-panels)
|
||||
'hostDetails.proxyPanel.title': '通过 HTTP/SOCKS5/命令代理',
|
||||
@@ -540,6 +596,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'snippets.searchPlaceholder': '搜索代码片段...',
|
||||
'snippets.action.newSnippet': '新建代码片段',
|
||||
'snippets.action.newPackage': '新建代码包',
|
||||
'snippets.action.import': '导入',
|
||||
'snippets.action.selectSnippets': '选择代码片段',
|
||||
'snippets.panel.newTitle': '新建代码片段',
|
||||
'snippets.panel.editTitle': '编辑代码片段',
|
||||
'snippets.field.description': '描述',
|
||||
@@ -577,6 +635,34 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'snippets.commandFallback': '命令',
|
||||
'snippets.view.grid': '网格',
|
||||
'snippets.view.list': '列表',
|
||||
'snippets.selection.selected': '已选择 {count} 个',
|
||||
'snippets.selection.selectVisible': '选择当前显示',
|
||||
'snippets.selection.deselectAll': '取消选择',
|
||||
'snippets.selection.exportSelected': '导出选中({count})',
|
||||
'snippets.export.snippet': '导出代码片段',
|
||||
'snippets.export.package': '导出代码包',
|
||||
'snippets.export.toast.empty': '没有可导出的代码片段。',
|
||||
'snippets.export.toast.successTitle': '导出已准备好',
|
||||
'snippets.export.toast.success': '已导出 {count} 个代码片段。',
|
||||
'snippets.import.toast.empty': '没有找到可导入的代码片段。',
|
||||
'snippets.import.toast.failedTitle': '导入失败',
|
||||
'snippets.import.toast.invalidDesc': '这不是有效的 Netcatty 代码片段文件。',
|
||||
'snippets.import.toast.successTitle': '导入完成',
|
||||
'snippets.import.toast.summary': '已导入 {imported} 个,覆盖 {overwritten} 个,跳过 {skipped} 个。',
|
||||
'snippets.import.modal.title': '导入代码片段',
|
||||
'snippets.import.modal.desc': '选择一个或多个 Netcatty 代码片段 JSON 文件。导入前会先解析并展示结果,不会直接写入。',
|
||||
'snippets.import.modal.exampleTitle': '示例 JSON',
|
||||
'snippets.import.modal.noFile': '还没有选择文件。可以参考下面的格式,也可以直接使用代码片段 JSON 数组,然后选择一个或多个文件。',
|
||||
'snippets.import.modal.chooseFile': '选择文件',
|
||||
'snippets.import.modal.downloadExamples': '下载示例文件',
|
||||
'snippets.import.modal.multipleFiles': '已选择 {count} 个文件',
|
||||
'snippets.import.modal.parsedSummary': '{files} 个文件,{total} 个代码片段,{packages} 个代码包,{conflicts} 个重复命令。主机绑定会被忽略。',
|
||||
'snippets.import.modal.confirm': '确认导入',
|
||||
'snippets.import.conflict.title': '导入代码片段?',
|
||||
'snippets.import.conflict.desc': '“{file}”包含 {total} 个代码片段,其中 {conflicts} 个命令已存在。',
|
||||
'snippets.import.conflict.hostBindingsNote': '代码片段的主机绑定不会被导入或导出。',
|
||||
'snippets.import.conflict.skip': '跳过重复项',
|
||||
'snippets.import.conflict.overwrite': '覆盖重复项',
|
||||
'snippets.packageDialog.title': '新建代码包',
|
||||
'snippets.packageDialog.parent': '父级:{parent}',
|
||||
'snippets.packageDialog.root': '根目录',
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNVaultMessages: Messages = {
|
||||
// Notes
|
||||
'notes.search.placeholder': '搜索笔记、主机、标签...',
|
||||
'notes.action.newNote': '新建笔记',
|
||||
'notes.action.newGroup': '新建文件夹',
|
||||
'notes.action.importMarkdown': '导入 Markdown',
|
||||
'notes.import.toast.success': '已导入 {count} 条笔记',
|
||||
'notes.import.toast.noNotes': '没有导入任何 Markdown 文件。',
|
||||
'notes.import.toast.failed': '导入 Markdown 文件失败。',
|
||||
'notes.import.toast.inProgress': '正在导入 Markdown 文件,请稍候。',
|
||||
'notes.import.toast.skipped': '已跳过 {count} 个不支持的文件。',
|
||||
'notes.codeBlock.copied': '已复制',
|
||||
'notes.codeBlock.copyFailed': '复制代码块失败。',
|
||||
'notes.group.prompt': '文件夹路径',
|
||||
'notes.group.all': '笔记本',
|
||||
'notes.group.ungrouped': '未分组',
|
||||
'notes.section.notebooks': '笔记本',
|
||||
'notes.section.notes': '笔记',
|
||||
'notes.mode.edit': '编辑',
|
||||
'notes.mode.split': '分屏',
|
||||
'notes.mode.preview': '预览',
|
||||
'notes.title.placeholder': '笔记标题',
|
||||
'notes.editor.placeholder': '在这里写 Markdown 笔记...',
|
||||
'notes.preview.empty': '暂无内容可预览。',
|
||||
'notes.empty.title': '还没有笔记',
|
||||
'notes.empty.desc': '可以记录运维手册、临时备忘、服务器交接信息。',
|
||||
'notes.empty.group': '这个笔记本里还没有笔记。',
|
||||
'notes.search.noResults': '没有找到笔记。',
|
||||
|
||||
// Select Host panel
|
||||
'selectHost.title': '选择主机',
|
||||
'selectHost.noHostsFound': '未找到主机',
|
||||
@@ -46,6 +74,63 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.section.appearance': '外观',
|
||||
'hostDetails.distro.title': 'Linux 发行版',
|
||||
'hostDetails.distro.desc': '可在连接后自动探测,也可以手动覆盖图标所用的发行版。',
|
||||
'hostDetails.icon.title': '主机图标',
|
||||
'hostDetails.icon.sectionTitle': '颜色与图标',
|
||||
'hostDetails.icon.desc': '选择此主机使用的图标来源和颜色。',
|
||||
'hostDetails.icon.source': '来源',
|
||||
'hostDetails.icon.manualLabel': '手动指定',
|
||||
'hostDetails.icon.tab.brand': '品牌',
|
||||
'hostDetails.icon.tab.type': '类型',
|
||||
'hostDetails.icon.mode.auto': '自动',
|
||||
'hostDetails.icon.mode.custom': '自定义',
|
||||
'hostDetails.icon.reset': '重置主机图标',
|
||||
'hostDetails.icon.colorLabel': '图标颜色',
|
||||
'hostDetails.icon.colorDefault': '默认',
|
||||
'hostDetails.icon.colorAuto': '自动',
|
||||
'hostDetails.icon.colorManual': '手动',
|
||||
'hostDetails.icon.colorChoice': '颜色',
|
||||
'hostDetails.icon.autoColorLabel': '当前颜色',
|
||||
'hostDetails.icon.showLibrary': '展开图标库',
|
||||
'hostDetails.icon.hideLibrary': '收起图标库',
|
||||
'hostDetails.icon.autoUsesDistro': '使用 Linux 发行版图标和所选颜色显示此主机。',
|
||||
'hostDetails.icon.customOverridesDistro': '内置图标会替换此主机的 Linux 发行版图标。',
|
||||
'hostDetails.icon.option.server': '服务器',
|
||||
'hostDetails.icon.option.terminal': '终端',
|
||||
'hostDetails.icon.option.database': '数据库',
|
||||
'hostDetails.icon.option.cloud': '云主机',
|
||||
'hostDetails.icon.option.router': '路由器',
|
||||
'hostDetails.icon.option.shield': '安全',
|
||||
'hostDetails.icon.option.code': '代码',
|
||||
'hostDetails.icon.option.box': '节点',
|
||||
'hostDetails.icon.option.globe': '公网',
|
||||
'hostDetails.icon.option.cpu': '计算',
|
||||
'hostDetails.icon.option.hard-drive': '存储',
|
||||
'hostDetails.icon.option.network': '网络',
|
||||
'hostDetails.icon.option.wifi': '无线',
|
||||
'hostDetails.icon.option.lock': '锁定',
|
||||
'hostDetails.icon.option.key': '密钥',
|
||||
'hostDetails.icon.option.monitor': '显示器',
|
||||
'hostDetails.icon.option.container': '容器',
|
||||
'hostDetails.icon.option.activity': '活动',
|
||||
'hostDetails.icon.option.zap': '高速',
|
||||
'hostDetails.icon.option.server-cog': '服务器设置',
|
||||
'hostDetails.icon.color.blue': '蓝色',
|
||||
'hostDetails.icon.color.green': '绿色',
|
||||
'hostDetails.icon.color.red': '红色',
|
||||
'hostDetails.icon.color.amber': '琥珀色',
|
||||
'hostDetails.icon.color.purple': '紫色',
|
||||
'hostDetails.icon.color.cyan': '青色',
|
||||
'hostDetails.icon.color.orange': '橙色',
|
||||
'hostDetails.icon.color.slate': '石板灰',
|
||||
'hostDetails.icon.color.violet': '紫罗兰',
|
||||
'hostDetails.icon.color.pink': '粉色',
|
||||
'hostDetails.icon.color.rose': '玫瑰红',
|
||||
'hostDetails.icon.color.lime': '青柠',
|
||||
'hostDetails.icon.color.teal': '蓝绿色',
|
||||
'hostDetails.icon.color.sky': '天蓝',
|
||||
'hostDetails.icon.color.indigo': '靛蓝',
|
||||
'hostDetails.icon.color.zinc': '锌灰',
|
||||
'hostDetails.icon.color.custom': '自定义颜色',
|
||||
'hostDetails.distro.mode': '来源',
|
||||
'hostDetails.distro.mode.auto': '自动探测',
|
||||
'hostDetails.distro.mode.manual': '手动覆盖',
|
||||
@@ -66,12 +151,14 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.alinux': '阿里云 Linux',
|
||||
'hostDetails.distro.option.openeuler': 'openEuler',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': '思科',
|
||||
'hostDetails.distro.option.juniper': '瞻博网络',
|
||||
'hostDetails.distro.option.huawei': '华为',
|
||||
'hostDetails.distro.option.hpe': '慧与 / H3C',
|
||||
'hostDetails.distro.option.h3c': '新华三 / H3C',
|
||||
'hostDetails.distro.option.hpe': '慧与 / HPE',
|
||||
'hostDetails.distro.option.mikrotik': 'MikroTik',
|
||||
'hostDetails.distro.option.fortinet': '飞塔',
|
||||
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
|
||||
@@ -115,6 +202,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.section.terminalBehavior': '终端行为',
|
||||
'hostDetails.lineTimestamps': '显示输出时间',
|
||||
'hostDetails.lineTimestamps.desc': '在终端输出行旁边显示本地时间,不改变终端文本内容。',
|
||||
'hostDetails.disableDynamicTabTitle': '禁用动态标签页标题',
|
||||
'hostDetails.disableDynamicTabTitle.desc': '标签页始终显示连接名称,不跟随 Shell 报告的窗口标题变化。',
|
||||
'hostDetails.legacyAlgorithms': '允许旧版算法',
|
||||
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法(diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
|
||||
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
|
||||
@@ -244,6 +333,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.composeBar.snippetClickHint': '单击插入 · Shift+单击直接发送',
|
||||
'terminal.toolbar.focus': '聚焦',
|
||||
'terminal.toolbar.focusMode': '聚焦模式',
|
||||
'terminal.toolbar.detach': '移出到独立标签',
|
||||
'terminal.toolbar.encoding': '终端编码',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
@@ -304,6 +394,9 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.menu.splitVertical': '垂直分屏',
|
||||
'terminal.menu.clearBuffer': '清空缓冲区',
|
||||
'terminal.menu.closeTerminal': '关闭终端',
|
||||
'terminal.menu.rename': '重命名',
|
||||
'terminal.menu.detach': '从工作区移出',
|
||||
'terminal.menu.detachSession': '移出 {name}',
|
||||
'terminal.ymodem.selectFile': '选择要发送的文件',
|
||||
'terminal.ymodem.allFiles': '所有文件',
|
||||
'terminal.ymodem.started': '正在通过 YMODEM 发送 {fileName}',
|
||||
@@ -520,7 +613,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'cloudSync.localBackups.reason.appVersionChange': '版本变化前',
|
||||
'cloudSync.localBackups.reason.beforeRestore': '恢复前',
|
||||
'cloudSync.localBackups.versionChange': '{from} -> {to}',
|
||||
'cloudSync.localBackups.counts': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段',
|
||||
'cloudSync.localBackups.counts': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段,{notes} 条笔记',
|
||||
'cloudSync.localBackups.restore': '恢复',
|
||||
'cloudSync.localBackups.restoreSuccess': '已恢复本地备份。',
|
||||
'cloudSync.localBackups.restoreFailedTitle': '恢复失败',
|
||||
@@ -545,6 +638,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'cloudSync.revisionHistory.hosts': '主机',
|
||||
'cloudSync.revisionHistory.keys': '密钥',
|
||||
'cloudSync.revisionHistory.snippets': '代码片段',
|
||||
'cloudSync.revisionHistory.notes': '笔记',
|
||||
'cloudSync.revisionHistory.identities': '身份',
|
||||
'cloudSync.revisionHistory.restoreButton': '恢复此版本',
|
||||
'cloudSync.revisionHistory.restored': '已从选中的修订恢复主机库数据。',
|
||||
@@ -597,6 +691,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'cloudSync.conflict.entity.identities': '身份',
|
||||
'cloudSync.conflict.entity.proxyProfiles': '代理配置',
|
||||
'cloudSync.conflict.entity.snippets': '片段',
|
||||
'cloudSync.conflict.entity.notes': '笔记',
|
||||
'cloudSync.conflict.entity.noteGroups': '笔记分组',
|
||||
'cloudSync.conflict.entity.customGroups': '分组',
|
||||
'cloudSync.conflict.entity.snippetPackages': '片段包',
|
||||
'cloudSync.conflict.entity.portForwardingRules': '端口转发',
|
||||
@@ -662,8 +758,9 @@ export const zhCNVaultMessages: Messages = {
|
||||
'pf.action.newForwarding': '新建转发',
|
||||
'pf.view.grid': '网格',
|
||||
'pf.view.list': '列表',
|
||||
'pf.rule.summary.local': '本机 {bindAddress}:{localPort} -> SSH 隧道 -> 远端 {remoteHost}:{remotePort}',
|
||||
'pf.rule.summary.remote': '远端 {bindAddress}:{localPort} -> SSH 隧道 -> 本机 {remoteHost}:{remotePort}',
|
||||
'pf.rule.summary.dynamic': 'SOCKS 监听于 {bindAddress}:{localPort}',
|
||||
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
|
||||
'pf.tooltip.relayHost': '中转主机',
|
||||
'pf.tooltip.hostLabel': '主机',
|
||||
'pf.tooltip.hostAddress': '地址',
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface LocalVaultBackupPreview {
|
||||
hostCount: number;
|
||||
keyCount: number;
|
||||
snippetCount: number;
|
||||
noteCount: number;
|
||||
identityCount: number;
|
||||
portForwardingRuleCount: number;
|
||||
};
|
||||
|
||||
@@ -65,7 +65,7 @@ test("pruneInactiveScopedTransientState removes closed workspace and terminal sc
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions preserves restorable terminal external session ids across reconnects", () => {
|
||||
test("pruneInactiveScopedSessions reports inactive targets without deleting persisted history", () => {
|
||||
const sessions = [
|
||||
createSession("terminal-restorable", {
|
||||
type: "terminal",
|
||||
@@ -98,10 +98,32 @@ test("pruneInactiveScopedSessions preserves restorable terminal external session
|
||||
"terminal-local",
|
||||
"workspace-closed",
|
||||
]);
|
||||
assert.deepEqual(next.sessions, [
|
||||
sessions[0],
|
||||
sessions[3],
|
||||
assert.equal(next.sessions, sessions);
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions preserves inactive workspace and local terminal history after restart", () => {
|
||||
const sessions = [
|
||||
createSession("terminal-local", {
|
||||
type: "terminal",
|
||||
targetId: "closed-local",
|
||||
hostIds: ["local-shell"],
|
||||
}, "ext-local"),
|
||||
createSession("workspace-closed", {
|
||||
type: "workspace",
|
||||
targetId: "closed-workspace",
|
||||
}, "ext-workspace"),
|
||||
];
|
||||
|
||||
const next = pruneInactiveScopedSessions(
|
||||
sessions,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
assert.deepEqual(next.orphanedSessionIds, [
|
||||
"terminal-local",
|
||||
"workspace-closed",
|
||||
]);
|
||||
assert.equal(next.sessions, sessions);
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions preserves original sessions when orphaned restorable chats are already detached", () => {
|
||||
|
||||
@@ -90,12 +90,6 @@ export function pruneInactiveScopedTransientState(
|
||||
};
|
||||
}
|
||||
|
||||
function isRestorableTerminalSession(session: AISession): boolean {
|
||||
return session.scope.type === "terminal"
|
||||
&& !!session.scope.hostIds?.length
|
||||
&& session.scope.hostIds.some((id) => !id.startsWith("local-") && !id.startsWith("serial-"));
|
||||
}
|
||||
|
||||
export function pruneInactiveScopedSessions(
|
||||
sessions: AISession[],
|
||||
activeTargetIds: Set<string>,
|
||||
@@ -123,23 +117,8 @@ export function pruneInactiveScopedSessions(
|
||||
};
|
||||
}
|
||||
|
||||
const orphanedSessionIdSet = new Set(orphanedSessionIds);
|
||||
let sessionsChanged = false;
|
||||
|
||||
const nextSessions = sessions.flatMap((session) => {
|
||||
if (!orphanedSessionIdSet.has(session.id)) {
|
||||
return [session];
|
||||
}
|
||||
|
||||
if (!isRestorableTerminalSession(session)) {
|
||||
sessionsChanged = true;
|
||||
return [];
|
||||
}
|
||||
return [session];
|
||||
});
|
||||
|
||||
return {
|
||||
sessions: sessionsChanged ? nextSessions : sessions,
|
||||
sessions,
|
||||
orphanedSessionIds,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveAutoSyncHashDecision } from "./autoSyncHashDecision.ts";
|
||||
|
||||
test("remote-applied data is skipped only when the current hash still matches it", () => {
|
||||
assert.equal(
|
||||
resolveAutoSyncHashDecision({
|
||||
currentHash: "remote-applied",
|
||||
lastSyncedHash: "old-local",
|
||||
appliedSkipHash: "remote-applied",
|
||||
}),
|
||||
"skip-applied",
|
||||
);
|
||||
});
|
||||
|
||||
test("user edits after remote apply still sync even when they match the old baseline", () => {
|
||||
assert.equal(
|
||||
resolveAutoSyncHashDecision({
|
||||
currentHash: "old-local",
|
||||
lastSyncedHash: "old-local",
|
||||
appliedSkipHash: "remote-applied",
|
||||
}),
|
||||
"sync",
|
||||
);
|
||||
});
|
||||
|
||||
test("unchanged data is skipped only when there is no pending remote-apply hash", () => {
|
||||
assert.equal(
|
||||
resolveAutoSyncHashDecision({
|
||||
currentHash: "same",
|
||||
lastSyncedHash: "same",
|
||||
appliedSkipHash: null,
|
||||
}),
|
||||
"unchanged",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
export type AutoSyncHashDecision = 'skip-applied' | 'unchanged' | 'sync';
|
||||
|
||||
export function resolveAutoSyncHashDecision({
|
||||
currentHash,
|
||||
lastSyncedHash,
|
||||
appliedSkipHash,
|
||||
}: {
|
||||
currentHash: string;
|
||||
lastSyncedHash: string;
|
||||
appliedSkipHash: string | null;
|
||||
}): AutoSyncHashDecision {
|
||||
if (appliedSkipHash !== null) {
|
||||
return currentHash === appliedSkipHash ? 'skip-applied' : 'sync';
|
||||
}
|
||||
return currentHash === lastSyncedHash ? 'unchanged' : 'sync';
|
||||
}
|
||||
@@ -1,31 +1,45 @@
|
||||
import type { SessionCapabilities } from '../../domain/systemManager/types';
|
||||
|
||||
/** Internal entry: capabilities plus computed expiry timestamp. */
|
||||
interface StoreEntry {
|
||||
capabilities: SessionCapabilities;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const capabilitiesBySessionId = new Map<string, SessionCapabilities>();
|
||||
const capabilitiesBySessionId = new Map<string, StoreEntry>();
|
||||
const listenersBySessionId = new Map<string, Set<Listener>>();
|
||||
|
||||
function isExpired(entry: StoreEntry): boolean {
|
||||
return Date.now() > entry.expiresAt;
|
||||
}
|
||||
|
||||
function notifySession(sessionId: string) {
|
||||
listenersBySessionId.get(sessionId)?.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export const sessionCapabilitiesStore = {
|
||||
get(sessionId: string): SessionCapabilities | undefined {
|
||||
return capabilitiesBySessionId.get(sessionId);
|
||||
const entry = capabilitiesBySessionId.get(sessionId);
|
||||
if (!entry) return undefined;
|
||||
if (isExpired(entry)) {
|
||||
capabilitiesBySessionId.delete(sessionId);
|
||||
notifySession(sessionId);
|
||||
return undefined;
|
||||
}
|
||||
return entry.capabilities;
|
||||
},
|
||||
|
||||
set(sessionId: string, capabilities: SessionCapabilities) {
|
||||
const prev = capabilitiesBySessionId.get(sessionId);
|
||||
if (
|
||||
prev
|
||||
&& prev.targetOs === capabilities.targetOs
|
||||
&& prev.hasTmux === capabilities.hasTmux
|
||||
&& prev.hasDocker === capabilities.hasDocker
|
||||
&& prev.probedAt === capabilities.probedAt
|
||||
) {
|
||||
return;
|
||||
}
|
||||
capabilitiesBySessionId.set(sessionId, capabilities);
|
||||
set(sessionId: string, capabilities: SessionCapabilities, ttlMs: number) {
|
||||
const entry: StoreEntry = {
|
||||
capabilities: {
|
||||
...capabilities,
|
||||
probedAt: Date.now(),
|
||||
},
|
||||
expiresAt: Date.now() + ttlMs,
|
||||
};
|
||||
capabilitiesBySessionId.set(sessionId, entry);
|
||||
notifySession(sessionId);
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
DEFAULT_RESTORE_TERMINAL_CWD,
|
||||
DEFAULT_RESTORE_PREVIOUS_SESSION,
|
||||
resolveRestoreTerminalCwdSetting,
|
||||
resolveRestorePreviousSessionSetting,
|
||||
} from "./sessionRestoreSettings.ts";
|
||||
|
||||
test("restore previous session setting defaults on", () => {
|
||||
assert.equal(DEFAULT_RESTORE_PREVIOUS_SESSION, true);
|
||||
assert.equal(resolveRestorePreviousSessionSetting(null), true);
|
||||
});
|
||||
|
||||
test("restore previous session setting preserves explicit stored values", () => {
|
||||
assert.equal(resolveRestorePreviousSessionSetting(true), true);
|
||||
assert.equal(resolveRestorePreviousSessionSetting(false), false);
|
||||
});
|
||||
|
||||
test("restore terminal cwd setting defaults off", () => {
|
||||
assert.equal(DEFAULT_RESTORE_TERMINAL_CWD, false);
|
||||
assert.equal(resolveRestoreTerminalCwdSetting(null), false);
|
||||
});
|
||||
|
||||
test("restore terminal cwd setting preserves explicit stored values", () => {
|
||||
assert.equal(resolveRestoreTerminalCwdSetting(true), true);
|
||||
assert.equal(resolveRestoreTerminalCwdSetting(false), false);
|
||||
});
|
||||
|
||||
test("restore previous session setting participates in cross-window settings sync", () => {
|
||||
const storageSyncSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
|
||||
const ipcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(storageSyncSource, /STORAGE_KEY_RESTORE_PREVIOUS_SESSION/);
|
||||
assert.match(storageSyncSource, /setRestorePreviousSessionState/);
|
||||
assert.match(storageSyncSource, /e\.key === STORAGE_KEY_RESTORE_PREVIOUS_SESSION/);
|
||||
|
||||
assert.match(ipcSyncSource, /STORAGE_KEY_RESTORE_PREVIOUS_SESSION/);
|
||||
assert.match(ipcSyncSource, /setRestorePreviousSessionState/);
|
||||
assert.match(ipcSyncSource, /key === STORAGE_KEY_RESTORE_PREVIOUS_SESSION/);
|
||||
});
|
||||
|
||||
test("disabling restore previous session clears the stored restore snapshot", () => {
|
||||
const settingsSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
|
||||
const importIndex = settingsSource.indexOf("sessionRestoreStorage");
|
||||
const setterIndex = settingsSource.indexOf("const setRestorePreviousSession = useCallback");
|
||||
const clearIndex = settingsSource.indexOf("sessionRestoreStorage.clear()", setterIndex);
|
||||
const writeIndex = settingsSource.indexOf("localStorageAdapter.writeBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION", setterIndex);
|
||||
|
||||
assert.notEqual(importIndex, -1);
|
||||
assert.notEqual(setterIndex, -1);
|
||||
assert.notEqual(clearIndex, -1);
|
||||
assert.notEqual(writeIndex, -1);
|
||||
assert.ok(
|
||||
writeIndex < clearIndex,
|
||||
"the setting should be persisted before clearing the restore snapshot",
|
||||
);
|
||||
});
|
||||
|
||||
test("session restore persistence re-arms when restore previous session is enabled", () => {
|
||||
const source = readFileSync(new URL("./useSessionState.ts", import.meta.url), "utf8");
|
||||
const adapterEventImportIndex = source.indexOf("LOCAL_STORAGE_ADAPTER_CHANGED_EVENT");
|
||||
const revisionStateIndex = source.indexOf("restorePreviousSessionRevision");
|
||||
const keyGuardIndex = source.indexOf("key !== STORAGE_KEY_RESTORE_PREVIOUS_SESSION");
|
||||
const listenerIndex = source.indexOf("addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT");
|
||||
const effectDependencyIndex = source.indexOf("restorePreviousSessionRevision, persistSessionRestore]", source.indexOf("beforeunload"));
|
||||
|
||||
assert.notEqual(adapterEventImportIndex, -1);
|
||||
assert.notEqual(revisionStateIndex, -1);
|
||||
assert.notEqual(keyGuardIndex, -1);
|
||||
assert.notEqual(listenerIndex, -1);
|
||||
assert.notEqual(effectDependencyIndex, -1);
|
||||
});
|
||||
|
||||
test("session restore persistence can be disabled for non-main windows", () => {
|
||||
const hookSource = readFileSync(new URL("./useSessionState.ts", import.meta.url), "utf8");
|
||||
const traySource = readFileSync(new URL("../../components/TrayPanel.tsx", import.meta.url), "utf8");
|
||||
const appSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
|
||||
const indexSource = readFileSync(new URL("../../index.tsx", import.meta.url), "utf8");
|
||||
const registerBridgesSource = readFileSync(new URL("../../electron/main/registerBridges.cjs", import.meta.url), "utf8");
|
||||
const mainWindowSource = readFileSync(new URL("../../electron/bridges/windowManager/mainWindow.cjs", import.meta.url), "utf8");
|
||||
|
||||
assert.match(hookSource, /persistSessionRestore\?: boolean/);
|
||||
assert.match(hookSource, /restoreEnabled: persistSessionRestore && resolveRestorePreviousSessionSetting/);
|
||||
assert.match(hookSource, /payload: persistSessionRestore \? sessionRestoreStorage\.read\(\) : null/);
|
||||
assert.match(hookSource, /if \(!persistSessionRestore\) return;/);
|
||||
assert.match(traySource, /useSessionState\(\{ persistSessionRestore: false \}\)/);
|
||||
assert.match(appSource, /window\.location\.hash\.startsWith\('#\/session-window'\)/);
|
||||
assert.match(appSource, /persistSessionRestore: !isPeerSessionWindow/);
|
||||
assert.match(indexSource, /hash === '#\/session-window'/);
|
||||
assert.match(registerBridgesSource, /route: "session-window"/);
|
||||
assert.match(registerBridgesSource, /registerAsMainWindow: false/);
|
||||
assert.match(mainWindowSource, /const rendererHash = typeof route === "string"/);
|
||||
assert.match(mainWindowSource, /registerAsMainWindow = true/);
|
||||
assert.match(mainWindowSource, /if \(registerAsMainWindow\)/);
|
||||
assert.match(mainWindowSource, /loadURL\(`\$\{getDevRendererBaseUrl\(devServerUrl\)\}\$\{rendererHash\}`\)/);
|
||||
assert.match(mainWindowSource, /loadURL\(`app:\/\/netcatty\/index\.html\$\{rendererHash\}`\)/);
|
||||
});
|
||||
|
||||
test("session peer windows do not run main-window startup effects", () => {
|
||||
const appSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
|
||||
const autoSyncSource = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
||||
const startupEffectsSource = readFileSync(new URL("../app/useAppStartupEffects.ts", import.meta.url), "utf8");
|
||||
const updateCheckSource = readFileSync(new URL("./useUpdateCheck.ts", import.meta.url), "utf8");
|
||||
const settingsStateSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
|
||||
const settingsIpcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
|
||||
const storageSyncSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
|
||||
const systemEffectsSource = readFileSync(new URL("./systemSettingsEffects.ts", import.meta.url), "utf8");
|
||||
const trayFocusIndex = appSource.indexOf("onTrayFocusSession");
|
||||
const trayPanelJumpIndex = appSource.indexOf("onTrayPanelJumpToSession");
|
||||
|
||||
assert.match(appSource, /const isPeerSessionWindow = typeof window !== 'undefined' && window\.location\.hash\.startsWith\('#\/session-window'\)/);
|
||||
assert.match(appSource, /useSettingsState\(\{[^}]*enableSettingsSync: !isPeerSessionWindow[^}]*enableSystemEffects: !isPeerSessionWindow/s);
|
||||
assert.match(appSource, /useAppStartupEffects\(\{[^}]*enabled: !isPeerSessionWindow/s);
|
||||
assert.match(appSource, /useUpdateCheck\(\{[^}]*enabled: !isPeerSessionWindow/s);
|
||||
assert.match(appSource, /if \(isPeerSessionWindow \|\| !isVaultInitialized \|\| versionBackupAttemptedRef\.current\) return;/);
|
||||
assert.match(appSource, /useAutoSync\(\{[^}]*enabled: !isPeerSessionWindow/s);
|
||||
assert.match(autoSyncSource, /enabled\?: boolean/);
|
||||
assert.match(autoSyncSource, /const enabled = config\.enabled !== false/);
|
||||
assert.match(autoSyncSource, /if \(!enabled\) return;/);
|
||||
assert.match(updateCheckSource, /enabled\?: boolean/);
|
||||
assert.match(updateCheckSource, /const enabled = options\?\.enabled !== false/);
|
||||
assert.match(updateCheckSource, /if \(!enabled\) return;/);
|
||||
assert.match(settingsStateSource, /enableSystemEffects\?: boolean/);
|
||||
assert.match(settingsStateSource, /enableSettingsSync\?: boolean/);
|
||||
assert.match(settingsStateSource, /const enableSettingsSync = options\.enableSettingsSync !== false/);
|
||||
assert.match(settingsStateSource, /useSettingsIpcSync\(\{[^}]*enabled: enableSettingsSync/s);
|
||||
assert.match(settingsStateSource, /useSettingsStorageSync\(\{[^}]*enabled: enableSettingsSync/s);
|
||||
assert.match(settingsStateSource, /if \(!enableSettingsSync\) return;\s*try \{\s*netcattyBridge\.get\(\)\?\.notifySettingsChanged/s);
|
||||
assert.match(settingsStateSource, /useSystemSettingsEffects\(\{[^}]*enabled: enableSystemEffects/s);
|
||||
assert.match(settingsIpcSyncSource, /enabled\?: boolean/);
|
||||
assert.match(settingsIpcSyncSource, /if \(!enabled\) return;/);
|
||||
assert.match(storageSyncSource, /enabled\?: boolean/);
|
||||
assert.match(storageSyncSource, /if \(!enabled\) return;/);
|
||||
assert.match(systemEffectsSource, /enabled\?: boolean/);
|
||||
assert.match(systemEffectsSource, /if \(!enabled\) return;/);
|
||||
assert.ok(
|
||||
appSource.lastIndexOf("if (isPeerSessionWindow) return;", trayFocusIndex) !== -1,
|
||||
"peer session windows should not register tray focus/toggle listeners",
|
||||
);
|
||||
assert.ok(
|
||||
appSource.lastIndexOf("if (isPeerSessionWindow) return;", trayPanelJumpIndex) !== -1,
|
||||
"peer session windows should not register tray panel listeners",
|
||||
);
|
||||
assert.match(startupEffectsSource, /enabled = true/);
|
||||
assert.match(startupEffectsSource, /if \(!enabled\) return;/);
|
||||
assert.match(startupEffectsSource, /export function shouldQueueKeyboardInteractiveRequest/);
|
||||
assert.match(startupEffectsSource, /request\.scope !== "terminal"/);
|
||||
assert.match(startupEffectsSource, /shouldQueueKeyboardInteractiveRequest\(request, sessionsRef\.current\)/);
|
||||
assert.doesNotMatch(
|
||||
startupEffectsSource,
|
||||
/if \(!enabled\) return;\s*const bridge = netcattyBridge\.get\(\);\s*if \(!bridge\?\.onCheckDirtyEditors\) return;/,
|
||||
"dirty editor quit guard must remain registered in peer session windows",
|
||||
);
|
||||
});
|
||||
|
||||
test("restore-only settings do not bump the cloud sync settings version", () => {
|
||||
const settingsSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
|
||||
const settingsVersionIndex = settingsSource.indexOf("settingsVersion: useMemo");
|
||||
const settingsVersionSource = settingsSource.slice(settingsVersionIndex);
|
||||
|
||||
assert.notEqual(settingsVersionIndex, -1);
|
||||
assert.doesNotMatch(settingsVersionSource, /restorePreviousSession|restoreTerminalCwd/);
|
||||
});
|
||||
|
||||
test("restore previous session re-arms after cross-window settings ipc sync", () => {
|
||||
const hookSource = readFileSync(new URL("./useSessionState.ts", import.meta.url), "utf8");
|
||||
const settingsIpcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(settingsIpcSyncSource, /STORAGE_KEY_RESTORE_PREVIOUS_SESSION/);
|
||||
assert.match(hookSource, /netcattyBridge/);
|
||||
assert.match(hookSource, /onSettingsChanged/);
|
||||
assert.match(hookSource, /handleRestorePreviousSessionChanged\(payload\?\.key\)/);
|
||||
assert.match(hookSource, /key !== STORAGE_KEY_RESTORE_PREVIOUS_SESSION/);
|
||||
assert.match(hookSource, /setRestorePreviousSessionRevision\(\(revision\) => revision \+ 1\)/);
|
||||
});
|
||||
|
||||
test("restore terminal cwd setting participates in cross-window settings sync", () => {
|
||||
const storageSyncSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
|
||||
const ipcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(storageSyncSource, /STORAGE_KEY_RESTORE_TERMINAL_CWD/);
|
||||
assert.match(storageSyncSource, /setRestoreTerminalCwdState/);
|
||||
assert.match(storageSyncSource, /e\.key === STORAGE_KEY_RESTORE_TERMINAL_CWD/);
|
||||
|
||||
assert.match(ipcSyncSource, /STORAGE_KEY_RESTORE_TERMINAL_CWD/);
|
||||
assert.match(ipcSyncSource, /setRestoreTerminalCwdState/);
|
||||
assert.match(ipcSyncSource, /key === STORAGE_KEY_RESTORE_TERMINAL_CWD/);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export const DEFAULT_RESTORE_PREVIOUS_SESSION = true;
|
||||
export const DEFAULT_RESTORE_TERMINAL_CWD = false;
|
||||
|
||||
export const resolveRestorePreviousSessionSetting = (stored: boolean | null): boolean =>
|
||||
stored ?? DEFAULT_RESTORE_PREVIOUS_SESSION;
|
||||
|
||||
export const resolveRestoreTerminalCwdSetting = (stored: boolean | null): boolean =>
|
||||
stored ?? DEFAULT_RESTORE_TERMINAL_CWD;
|
||||
@@ -0,0 +1,225 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
buildAndWriteSessionRestorePayload,
|
||||
createInitialRestoredSessionState,
|
||||
mergeSessionRestoreCwd,
|
||||
updateRestoredSessionStatusState,
|
||||
shouldPersistSessionRestoreState,
|
||||
} from "./sessionRestoreState.ts";
|
||||
import type { SessionRestorePayload } from "../../domain/sessionRestore.ts";
|
||||
|
||||
const payload: SessionRestorePayload = {
|
||||
version: 1,
|
||||
savedAt: 1,
|
||||
activeTabId: "ws-1",
|
||||
tabOrder: ["ws-1"],
|
||||
sessions: [{
|
||||
id: "s1",
|
||||
hostId: "host-s1",
|
||||
hostLabel: "Host s1",
|
||||
hostname: "s1.example.test",
|
||||
username: "root",
|
||||
status: "disconnected",
|
||||
workspaceId: "ws-1",
|
||||
restoreState: "restored-disconnected",
|
||||
}],
|
||||
workspaces: [{
|
||||
id: "ws-1",
|
||||
title: "Workspace",
|
||||
root: { id: "pane-1", type: "pane", sessionId: "s1" },
|
||||
}],
|
||||
};
|
||||
|
||||
test("restored session state hydrates sessions, workspaces, tab order, and active tab", () => {
|
||||
const restored = createInitialRestoredSessionState({
|
||||
restoreEnabled: true,
|
||||
payload,
|
||||
});
|
||||
|
||||
assert.equal(restored.sessions[0].status, "disconnected");
|
||||
assert.equal(restored.sessions[0].restoreState, "restored-disconnected");
|
||||
assert.equal(restored.workspaces[0].id, "ws-1");
|
||||
assert.deepEqual(restored.tabOrder, ["ws-1"]);
|
||||
assert.equal(restored.activeTabId, "ws-1");
|
||||
});
|
||||
|
||||
test("restored session state is empty when restore is disabled", () => {
|
||||
const restored = createInitialRestoredSessionState({ restoreEnabled: false, payload });
|
||||
assert.deepEqual(restored.sessions, []);
|
||||
assert.deepEqual(restored.workspaces, []);
|
||||
assert.deepEqual(restored.tabOrder, []);
|
||||
assert.equal(restored.activeTabId, "vault");
|
||||
});
|
||||
|
||||
test("mergeSessionRestoreCwd records latest cwd metadata without terminal data", () => {
|
||||
const next = mergeSessionRestoreCwd(payload, "s1", "/usr/local/src");
|
||||
assert.equal(next.sessions[0].lastCwd, "/usr/local/src");
|
||||
assert.equal("terminalData" in next.sessions[0], false);
|
||||
});
|
||||
|
||||
test("mergeSessionRestoreCwd removes cwd when terminal reports null", () => {
|
||||
const next = mergeSessionRestoreCwd({
|
||||
...payload,
|
||||
sessions: [{ ...payload.sessions[0], lastCwd: "/tmp" }],
|
||||
}, "s1", null);
|
||||
assert.equal(next.sessions[0].lastCwd, undefined);
|
||||
});
|
||||
|
||||
test("updateRestoredSessionStatusState clears restore marker after reconnect starts", () => {
|
||||
const next = updateRestoredSessionStatusState(payload.sessions, "s1", "connecting");
|
||||
|
||||
assert.equal(next[0].status, "connecting");
|
||||
assert.equal(next[0].restoreState, undefined);
|
||||
});
|
||||
|
||||
test("updateRestoredSessionStatusState keeps restore marker for disconnected placeholders", () => {
|
||||
const next = updateRestoredSessionStatusState(payload.sessions, "s1", "disconnected");
|
||||
|
||||
assert.equal(next[0].status, "disconnected");
|
||||
assert.equal(next[0].restoreState, "restored-disconnected");
|
||||
});
|
||||
|
||||
test("shouldPersistSessionRestoreState skips transient empty startup state", () => {
|
||||
assert.equal(shouldPersistSessionRestoreState([], [], []), false);
|
||||
assert.equal(shouldPersistSessionRestoreState(payload.sessions, payload.workspaces, payload.tabOrder), true);
|
||||
});
|
||||
|
||||
test("restored session state preserves lightweight workspace chrome only", () => {
|
||||
const restored = createInitialRestoredSessionState({
|
||||
restoreEnabled: true,
|
||||
payload: {
|
||||
...payload,
|
||||
workspaces: [{
|
||||
...payload.workspaces[0],
|
||||
viewMode: "focus",
|
||||
focusedSessionId: "s1",
|
||||
focusSessionOrder: ["s1"],
|
||||
broadcastEnabled: true,
|
||||
transientPanelState: { selected: "history" },
|
||||
} as never],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(restored.workspaces[0].viewMode, "focus");
|
||||
assert.equal(restored.workspaces[0].focusedSessionId, "s1");
|
||||
assert.deepEqual(restored.workspaces[0].focusSessionOrder, ["s1"]);
|
||||
assert.equal("transientPanelState" in restored.workspaces[0], false);
|
||||
});
|
||||
|
||||
test("session restore flush writes through the same sanitized payload path", () => {
|
||||
const writes: SessionRestorePayload[] = [];
|
||||
const storage = {
|
||||
write: (next: SessionRestorePayload) => {
|
||||
writes.push(next);
|
||||
return true;
|
||||
},
|
||||
clear: () => {
|
||||
throw new Error("should not clear non-empty restore state");
|
||||
},
|
||||
};
|
||||
|
||||
const wrote = buildAndWriteSessionRestorePayload({
|
||||
sessions: [{
|
||||
...payload.sessions[0],
|
||||
status: "connected",
|
||||
terminalData: "do-not-store",
|
||||
} as never],
|
||||
workspaces: [{
|
||||
...payload.workspaces[0],
|
||||
transientPanelState: { selected: "history" },
|
||||
} as never],
|
||||
tabOrder: ["ws-1"],
|
||||
activeTabId: "ws-1",
|
||||
now: 42,
|
||||
storage,
|
||||
});
|
||||
|
||||
assert.equal(wrote, true);
|
||||
assert.equal(writes.length, 1);
|
||||
assert.equal(writes[0].sessions[0].status, "disconnected");
|
||||
assert.equal("terminalData" in writes[0].sessions[0], false);
|
||||
assert.equal("transientPanelState" in writes[0].workspaces[0], false);
|
||||
assert.equal(writes[0].savedAt, 42);
|
||||
});
|
||||
|
||||
test("session restore flush clears storage instead of writing when restore is disabled", () => {
|
||||
const writes: SessionRestorePayload[] = [];
|
||||
let clearCount = 0;
|
||||
const storage = {
|
||||
write: (next: SessionRestorePayload) => {
|
||||
writes.push(next);
|
||||
return true;
|
||||
},
|
||||
clear: () => {
|
||||
clearCount += 1;
|
||||
},
|
||||
};
|
||||
|
||||
const wrote = buildAndWriteSessionRestorePayload({
|
||||
restoreEnabled: false,
|
||||
sessions: payload.sessions,
|
||||
workspaces: payload.workspaces,
|
||||
tabOrder: payload.tabOrder,
|
||||
activeTabId: payload.activeTabId,
|
||||
storage,
|
||||
});
|
||||
|
||||
assert.equal(wrote, false);
|
||||
assert.equal(clearCount, 1);
|
||||
assert.equal(writes.length, 0);
|
||||
});
|
||||
|
||||
test("session restore flush skips empty transient windows without clearing existing snapshots", () => {
|
||||
const writes: SessionRestorePayload[] = [];
|
||||
let clearCount = 0;
|
||||
const storage = {
|
||||
write: (next: SessionRestorePayload) => {
|
||||
writes.push(next);
|
||||
return true;
|
||||
},
|
||||
clear: () => {
|
||||
clearCount += 1;
|
||||
},
|
||||
};
|
||||
|
||||
const wrote = buildAndWriteSessionRestorePayload({
|
||||
sessions: [],
|
||||
workspaces: [],
|
||||
tabOrder: [],
|
||||
activeTabId: "vault",
|
||||
storage,
|
||||
});
|
||||
|
||||
assert.equal(wrote, false);
|
||||
assert.equal(clearCount, 0);
|
||||
assert.equal(writes.length, 0);
|
||||
});
|
||||
|
||||
test("session restore flush clears stale snapshots when main window becomes empty after restorable state existed", () => {
|
||||
const writes: SessionRestorePayload[] = [];
|
||||
let clearCount = 0;
|
||||
const storage = {
|
||||
write: (next: SessionRestorePayload) => {
|
||||
writes.push(next);
|
||||
return true;
|
||||
},
|
||||
clear: () => {
|
||||
clearCount += 1;
|
||||
},
|
||||
};
|
||||
|
||||
const wrote = buildAndWriteSessionRestorePayload({
|
||||
sessions: [],
|
||||
workspaces: [],
|
||||
tabOrder: [],
|
||||
activeTabId: "vault",
|
||||
clearOnEmpty: true,
|
||||
storage,
|
||||
});
|
||||
|
||||
assert.equal(wrote, false);
|
||||
assert.equal(clearCount, 1);
|
||||
assert.equal(writes.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { TerminalSession, Workspace } from "../../domain/models";
|
||||
import {
|
||||
buildSessionRestorePayload,
|
||||
sanitizeSessionRestorePayload,
|
||||
type SessionRestorePayload,
|
||||
} from "../../domain/sessionRestore";
|
||||
|
||||
export type InitialRestoredSessionState = {
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
tabOrder: string[];
|
||||
activeTabId: string;
|
||||
};
|
||||
|
||||
export function createInitialRestoredSessionState({
|
||||
restoreEnabled,
|
||||
payload,
|
||||
}: {
|
||||
restoreEnabled: boolean;
|
||||
payload: SessionRestorePayload | null;
|
||||
}): InitialRestoredSessionState {
|
||||
if (!restoreEnabled || !payload) {
|
||||
return {
|
||||
sessions: [],
|
||||
workspaces: [],
|
||||
tabOrder: [],
|
||||
activeTabId: "vault",
|
||||
};
|
||||
}
|
||||
|
||||
const sanitized = sanitizeSessionRestorePayload(payload);
|
||||
return {
|
||||
sessions: sanitized.sessions,
|
||||
workspaces: sanitized.workspaces,
|
||||
tabOrder: sanitized.tabOrder,
|
||||
activeTabId: sanitized.activeTabId,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldPersistSessionRestoreState(
|
||||
sessions: readonly TerminalSession[],
|
||||
workspaces: readonly Workspace[],
|
||||
tabOrder: readonly string[],
|
||||
): boolean {
|
||||
return sessions.length > 0 || workspaces.length > 0 || tabOrder.length > 0;
|
||||
}
|
||||
|
||||
export function buildPersistableSessionRestorePayload({
|
||||
sessions,
|
||||
workspaces,
|
||||
tabOrder,
|
||||
activeTabId,
|
||||
now,
|
||||
}: {
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
tabOrder: string[];
|
||||
activeTabId: string;
|
||||
now?: number;
|
||||
}): SessionRestorePayload | null {
|
||||
if (!shouldPersistSessionRestoreState(sessions, workspaces, tabOrder)) return null;
|
||||
return buildSessionRestorePayload({
|
||||
sessions,
|
||||
workspaces,
|
||||
tabOrder,
|
||||
activeTabId,
|
||||
now,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildAndWriteSessionRestorePayload({
|
||||
restoreEnabled = true,
|
||||
clearOnEmpty = false,
|
||||
sessions,
|
||||
workspaces,
|
||||
tabOrder,
|
||||
activeTabId,
|
||||
now,
|
||||
storage,
|
||||
}: {
|
||||
restoreEnabled?: boolean;
|
||||
clearOnEmpty?: boolean;
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
tabOrder: string[];
|
||||
activeTabId: string;
|
||||
now?: number;
|
||||
storage: {
|
||||
write(payload: SessionRestorePayload): boolean;
|
||||
clear(): void;
|
||||
};
|
||||
}): boolean {
|
||||
if (!restoreEnabled) {
|
||||
storage.clear();
|
||||
return false;
|
||||
}
|
||||
const payload = buildPersistableSessionRestorePayload({
|
||||
sessions,
|
||||
workspaces,
|
||||
tabOrder,
|
||||
activeTabId,
|
||||
now,
|
||||
});
|
||||
if (!payload) {
|
||||
if (clearOnEmpty) {
|
||||
storage.clear();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return storage.write(payload);
|
||||
}
|
||||
|
||||
export function mergeSessionRestoreCwd(
|
||||
payload: SessionRestorePayload,
|
||||
sessionId: string,
|
||||
cwd: string | null,
|
||||
): SessionRestorePayload {
|
||||
return sanitizeSessionRestorePayload({
|
||||
...payload,
|
||||
sessions: payload.sessions.map((session) => {
|
||||
if (session.id !== sessionId) return session;
|
||||
const { lastCwd: _lastCwd, ...rest } = session;
|
||||
return cwd ? { ...rest, lastCwd: cwd } : rest;
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateRestoredSessionStatusState<T extends Pick<TerminalSession, "id" | "status" | "restoreState">>(
|
||||
sessions: readonly T[],
|
||||
sessionId: string,
|
||||
status: TerminalSession["status"],
|
||||
): T[] {
|
||||
let changed = false;
|
||||
const next = sessions.map((session) => {
|
||||
if (session.id !== sessionId) return session;
|
||||
const shouldClearRestoreState = status === "connecting" || status === "connected";
|
||||
if (session.status === status && (!shouldClearRestoreState || session.restoreState === undefined)) {
|
||||
return session;
|
||||
}
|
||||
changed = true;
|
||||
if (!shouldClearRestoreState) return { ...session, status };
|
||||
const { restoreState: _restoreState, ...rest } = session;
|
||||
return { ...rest, status } as T;
|
||||
});
|
||||
return changed ? next : sessions as T[];
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createSessionRestoreStorage } from "./sessionRestoreStorage.ts";
|
||||
import type { SessionRestorePayload } from "../../domain/sessionRestore.ts";
|
||||
|
||||
const payload: SessionRestorePayload = {
|
||||
version: 1,
|
||||
savedAt: 1,
|
||||
activeTabId: "vault",
|
||||
tabOrder: [],
|
||||
sessions: [],
|
||||
workspaces: [],
|
||||
};
|
||||
|
||||
test("session restore storage reads a valid payload", () => {
|
||||
const backing = new Map<string, unknown>();
|
||||
const storage = createSessionRestoreStorage({
|
||||
read: <T,>(key: string): T | null => (backing.get(key) as T) ?? null,
|
||||
write: <T,>(key: string, value: T): boolean => {
|
||||
backing.set(key, value);
|
||||
return true;
|
||||
},
|
||||
remove: (key: string) => {
|
||||
backing.delete(key);
|
||||
},
|
||||
});
|
||||
|
||||
storage.write(payload);
|
||||
assert.deepEqual(storage.read(), payload);
|
||||
});
|
||||
|
||||
test("session restore storage removes invalid payloads", () => {
|
||||
const backing = new Map<string, unknown>([["netcatty_session_restore_v1", { version: 999 }]]);
|
||||
const storage = createSessionRestoreStorage({
|
||||
read: <T,>(key: string): T | null => (backing.get(key) as T) ?? null,
|
||||
write: () => true,
|
||||
remove: (key: string) => {
|
||||
backing.delete(key);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(storage.read(), null);
|
||||
assert.equal(backing.has("netcatty_session_restore_v1"), false);
|
||||
});
|
||||
|
||||
test("session restore storage removes malformed payloads that pass shallow checks", () => {
|
||||
const backing = new Map<string, unknown>([[
|
||||
"netcatty_session_restore_v1",
|
||||
{
|
||||
version: 1,
|
||||
savedAt: 1,
|
||||
activeTabId: "vault",
|
||||
tabOrder: [],
|
||||
sessions: [null],
|
||||
workspaces: [],
|
||||
},
|
||||
]]);
|
||||
const storage = createSessionRestoreStorage({
|
||||
read: <T,>(key: string): T | null => (backing.get(key) as T) ?? null,
|
||||
write: () => true,
|
||||
remove: (key: string) => {
|
||||
backing.delete(key);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(storage.read(), null);
|
||||
assert.equal(backing.has("netcatty_session_restore_v1"), false);
|
||||
});
|
||||
|
||||
test("session restore storage sanitizes payloads before writing", () => {
|
||||
const backing = new Map<string, unknown>();
|
||||
const storage = createSessionRestoreStorage({
|
||||
read: <T,>(key: string): T | null => (backing.get(key) as T) ?? null,
|
||||
write: <T,>(key: string, value: T): boolean => {
|
||||
backing.set(key, value);
|
||||
return true;
|
||||
},
|
||||
remove: (key: string) => {
|
||||
backing.delete(key);
|
||||
},
|
||||
});
|
||||
|
||||
storage.write({
|
||||
...payload,
|
||||
sessions: [{
|
||||
id: "s1",
|
||||
hostId: "h1",
|
||||
hostLabel: "Host 1",
|
||||
hostname: "example.test",
|
||||
username: "root",
|
||||
status: "connected",
|
||||
}],
|
||||
tabOrder: ["s1"],
|
||||
activeTabId: "s1",
|
||||
} as unknown as SessionRestorePayload);
|
||||
|
||||
assert.equal(storage.read()?.sessions[0].status, "disconnected");
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
STORAGE_KEY_SESSION_RESTORE,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
SESSION_RESTORE_VERSION,
|
||||
sanitizeSessionRestorePayload,
|
||||
type SessionRestorePayload,
|
||||
} from "../../domain/sessionRestore";
|
||||
|
||||
type RestoreStorageAdapter = {
|
||||
read<T>(key: string): T | null;
|
||||
write<T>(key: string, value: T): boolean;
|
||||
remove(key: string): void;
|
||||
};
|
||||
|
||||
const isRestorePayload = (value: unknown): value is SessionRestorePayload => {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const record = value as Partial<SessionRestorePayload>;
|
||||
return record.version === SESSION_RESTORE_VERSION
|
||||
&& Array.isArray(record.sessions)
|
||||
&& record.sessions.every((session) => (
|
||||
Boolean(session)
|
||||
&& typeof session === "object"
|
||||
&& typeof (session as { id?: unknown }).id === "string"
|
||||
&& typeof (session as { hostId?: unknown }).hostId === "string"
|
||||
&& typeof (session as { hostLabel?: unknown }).hostLabel === "string"
|
||||
&& typeof (session as { hostname?: unknown }).hostname === "string"
|
||||
&& typeof (session as { username?: unknown }).username === "string"
|
||||
))
|
||||
&& Array.isArray(record.workspaces)
|
||||
&& record.workspaces.every((workspace) => (
|
||||
Boolean(workspace)
|
||||
&& typeof workspace === "object"
|
||||
&& typeof (workspace as { id?: unknown }).id === "string"
|
||||
&& Boolean((workspace as { root?: unknown }).root)
|
||||
&& typeof (workspace as { root?: unknown }).root === "object"
|
||||
))
|
||||
&& Array.isArray(record.tabOrder)
|
||||
&& record.tabOrder.every((tabId) => typeof tabId === "string")
|
||||
&& typeof record.activeTabId === "string";
|
||||
};
|
||||
|
||||
export function createSessionRestoreStorage(adapter: RestoreStorageAdapter = localStorageAdapter) {
|
||||
return {
|
||||
read(): SessionRestorePayload | null {
|
||||
const payload = adapter.read<unknown>(STORAGE_KEY_SESSION_RESTORE);
|
||||
if (!isRestorePayload(payload)) {
|
||||
if (payload !== null) adapter.remove(STORAGE_KEY_SESSION_RESTORE);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return sanitizeSessionRestorePayload(payload);
|
||||
} catch {
|
||||
adapter.remove(STORAGE_KEY_SESSION_RESTORE);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
write(payload: SessionRestorePayload): boolean {
|
||||
return adapter.write(STORAGE_KEY_SESSION_RESTORE, sanitizeSessionRestorePayload(payload));
|
||||
},
|
||||
clear(): void {
|
||||
adapter.remove(STORAGE_KEY_SESSION_RESTORE);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const sessionRestoreStorage = createSessionRestoreStorage();
|
||||
@@ -0,0 +1,123 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { TerminalSession, Workspace } from "../../domain/models";
|
||||
import {
|
||||
closeSessionWorkspaceLayoutState,
|
||||
detachSessionFromWorkspaceState,
|
||||
replaceDissolvedWorkspaceTabOrder,
|
||||
} from "./sessionWorkspaceDetach";
|
||||
|
||||
const session = (id: string, workspaceId = "ws-1"): TerminalSession => ({
|
||||
id,
|
||||
hostId: id,
|
||||
hostLabel: id,
|
||||
status: "connected",
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const workspace = (sessionIds: string[]): Workspace => ({
|
||||
id: "ws-1",
|
||||
title: "Workspace",
|
||||
focusedSessionId: sessionIds[0],
|
||||
focusSessionOrder: sessionIds,
|
||||
root: sessionIds.length === 1
|
||||
? { id: "pane-1", type: "pane", sessionId: sessionIds[0] }
|
||||
: {
|
||||
id: "split-1",
|
||||
type: "split",
|
||||
direction: "vertical",
|
||||
children: sessionIds.map((sessionId, index) => ({
|
||||
id: `pane-${index + 1}`,
|
||||
type: "pane" as const,
|
||||
sessionId,
|
||||
})),
|
||||
sizes: sessionIds.map(() => 1),
|
||||
},
|
||||
});
|
||||
|
||||
test("detach dissolves the original workspace when one session remains", () => {
|
||||
const result = detachSessionFromWorkspaceState({
|
||||
sessions: [session("s1"), session("s2")],
|
||||
workspaces: [workspace(["s1", "s2"])],
|
||||
sessionId: "s1",
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.activeTabId, "s1");
|
||||
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
|
||||
["s1", undefined],
|
||||
["s2", undefined],
|
||||
]);
|
||||
assert.equal(result.workspaces.length, 0);
|
||||
assert.equal(result.dissolvedWorkspaceId, "ws-1");
|
||||
assert.deepEqual(result.replacementTabIds, ["s1", "s2"]);
|
||||
});
|
||||
|
||||
test("detach preserves the other sessions in a multi-pane workspace", () => {
|
||||
const result = detachSessionFromWorkspaceState({
|
||||
sessions: [session("s1"), session("s2"), session("s3")],
|
||||
workspaces: [workspace(["s1", "s2", "s3"])],
|
||||
sessionId: "s2",
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
|
||||
["s1", "ws-1"],
|
||||
["s2", undefined],
|
||||
["s3", "ws-1"],
|
||||
]);
|
||||
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s3"]);
|
||||
assert.equal(result.workspaces[0].focusedSessionId, "s1");
|
||||
assert.deepEqual(
|
||||
result.workspaces[0].root.type === "split"
|
||||
? result.workspaces[0].root.children.map((child) => child.type === "pane" ? child.sessionId : null)
|
||||
: [],
|
||||
["s1", "s3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("dissolved workspace replacement preserves its tab position", () => {
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
|
||||
["log-1", "s1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("dissolved workspace replacement removes duplicate replacement ids", () => {
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(["s1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
|
||||
["s1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("dissolved workspace replacement is idempotent", () => {
|
||||
const once = replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]);
|
||||
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(once, "ws-1", ["s1", "s2"]),
|
||||
once,
|
||||
);
|
||||
});
|
||||
|
||||
test("single remaining session preserves dissolved workspace tab position", () => {
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s2"]),
|
||||
["log-1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("closing a workspace session dissolves the workspace when one terminal remains", () => {
|
||||
const result = closeSessionWorkspaceLayoutState([workspace(["s1", "s2"])], "ws-1", "s1");
|
||||
|
||||
assert.equal(result.dissolvedWorkspaceId, "ws-1");
|
||||
assert.equal(result.lastRemainingSessionId, "s2");
|
||||
assert.deepEqual(result.workspaces, []);
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(
|
||||
["log-1", result.dissolvedWorkspaceId!, "session-3"],
|
||||
result.dissolvedWorkspaceId,
|
||||
result.lastRemainingSessionId ? [result.lastRemainingSessionId] : undefined,
|
||||
),
|
||||
["log-1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { TerminalSession, Workspace } from "../../domain/models";
|
||||
import { collectSessionIds, pruneWorkspaceNode } from "../../domain/workspace";
|
||||
|
||||
export type DetachSessionFromWorkspaceStateResult = {
|
||||
changed: boolean;
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
activeTabId?: string;
|
||||
dissolvedWorkspaceId?: string;
|
||||
replacementTabIds?: string[];
|
||||
};
|
||||
|
||||
export type CloseSessionWorkspaceLayoutResult = {
|
||||
workspaces: Workspace[];
|
||||
removedWorkspaceId?: string;
|
||||
dissolvedWorkspaceId?: string;
|
||||
lastRemainingSessionId?: string;
|
||||
};
|
||||
|
||||
type DetachSessionFromWorkspaceStateOptions = {
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export function replaceDissolvedWorkspaceTabOrder(
|
||||
tabOrder: readonly string[],
|
||||
workspaceId: string | undefined,
|
||||
replacementTabIds: readonly string[] | undefined,
|
||||
): string[] {
|
||||
if (!workspaceId || !replacementTabIds?.length) return [...tabOrder];
|
||||
|
||||
const uniqueReplacementIds = replacementTabIds.filter((tabId, index, list) => (
|
||||
tabId && list.indexOf(tabId) === index
|
||||
));
|
||||
if (uniqueReplacementIds.length === 0) return [...tabOrder];
|
||||
|
||||
if (!tabOrder.includes(workspaceId)) {
|
||||
const hasAllReplacementIds = uniqueReplacementIds.every((tabId) => tabOrder.includes(tabId));
|
||||
return hasAllReplacementIds ? [...tabOrder] : [
|
||||
...tabOrder,
|
||||
...uniqueReplacementIds.filter((tabId) => !tabOrder.includes(tabId)),
|
||||
];
|
||||
}
|
||||
|
||||
const replacementIdSet = new Set(uniqueReplacementIds);
|
||||
let inserted = false;
|
||||
const nextOrder: string[] = [];
|
||||
|
||||
for (const tabId of tabOrder) {
|
||||
if (tabId === workspaceId) {
|
||||
if (!inserted) {
|
||||
nextOrder.push(...uniqueReplacementIds);
|
||||
inserted = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!replacementIdSet.has(tabId)) {
|
||||
nextOrder.push(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
return nextOrder;
|
||||
}
|
||||
|
||||
export function closeSessionWorkspaceLayoutState(
|
||||
workspaces: readonly Workspace[],
|
||||
workspaceId: string | undefined,
|
||||
sessionId: string,
|
||||
): CloseSessionWorkspaceLayoutResult {
|
||||
if (!workspaceId) return { workspaces: [...workspaces] };
|
||||
|
||||
let removedWorkspaceId: string | undefined;
|
||||
let dissolvedWorkspaceId: string | undefined;
|
||||
let lastRemainingSessionId: string | undefined;
|
||||
const nextWorkspaces = workspaces
|
||||
.map((workspace) => {
|
||||
if (workspace.id !== workspaceId) return workspace;
|
||||
const prunedRoot = pruneWorkspaceNode(workspace.root, sessionId);
|
||||
if (!prunedRoot) {
|
||||
removedWorkspaceId = workspace.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
const remainingSessionIds = collectSessionIds(prunedRoot);
|
||||
if (remainingSessionIds.length === 1) {
|
||||
dissolvedWorkspaceId = workspace.id;
|
||||
lastRemainingSessionId = remainingSessionIds[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
return { ...workspace, root: prunedRoot };
|
||||
})
|
||||
.filter((workspace): workspace is Workspace => Boolean(workspace));
|
||||
|
||||
return {
|
||||
workspaces: nextWorkspaces,
|
||||
removedWorkspaceId,
|
||||
dissolvedWorkspaceId,
|
||||
lastRemainingSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
export function detachSessionFromWorkspaceState({
|
||||
sessions,
|
||||
workspaces,
|
||||
sessionId,
|
||||
}: DetachSessionFromWorkspaceStateOptions): DetachSessionFromWorkspaceStateResult {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (!session?.workspaceId) {
|
||||
return { changed: false, sessions, workspaces };
|
||||
}
|
||||
|
||||
const workspaceId = session.workspaceId;
|
||||
const targetWorkspace = workspaces.find((workspace) => workspace.id === workspaceId);
|
||||
if (!targetWorkspace) {
|
||||
return { changed: false, sessions, workspaces };
|
||||
}
|
||||
|
||||
const prunedRoot = pruneWorkspaceNode(targetWorkspace.root, sessionId);
|
||||
let nextSessions = sessions.map((candidate) => (
|
||||
candidate.id === sessionId ? { ...candidate, workspaceId: undefined } : candidate
|
||||
));
|
||||
|
||||
if (!prunedRoot) {
|
||||
return {
|
||||
changed: true,
|
||||
sessions: nextSessions,
|
||||
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
|
||||
activeTabId: sessionId,
|
||||
dissolvedWorkspaceId: workspaceId,
|
||||
replacementTabIds: [sessionId],
|
||||
};
|
||||
}
|
||||
|
||||
const remainingSessionIds = collectSessionIds(prunedRoot);
|
||||
if (remainingSessionIds.length === 1) {
|
||||
nextSessions = nextSessions.map((candidate) => (
|
||||
candidate.id === remainingSessionIds[0] ? { ...candidate, workspaceId: undefined } : candidate
|
||||
));
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
sessions: nextSessions,
|
||||
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
|
||||
activeTabId: sessionId,
|
||||
dissolvedWorkspaceId: workspaceId,
|
||||
replacementTabIds: [sessionId, ...remainingSessionIds],
|
||||
};
|
||||
}
|
||||
|
||||
const nextFocusedSessionId = remainingSessionIds.includes(targetWorkspace.focusedSessionId)
|
||||
? targetWorkspace.focusedSessionId
|
||||
: remainingSessionIds[0];
|
||||
const nextFocusSessionOrder = (targetWorkspace.focusSessionOrder ?? [])
|
||||
.filter((candidateId, index, list) => (
|
||||
candidateId !== sessionId &&
|
||||
remainingSessionIds.includes(candidateId) &&
|
||||
list.indexOf(candidateId) === index
|
||||
));
|
||||
for (const remainingSessionId of remainingSessionIds) {
|
||||
if (!nextFocusSessionOrder.includes(remainingSessionId)) {
|
||||
nextFocusSessionOrder.push(remainingSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
sessions: nextSessions,
|
||||
workspaces: workspaces.map((workspace) => (
|
||||
workspace.id === workspaceId
|
||||
? {
|
||||
...workspace,
|
||||
root: prunedRoot,
|
||||
focusedSessionId: nextFocusedSessionId,
|
||||
focusSessionOrder: nextFocusSessionOrder,
|
||||
}
|
||||
: workspace
|
||||
)),
|
||||
activeTabId: sessionId,
|
||||
};
|
||||
}
|
||||
@@ -13,11 +13,14 @@ import {
|
||||
STORAGE_KEY_HOTKEY_RECORDING,
|
||||
STORAGE_KEY_HOTKEY_SCHEME,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
|
||||
STORAGE_KEY_RESTORE_TERMINAL_CWD,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
@@ -46,6 +49,7 @@ import {
|
||||
} from './settingsStateDefaults';
|
||||
|
||||
interface UseSettingsIpcSyncParams {
|
||||
enabled?: boolean;
|
||||
syncAppearanceFromStorage: () => void;
|
||||
syncCustomCssFromStorage: () => void;
|
||||
setUiLanguage: Dispatch<SetStateAction<UILanguage>>;
|
||||
@@ -63,6 +67,7 @@ interface UseSettingsIpcSyncParams {
|
||||
setSessionLogsFormat: Dispatch<SetStateAction<SessionLogFormat>>;
|
||||
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSshDeepLinkEnabledState: (enabled: boolean) => void;
|
||||
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
|
||||
applyIncomingCustomKeyBindings: (incoming: { bindings: CustomKeyBindings; version: number; origin: string }) => void;
|
||||
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -75,10 +80,13 @@ interface UseSettingsIpcSyncParams {
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
|
||||
}
|
||||
|
||||
export function useSettingsIpcSync({
|
||||
enabled = true,
|
||||
syncAppearanceFromStorage,
|
||||
syncCustomCssFromStorage,
|
||||
setUiLanguage,
|
||||
@@ -96,6 +104,7 @@ export function useSettingsIpcSync({
|
||||
setSessionLogsFormat,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
setSshDeepLinkEnabledState,
|
||||
setHotkeyScheme,
|
||||
applyIncomingCustomKeyBindings,
|
||||
setIsHotkeyRecordingState,
|
||||
@@ -108,10 +117,13 @@ export function useSettingsIpcSync({
|
||||
setWorkspaceFocusStyleState,
|
||||
setShowHostTreeSidebarState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
setSftpTransferConcurrencyState,
|
||||
}: UseSettingsIpcSyncParams) {
|
||||
// Listen for settings changes from other windows via IPC
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onSettingsChanged) return;
|
||||
const unsubscribe = bridge.onSettingsChanged((payload) => {
|
||||
@@ -192,6 +204,9 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED && typeof value === 'boolean') {
|
||||
setSshDebugLogsEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SSH_DEEP_LINK_ENABLED && typeof value === 'boolean') {
|
||||
setSshDeepLinkEnabledState(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
|
||||
setHotkeyScheme(value);
|
||||
}
|
||||
@@ -234,6 +249,12 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && typeof value === 'boolean') {
|
||||
setDisableTerminalFontZoomState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_RESTORE_PREVIOUS_SESSION && typeof value === 'boolean') {
|
||||
setRestorePreviousSessionState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_RESTORE_TERMINAL_CWD && typeof value === 'boolean') {
|
||||
setRestoreTerminalCwdState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
|
||||
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -246,6 +267,7 @@ export function useSettingsIpcSync({
|
||||
}
|
||||
};
|
||||
}, [
|
||||
enabled,
|
||||
applyIncomingCustomKeyBindings,
|
||||
mergeIncomingTerminalSettings,
|
||||
setAutoUpdateEnabled,
|
||||
@@ -259,12 +281,15 @@ export function useSettingsIpcSync({
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsFormat,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDeepLinkEnabledState,
|
||||
setSshDebugLogsEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setShowHostTreeSidebarState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
setSftpTransferConcurrencyState,
|
||||
setTerminalFontFamilyId,
|
||||
setTerminalFontSize,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
getContrastRatio,
|
||||
buildAppThemeCssVars,
|
||||
getHslTokenRelativeLuminance,
|
||||
resolveReadableForegroundForHsl,
|
||||
resolveThemeAccentForeground,
|
||||
} from "./settingsStateDefaults.ts";
|
||||
import type { UiThemeTokens } from "../../infrastructure/config/uiThemes.ts";
|
||||
|
||||
test("readable foreground picks white text for dark accent colors", () => {
|
||||
assert.equal(resolveReadableForegroundForHsl("270 70% 45%"), "0 0% 100%");
|
||||
});
|
||||
|
||||
test("readable foreground picks black text for light accent colors", () => {
|
||||
assert.equal(resolveReadableForegroundForHsl("48 95% 72%"), "0 0% 0%");
|
||||
});
|
||||
|
||||
test("computed contrast chooses the stronger black or white foreground", () => {
|
||||
const purpleLuminance = getHslTokenRelativeLuminance("270 70% 45%");
|
||||
assert.equal(typeof purpleLuminance, "number");
|
||||
assert.ok(getContrastRatio(1, purpleLuminance as number) > getContrastRatio(0, purpleLuminance as number));
|
||||
|
||||
const yellowLuminance = getHslTokenRelativeLuminance("48 95% 72%");
|
||||
assert.equal(typeof yellowLuminance, "number");
|
||||
assert.ok(getContrastRatio(0, yellowLuminance as number) > getContrastRatio(1, yellowLuminance as number));
|
||||
});
|
||||
|
||||
test("theme accent foreground uses the computed color for preset accent buttons", () => {
|
||||
const tokens: UiThemeTokens = {
|
||||
background: "0 0% 100%",
|
||||
foreground: "222 47% 12%",
|
||||
card: "0 0% 100%",
|
||||
cardForeground: "222 47% 12%",
|
||||
popover: "0 0% 100%",
|
||||
popoverForeground: "222 47% 12%",
|
||||
primary: "270 70% 45%",
|
||||
primaryForeground: "0 0% 0%",
|
||||
secondary: "220 12% 95%",
|
||||
secondaryForeground: "222 47% 12%",
|
||||
muted: "220 12% 95%",
|
||||
mutedForeground: "220 10% 45%",
|
||||
accent: "270 70% 45%",
|
||||
accentForeground: "0 0% 0%",
|
||||
destructive: "0 70% 50%",
|
||||
destructiveForeground: "0 0% 100%",
|
||||
border: "220 12% 88%",
|
||||
input: "220 12% 88%",
|
||||
ring: "270 70% 45%",
|
||||
};
|
||||
|
||||
assert.equal(resolveThemeAccentForeground(tokens, "theme", "48 95% 72%"), "0 0% 100%");
|
||||
assert.equal(resolveThemeAccentForeground(tokens, "custom", "48 95% 72%"), "0 0% 0%");
|
||||
});
|
||||
|
||||
test("app surface theme vars isolate non-terminal pages from active terminal chrome", () => {
|
||||
const tokens: UiThemeTokens = {
|
||||
background: "0 0% 100%",
|
||||
foreground: "222 47% 12%",
|
||||
card: "0 0% 100%",
|
||||
cardForeground: "222 47% 12%",
|
||||
popover: "0 0% 100%",
|
||||
popoverForeground: "222 47% 12%",
|
||||
primary: "270 70% 45%",
|
||||
primaryForeground: "0 0% 0%",
|
||||
secondary: "220 12% 95%",
|
||||
secondaryForeground: "222 47% 12%",
|
||||
muted: "220 12% 95%",
|
||||
mutedForeground: "220 10% 45%",
|
||||
accent: "270 70% 45%",
|
||||
accentForeground: "0 0% 0%",
|
||||
destructive: "0 70% 50%",
|
||||
destructiveForeground: "0 0% 100%",
|
||||
border: "220 12% 88%",
|
||||
input: "220 12% 88%",
|
||||
ring: "270 70% 45%",
|
||||
};
|
||||
|
||||
assert.deepEqual(buildAppThemeCssVars(tokens, "theme", "48 95% 72%"), {
|
||||
"--background": "0 0% 100%",
|
||||
"--foreground": "222 47% 12%",
|
||||
"--card": "0 0% 100%",
|
||||
"--card-foreground": "222 47% 12%",
|
||||
"--popover": "0 0% 100%",
|
||||
"--popover-foreground": "222 47% 12%",
|
||||
"--primary": "270 70% 45%",
|
||||
"--primary-foreground": "0 0% 100%",
|
||||
"--secondary": "220 12% 95%",
|
||||
"--secondary-foreground": "222 47% 12%",
|
||||
"--muted": "220 12% 95%",
|
||||
"--muted-foreground": "220 10% 45%",
|
||||
"--accent": "270 70% 45%",
|
||||
"--accent-foreground": "0 0% 100%",
|
||||
"--destructive": "0 70% 50%",
|
||||
"--destructive-foreground": "0 0% 100%",
|
||||
"--border": "220 12% 88%",
|
||||
"--input": "220 12% 88%",
|
||||
"--ring": "270 70% 45%",
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,13 @@ import { UI_FONTS } from '../../infrastructure/config/uiFonts';
|
||||
import { uiFontStore } from './uiFontStore';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { resolveReadableForegroundForHsl } from '../../domain/colorContrast';
|
||||
|
||||
export {
|
||||
getContrastRatio,
|
||||
getHslTokenRelativeLuminance,
|
||||
resolveReadableForegroundForHsl,
|
||||
} from '../../domain/colorContrast';
|
||||
|
||||
export const DEFAULT_THEME: 'light' | 'dark' | 'system' = 'dark';
|
||||
export const DEFAULT_WINDOW_OPACITY = 1;
|
||||
@@ -66,6 +73,7 @@ export const DEFAULT_SHOW_SFTP_TAB = true;
|
||||
export const DEFAULT_SHOW_HOST_TREE_SIDEBAR = true;
|
||||
export const DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = false;
|
||||
export const DEFAULT_DISABLE_TERMINAL_FONT_ZOOM = false;
|
||||
export { DEFAULT_RESTORE_PREVIOUS_SESSION } from './sessionRestoreSettings';
|
||||
|
||||
// Editor defaults
|
||||
export const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
@@ -75,6 +83,7 @@ export const DEFAULT_SESSION_LOGS_ENABLED = false;
|
||||
export const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
|
||||
export const DEFAULT_SESSION_LOGS_TIMESTAMPS_ENABLED = false;
|
||||
export const DEFAULT_SSH_DEBUG_LOGS_ENABLED = false;
|
||||
export const DEFAULT_SSH_DEEP_LINK_ENABLED = true;
|
||||
|
||||
export const readStoredString = (key: string): string | null => {
|
||||
const raw = localStorageAdapter.readString(key);
|
||||
@@ -97,6 +106,46 @@ export const isValidHslToken = (value: string): boolean => {
|
||||
return /^\s*\d+(\.\d+)?\s+\d+(\.\d+)?%\s+\d+(\.\d+)?%\s*$/.test(value);
|
||||
};
|
||||
|
||||
export const resolveThemeAccentForeground = (
|
||||
tokens: UiThemeTokens,
|
||||
accentMode: 'theme' | 'custom',
|
||||
accentOverride: string,
|
||||
): string => {
|
||||
const accentToken = accentMode === 'custom' ? accentOverride : tokens.accent;
|
||||
return resolveReadableForegroundForHsl(accentToken, tokens.primaryForeground);
|
||||
};
|
||||
|
||||
export const buildAppThemeCssVars = (
|
||||
tokens: UiThemeTokens,
|
||||
accentMode: 'theme' | 'custom',
|
||||
accentOverride: string,
|
||||
): Record<string, string> => {
|
||||
const accentToken = accentMode === 'custom' ? accentOverride : tokens.accent;
|
||||
const computedAccentForeground = resolveThemeAccentForeground(tokens, accentMode, accentOverride);
|
||||
|
||||
return {
|
||||
'--background': tokens.background,
|
||||
'--foreground': tokens.foreground,
|
||||
'--card': tokens.card,
|
||||
'--card-foreground': tokens.cardForeground,
|
||||
'--popover': tokens.popover,
|
||||
'--popover-foreground': tokens.popoverForeground,
|
||||
'--primary': accentToken,
|
||||
'--primary-foreground': computedAccentForeground,
|
||||
'--secondary': tokens.secondary,
|
||||
'--secondary-foreground': tokens.secondaryForeground,
|
||||
'--muted': tokens.muted,
|
||||
'--muted-foreground': tokens.mutedForeground,
|
||||
'--accent': accentToken,
|
||||
'--accent-foreground': computedAccentForeground,
|
||||
'--destructive': tokens.destructive,
|
||||
'--destructive-foreground': tokens.destructiveForeground,
|
||||
'--border': tokens.border,
|
||||
'--input': tokens.input,
|
||||
'--ring': accentToken,
|
||||
};
|
||||
};
|
||||
|
||||
export const isValidUiThemeId = (theme: 'light' | 'dark', value: string): boolean => {
|
||||
const list = theme === 'dark' ? DARK_UI_THEMES : LIGHT_UI_THEMES;
|
||||
return list.some((preset) => preset.id === value);
|
||||
@@ -134,31 +183,10 @@ export const applyThemeTokens = (
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(resolvedTheme);
|
||||
root.style.setProperty('--background', tokens.background);
|
||||
root.style.setProperty('--foreground', tokens.foreground);
|
||||
root.style.setProperty('--card', tokens.card);
|
||||
root.style.setProperty('--card-foreground', tokens.cardForeground);
|
||||
root.style.setProperty('--popover', tokens.popover);
|
||||
root.style.setProperty('--popover-foreground', tokens.popoverForeground);
|
||||
const accentToken = accentMode === 'custom' ? accentOverride : tokens.accent;
|
||||
const accentLightness = parseFloat(accentToken.split(/\s+/)[2]?.replace('%', '') || '');
|
||||
const computedAccentForeground = resolvedTheme === 'dark'
|
||||
? '220 40% 96%'
|
||||
: (!Number.isNaN(accentLightness) && accentLightness < 55 ? '0 0% 98%' : '222 47% 12%');
|
||||
|
||||
root.style.setProperty('--primary', accentToken);
|
||||
root.style.setProperty('--primary-foreground', accentMode === 'custom' ? computedAccentForeground : tokens.primaryForeground);
|
||||
root.style.setProperty('--secondary', tokens.secondary);
|
||||
root.style.setProperty('--secondary-foreground', tokens.secondaryForeground);
|
||||
root.style.setProperty('--muted', tokens.muted);
|
||||
root.style.setProperty('--muted-foreground', tokens.mutedForeground);
|
||||
root.style.setProperty('--accent', accentToken);
|
||||
root.style.setProperty('--accent-foreground', accentMode === 'custom' ? computedAccentForeground : tokens.accentForeground);
|
||||
root.style.setProperty('--destructive', tokens.destructive);
|
||||
root.style.setProperty('--destructive-foreground', tokens.destructiveForeground);
|
||||
root.style.setProperty('--border', tokens.border);
|
||||
root.style.setProperty('--input', tokens.input);
|
||||
root.style.setProperty('--ring', accentToken);
|
||||
const cssVars = buildAppThemeCssVars(tokens, accentMode, accentOverride);
|
||||
for (const [property, value] of Object.entries(cssVars)) {
|
||||
root.style.setProperty(property, value);
|
||||
}
|
||||
|
||||
// Sync with native window title bar (Electron)
|
||||
netcattyBridge.get()?.setTheme?.(themeSource);
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
|
||||
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
|
||||
STORAGE_KEY_RESTORE_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
@@ -55,6 +58,7 @@ import {
|
||||
} from './settingsStateDefaults';
|
||||
|
||||
interface UseSettingsStorageSyncParams {
|
||||
enabled?: boolean;
|
||||
theme: 'dark' | 'light' | 'system';
|
||||
lightUiThemeId: string;
|
||||
darkUiThemeId: string;
|
||||
@@ -81,12 +85,15 @@ interface UseSettingsStorageSyncParams {
|
||||
showHostTreeSidebar: boolean;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
disableTerminalFontZoom: boolean;
|
||||
restorePreviousSession: boolean;
|
||||
restoreTerminalCwd: boolean;
|
||||
editorWordWrap: boolean;
|
||||
sessionLogsEnabled: boolean;
|
||||
sessionLogsDir: string;
|
||||
sessionLogsFormat: SessionLogFormat;
|
||||
sessionLogsTimestampsEnabled: boolean;
|
||||
sshDebugLogsEnabled: boolean;
|
||||
sshDeepLinkEnabled: boolean;
|
||||
globalHotkeyEnabled: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
windowOpacity: number;
|
||||
@@ -118,12 +125,15 @@ interface UseSettingsStorageSyncParams {
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
|
||||
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
|
||||
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsDir: Dispatch<SetStateAction<string>>;
|
||||
setSessionLogsFormat: Dispatch<SetStateAction<SessionLogFormat>>;
|
||||
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSshDeepLinkEnabledState: (enabled: boolean) => void;
|
||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWindowOpacity: Dispatch<SetStateAction<number>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -134,13 +144,14 @@ interface UseSettingsStorageSyncParams {
|
||||
}
|
||||
|
||||
export function useSettingsStorageSync({
|
||||
enabled = true,
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
@@ -148,8 +159,8 @@ export function useSettingsStorageSync({
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
}: UseSettingsStorageSyncParams) {
|
||||
@@ -162,8 +173,8 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
});
|
||||
settingsSnapshotRef.current = {
|
||||
@@ -172,13 +183,14 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
};
|
||||
|
||||
// Listen for storage changes from other windows (cross-window sync)
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
const s = settingsSnapshotRef.current;
|
||||
if (e.key === STORAGE_KEY_THEME && e.newValue) {
|
||||
@@ -336,6 +348,12 @@ export function useSettingsStorageSync({
|
||||
setSshDebugLogsEnabled(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SSH_DEEP_LINK_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.sshDeepLinkEnabled) {
|
||||
setSshDeepLinkEnabledState(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP compressed upload setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
|
||||
@@ -398,6 +416,18 @@ export function useSettingsStorageSync({
|
||||
setDisableTerminalFontZoomState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_RESTORE_PREVIOUS_SESSION && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.restorePreviousSession) {
|
||||
setRestorePreviousSessionState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_RESTORE_TERMINAL_CWD && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.restoreTerminalCwd) {
|
||||
setRestoreTerminalCwdState(newValue);
|
||||
}
|
||||
}
|
||||
// Sync global hotkey enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
@@ -436,6 +466,7 @@ export function useSettingsStorageSync({
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [
|
||||
enabled,
|
||||
applyIncomingCustomKeyBindings,
|
||||
mergeIncomingTerminalSettings,
|
||||
setAccentMode,
|
||||
@@ -453,6 +484,7 @@ export function useSettingsStorageSync({
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsFormat,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDeepLinkEnabledState,
|
||||
setSshDebugLogsEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
@@ -468,6 +500,8 @@ export function useSettingsStorageSync({
|
||||
setShowSftpTabState,
|
||||
setShellOnlyTabNumberShortcutsState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
setTerminalFontFamilyId,
|
||||
setTerminalFontSize,
|
||||
setTerminalThemeDarkId,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
|
||||
resolveSftpTransferConcurrency,
|
||||
runSftpTransferWorkers,
|
||||
} from "./transferConcurrency";
|
||||
|
||||
test("defaults folder file transfers to two concurrent files", () => {
|
||||
assert.equal(resolveSftpTransferConcurrency(() => null), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
|
||||
assert.equal(DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY, 2);
|
||||
});
|
||||
|
||||
test("keeps explicit folder transfer concurrency within the supported range", () => {
|
||||
assert.equal(resolveSftpTransferConcurrency(() => 1), 1);
|
||||
assert.equal(resolveSftpTransferConcurrency(() => 16), 16);
|
||||
assert.equal(resolveSftpTransferConcurrency(() => 0), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
|
||||
assert.equal(resolveSftpTransferConcurrency(() => 17), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
|
||||
});
|
||||
|
||||
test("limits default multi-file transfer scheduling to two concurrent workers", async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
|
||||
await runSftpTransferWorkers([1, 2, 3, 4], () => null, async () => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
active -= 1;
|
||||
});
|
||||
|
||||
assert.equal(maxActive, 2);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
export const DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY = 2;
|
||||
export const MIN_SFTP_FILE_TRANSFER_CONCURRENCY = 1;
|
||||
export const MAX_SFTP_FILE_TRANSFER_CONCURRENCY = 16;
|
||||
|
||||
export function resolveSftpTransferConcurrency(readStoredValue: () => number | null | undefined): number {
|
||||
const stored = readStoredValue();
|
||||
return stored != null &&
|
||||
stored >= MIN_SFTP_FILE_TRANSFER_CONCURRENCY &&
|
||||
stored <= MAX_SFTP_FILE_TRANSFER_CONCURRENCY
|
||||
? stored
|
||||
: DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY;
|
||||
}
|
||||
|
||||
export async function runSftpTransferWorkers<T>(
|
||||
items: T[],
|
||||
readStoredConcurrency: () => number | null | undefined,
|
||||
worker: (item: T, index: number) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const concurrency = resolveSftpTransferConcurrency(readStoredConcurrency);
|
||||
let nextIndex = 0;
|
||||
|
||||
const runNext = async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex++;
|
||||
await worker(items[index], index);
|
||||
}
|
||||
};
|
||||
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(concurrency, items.length) },
|
||||
() => runNext(),
|
||||
);
|
||||
await Promise.all(workers);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/c
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { runSftpTransferWorkers } from "./transferConcurrency";
|
||||
import { joinPath } from "./utils";
|
||||
|
||||
interface UseSftpDirectoryTransferOpsParams {
|
||||
@@ -185,11 +186,6 @@ export function useSftpDirectoryTransferOps({
|
||||
});
|
||||
};
|
||||
|
||||
const getTransferConcurrency = () => {
|
||||
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
|
||||
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
|
||||
};
|
||||
|
||||
/** Recursively count all files under a directory (for progress display). */
|
||||
const countDirectoryFiles = async (
|
||||
sourcePath: string,
|
||||
@@ -308,8 +304,8 @@ export function useSftpDirectoryTransferOps({
|
||||
|
||||
// Process subdirectories sequentially to avoid unbounded concurrent SFTP
|
||||
// requests from nested Promise.all + worker pools across the tree.
|
||||
// File-level concurrency within each directory is still governed by
|
||||
// getTransferConcurrency().
|
||||
// File-level concurrency within each directory is still governed by the
|
||||
// shared SFTP transfer worker scheduler below.
|
||||
for (const dir of dirs) {
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
@@ -346,17 +342,16 @@ export function useSftpDirectoryTransferOps({
|
||||
|
||||
// Transfer files in parallel with concurrency limit
|
||||
if (regularFiles.length > 0) {
|
||||
let fileIndex = 0;
|
||||
const errors: Error[] = [];
|
||||
|
||||
const worker = async () => {
|
||||
while (fileIndex < regularFiles.length) {
|
||||
await runSftpTransferWorkers(
|
||||
regularFiles,
|
||||
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
|
||||
async (file) => {
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const idx = fileIndex++;
|
||||
const file = regularFiles[idx];
|
||||
const fileId = crypto.randomUUID();
|
||||
const fileSize = getEntrySize(file);
|
||||
|
||||
@@ -431,15 +426,8 @@ export function useSftpDirectoryTransferOps({
|
||||
if (err instanceof Error && err.message === "Transfer cancelled") throw err;
|
||||
errors.push(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const concurrency = getTransferConcurrency();
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(concurrency, regularFiles.length) },
|
||||
() => worker(),
|
||||
},
|
||||
);
|
||||
await Promise.all(workers);
|
||||
|
||||
totalErrors += errors.length;
|
||||
if (errors.length > 0) {
|
||||
|
||||
@@ -3,7 +3,18 @@ import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { SftpPane } from "./types";
|
||||
import { getFileName, getParentPath, isNavigableDirectory, isWindowsRoot, joinPath } from "./utils";
|
||||
import {
|
||||
getFileName,
|
||||
getParentPath,
|
||||
getSftpFilterAfterPathChange,
|
||||
getSftpFilterAfterPathChangeError,
|
||||
isNavigableDirectory,
|
||||
isSameSftpPath,
|
||||
isWindowsRoot,
|
||||
joinPath,
|
||||
normalizeSftpPathForCompare,
|
||||
shouldClearSftpFilterForPathChange,
|
||||
} from "./utils";
|
||||
import { buildCacheKey, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
|
||||
|
||||
/** Shared empty set for navigation resets — never mutate this. */
|
||||
@@ -32,8 +43,10 @@ interface UseSftpPaneActionsParams {
|
||||
dirCacheTtlMs: number;
|
||||
}
|
||||
|
||||
export type SftpNavigateResult = "reached" | "failed" | "aborted";
|
||||
|
||||
interface UseSftpPaneActionsResult {
|
||||
navigateTo: (side: "left" | "right", path: string, options?: { force?: boolean; tabId?: string }) => Promise<void>;
|
||||
navigateTo: (side: "left" | "right", path: string, options?: { force?: boolean; tabId?: string }) => Promise<SftpNavigateResult>;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
|
||||
navigateUp: (side: "left" | "right") => Promise<void>;
|
||||
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
|
||||
@@ -83,17 +96,12 @@ export const useSftpPaneActions = ({
|
||||
dirCacheTtlMs,
|
||||
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
|
||||
const normalizePathForCompare = useCallback((path: string): string => {
|
||||
if (isWindowsRoot(path)) return path.replace(/\//g, "\\").toLowerCase();
|
||||
if (/^[A-Za-z]:/.test(path)) {
|
||||
return path.replace(/\//g, "\\").replace(/[\\]+$/, "").toLowerCase();
|
||||
}
|
||||
if (path === "/") return "/";
|
||||
return path.replace(/\/+$/, "");
|
||||
return normalizeSftpPathForCompare(path);
|
||||
}, []);
|
||||
|
||||
const isSamePath = useCallback((a: string, b: string): boolean => {
|
||||
return normalizePathForCompare(a) === normalizePathForCompare(b);
|
||||
}, [normalizePathForCompare]);
|
||||
return isSameSftpPath(a, b);
|
||||
}, []);
|
||||
|
||||
const isDescendantPath = useCallback((candidate: string, parent: string): boolean => {
|
||||
const normalizedCandidate = normalizePathForCompare(candidate);
|
||||
@@ -146,7 +154,7 @@ export const useSftpPaneActions = ({
|
||||
// than an intermediate optimistic state from another in-flight navigation.
|
||||
// Includes connectionId so stale entries from a previous host are ignored.
|
||||
const lastConfirmedRef = useRef(
|
||||
new Map<string, { connectionId: string; path: string; files: SftpFileEntry[]; selectedFiles: Set<string> }>(),
|
||||
new Map<string, { connectionId: string; path: string; files: SftpFileEntry[]; selectedFiles: Set<string>; filter: string }>(),
|
||||
);
|
||||
|
||||
const navigateTo = useCallback(
|
||||
@@ -154,7 +162,7 @@ export const useSftpPaneActions = ({
|
||||
side: "left" | "right",
|
||||
path: string,
|
||||
options?: { force?: boolean; tabId?: string },
|
||||
) => {
|
||||
): Promise<SftpNavigateResult> => {
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
// When tabId is specified, target that specific tab instead of the active one.
|
||||
// This allows refreshing a background tab (e.g. after a transfer completes
|
||||
@@ -165,12 +173,14 @@ export const useSftpPaneActions = ({
|
||||
: getActivePane(side);
|
||||
|
||||
if (!pane?.connection || !targetTabId) {
|
||||
return;
|
||||
return "aborted";
|
||||
}
|
||||
|
||||
const connectionId = pane.connection.id;
|
||||
const requestId = ++navSeqRef.current[side];
|
||||
const cacheKey = makeCacheKey(connectionId, path, pane.filenameEncoding);
|
||||
const clearFilterForPathChange = shouldClearSftpFilterForPathChange(pane.connection.currentPath, path);
|
||||
const nextConfirmedFilter = getSftpFilterAfterPathChange(pane.connection.currentPath, path, pane.filter);
|
||||
const cached = options?.force
|
||||
? undefined
|
||||
: dirCacheRef.current.get(cacheKey);
|
||||
@@ -186,6 +196,7 @@ export const useSftpPaneActions = ({
|
||||
path,
|
||||
files: cached.files,
|
||||
selectedFiles: EMPTY_SET,
|
||||
filter: nextConfirmedFilter,
|
||||
});
|
||||
updateTab(side, targetTabId, (prev) => ({
|
||||
...prev,
|
||||
@@ -196,6 +207,7 @@ export const useSftpPaneActions = ({
|
||||
loading: false,
|
||||
error: null,
|
||||
selectedFiles: EMPTY_SET,
|
||||
filter: clearFilterForPathChange ? "" : prev.filter,
|
||||
}));
|
||||
if (!pane.connection.isLocal) {
|
||||
// Use hostId as the shared cache key — this is safe because the
|
||||
@@ -210,7 +222,7 @@ export const useSftpPaneActions = ({
|
||||
filenameEncoding: pane.filenameEncoding,
|
||||
});
|
||||
}
|
||||
return;
|
||||
return "reached";
|
||||
}
|
||||
|
||||
// Re-seed confirmed state whenever the pane is settled (not loading), or
|
||||
@@ -224,12 +236,14 @@ export const useSftpPaneActions = ({
|
||||
path: pane.connection.currentPath,
|
||||
files: pane.files,
|
||||
selectedFiles: pane.selectedFiles,
|
||||
filter: pane.filter,
|
||||
});
|
||||
}
|
||||
const confirmed = lastConfirmedRef.current.get(targetTabId)!;
|
||||
const previousPath = confirmed.path;
|
||||
const previousFiles = confirmed.files;
|
||||
const previousSelection = confirmed.selectedFiles;
|
||||
const previousFilter = confirmed.filter;
|
||||
tabNavSeqRef.current.set(targetTabId, requestId);
|
||||
// Keep existing files visible during loading — the loading overlay
|
||||
// (pointer-events-none) prevents interaction. This avoids blanking a tab
|
||||
@@ -240,6 +254,7 @@ export const useSftpPaneActions = ({
|
||||
? { ...prev.connection, currentPath: path }
|
||||
: null,
|
||||
selectedFiles: EMPTY_SET,
|
||||
filter: clearFilterForPathChange ? "" : prev.filter,
|
||||
loading: true,
|
||||
error: null,
|
||||
}));
|
||||
@@ -264,7 +279,7 @@ export const useSftpPaneActions = ({
|
||||
} else {
|
||||
handleSessionError(side, new Error("SFTP session lost"));
|
||||
}
|
||||
return;
|
||||
return "aborted";
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -282,7 +297,7 @@ export const useSftpPaneActions = ({
|
||||
} else {
|
||||
handleSessionError(side, err as Error);
|
||||
}
|
||||
return;
|
||||
return "aborted";
|
||||
}
|
||||
throw err as Error;
|
||||
}
|
||||
@@ -293,7 +308,7 @@ export const useSftpPaneActions = ({
|
||||
// a connect/disconnect. Check if THIS tab's request is still current.
|
||||
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
|
||||
// This tab also has a newer navigation — drop completely.
|
||||
return;
|
||||
return "aborted";
|
||||
}
|
||||
// Side was superseded by another tab, but this tab's request is
|
||||
// still current. The fetched files are valid — fall through to
|
||||
@@ -310,6 +325,7 @@ export const useSftpPaneActions = ({
|
||||
path,
|
||||
files,
|
||||
selectedFiles: EMPTY_SET,
|
||||
filter: nextConfirmedFilter,
|
||||
});
|
||||
|
||||
updateTab(side, targetTabId, (prev) => ({
|
||||
@@ -320,6 +336,7 @@ export const useSftpPaneActions = ({
|
||||
files,
|
||||
loading: false,
|
||||
selectedFiles: EMPTY_SET,
|
||||
filter: clearFilterForPathChange ? "" : prev.filter,
|
||||
}));
|
||||
if (!pane.connection.isLocal) {
|
||||
setSharedRemoteHostCache(getActivePaneCacheKey(side, pane.connection.hostId, pane.connection.id), {
|
||||
@@ -329,28 +346,33 @@ export const useSftpPaneActions = ({
|
||||
filenameEncoding: pane.filenameEncoding,
|
||||
});
|
||||
}
|
||||
return "reached";
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== requestId) {
|
||||
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
|
||||
return;
|
||||
return "aborted";
|
||||
}
|
||||
// Side superseded by another tab, but this tab's request is
|
||||
// current — fall through to show the error on this tab.
|
||||
}
|
||||
let navigationFailed = false;
|
||||
updateTab(side, targetTabId, (prev) => {
|
||||
if (prev.connection?.id !== connectionId) {
|
||||
return prev;
|
||||
}
|
||||
navigationFailed = true;
|
||||
return {
|
||||
...prev,
|
||||
connection: { ...prev.connection, currentPath: previousPath },
|
||||
files: previousFiles,
|
||||
selectedFiles: previousSelection,
|
||||
filter: getSftpFilterAfterPathChangeError(clearFilterForPathChange, previousFilter, prev.filter),
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to list directory",
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
return navigationFailed ? "failed" : "aborted";
|
||||
}
|
||||
},
|
||||
[
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { isConcreteTransferTargetPath } from "./utils";
|
||||
import {
|
||||
getParentPath,
|
||||
getSftpFilterAfterPathChange,
|
||||
getSftpFilterAfterPathChangeError,
|
||||
isConcreteTransferTargetPath,
|
||||
shouldClearSftpFilterForPathChange,
|
||||
} from "./utils";
|
||||
|
||||
test("concrete transfer target paths exclude temporary placeholders", () => {
|
||||
assert.equal(isConcreteTransferTargetPath({ targetPath: "/Users/alice/Downloads/report.pdf" }), true);
|
||||
@@ -9,3 +15,30 @@ test("concrete transfer target paths exclude temporary placeholders", () => {
|
||||
assert.equal(isConcreteTransferTargetPath({ targetPath: "(temp)" }), false);
|
||||
assert.equal(isConcreteTransferTargetPath({ targetPath: " " }), false);
|
||||
});
|
||||
|
||||
test("SFTP filter clears when entering a different directory", () => {
|
||||
assert.equal(shouldClearSftpFilterForPathChange("/srv", "/srv/app"), true);
|
||||
assert.equal(getSftpFilterAfterPathChange("/srv", "/srv/app", "log"), "");
|
||||
});
|
||||
|
||||
test("SFTP filter clears when navigating to the parent directory", () => {
|
||||
assert.equal(shouldClearSftpFilterForPathChange("/srv/app", getParentPath("/srv/app")), true);
|
||||
assert.equal(getSftpFilterAfterPathChange("/srv/app", getParentPath("/srv/app"), "dist"), "");
|
||||
});
|
||||
|
||||
test("SFTP filter stays when refreshing the same directory", () => {
|
||||
assert.equal(shouldClearSftpFilterForPathChange("/srv/app", "/srv/app"), false);
|
||||
assert.equal(shouldClearSftpFilterForPathChange("/srv/app", "/srv/app/"), false);
|
||||
assert.equal(shouldClearSftpFilterForPathChange("C:\\Users\\alice", "c:/Users/alice"), false);
|
||||
assert.equal(getSftpFilterAfterPathChange("/srv/app", "/srv/app", "log"), "log");
|
||||
assert.equal(getSftpFilterAfterPathChange("/srv/app", "/srv/app/", "log"), "log");
|
||||
assert.equal(getSftpFilterAfterPathChange("C:\\Users\\alice", "c:/Users/alice", "docs"), "docs");
|
||||
});
|
||||
|
||||
test("SFTP filter restores when changed-directory navigation fails", () => {
|
||||
assert.equal(getSftpFilterAfterPathChangeError(true, "log", "typed-while-loading"), "log");
|
||||
});
|
||||
|
||||
test("SFTP filter preserves in-flight edits when same-directory refresh fails", () => {
|
||||
assert.equal(getSftpFilterAfterPathChangeError(false, "log", "typed-while-loading"), "typed-while-loading");
|
||||
});
|
||||
@@ -85,3 +85,39 @@ export const getFileName = (path: string): string => {
|
||||
const parts = path.split(/[\\/]/).filter(Boolean);
|
||||
return parts[parts.length - 1] || "";
|
||||
};
|
||||
|
||||
export const normalizeSftpPathForCompare = (path: string): string => {
|
||||
if (isWindowsRoot(path)) return path.replace(/\//g, "\\").toLowerCase();
|
||||
if (/^[A-Za-z]:/.test(path)) {
|
||||
return path.replace(/\//g, "\\").replace(/[\\]+$/, "").toLowerCase();
|
||||
}
|
||||
if (path === "/") return "/";
|
||||
return path.replace(/\/+$/, "");
|
||||
};
|
||||
|
||||
export const isSameSftpPath = (a: string, b: string): boolean => {
|
||||
return normalizeSftpPathForCompare(a) === normalizeSftpPathForCompare(b);
|
||||
};
|
||||
|
||||
export const shouldClearSftpFilterForPathChange = (
|
||||
currentPath: string,
|
||||
nextPath: string,
|
||||
): boolean => {
|
||||
return !isSameSftpPath(currentPath, nextPath);
|
||||
};
|
||||
|
||||
export const getSftpFilterAfterPathChange = (
|
||||
currentPath: string,
|
||||
nextPath: string,
|
||||
currentFilter: string,
|
||||
): string => {
|
||||
return shouldClearSftpFilterForPathChange(currentPath, nextPath) ? "" : currentFilter;
|
||||
};
|
||||
|
||||
export const getSftpFilterAfterPathChangeError = (
|
||||
clearFilterForPathChange: boolean,
|
||||
previousFilter: string,
|
||||
currentFilter: string,
|
||||
): string => {
|
||||
return clearFilterForPathChange ? previousFilter : currentFilter;
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
interface UseSystemSettingsEffectsParams {
|
||||
enabled?: boolean;
|
||||
toggleWindowHotkey: string;
|
||||
globalHotkeyEnabled: boolean;
|
||||
closeToTray: boolean;
|
||||
@@ -22,6 +23,7 @@ interface UseSystemSettingsEffectsParams {
|
||||
}
|
||||
|
||||
export function useSystemSettingsEffects({
|
||||
enabled = true,
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
@@ -34,6 +36,7 @@ export function useSystemSettingsEffects({
|
||||
}: UseSystemSettingsEffectsParams) {
|
||||
// Persist and sync toggle window hotkey setting
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
// Register/unregister the global hotkey in main process (needed on mount)
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.registerGlobalHotkey) {
|
||||
@@ -64,6 +67,7 @@ export function useSystemSettingsEffects({
|
||||
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
}, [
|
||||
toggleWindowHotkey,
|
||||
enabled,
|
||||
globalHotkeyEnabled,
|
||||
notifySettingsChanged,
|
||||
persistMountedRef,
|
||||
@@ -72,13 +76,15 @@ export function useSystemSettingsEffects({
|
||||
|
||||
// Persist global hotkey enabled setting
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled);
|
||||
}, [globalHotkeyEnabled, notifySettingsChanged, persistMountedRef]);
|
||||
}, [enabled, globalHotkeyEnabled, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Persist and sync close to tray setting
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
// Update main process tray behavior (needed on mount)
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.setCloseToTray) {
|
||||
@@ -90,10 +96,11 @@ export function useSystemSettingsEffects({
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
}, [closeToTray, notifySettingsChanged, persistMountedRef]);
|
||||
}, [enabled, closeToTray, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Persist and sync window opacity
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.setWindowOpacity?.(windowOpacity).catch((err) => {
|
||||
console.warn('[WindowOpacity] Failed to apply window opacity:', err);
|
||||
@@ -101,12 +108,13 @@ export function useSystemSettingsEffects({
|
||||
localStorageAdapter.writeString(STORAGE_KEY_WINDOW_OPACITY, String(windowOpacity));
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_WINDOW_OPACITY, windowOpacity);
|
||||
}, [windowOpacity, notifySettingsChanged, persistMountedRef]);
|
||||
}, [enabled, windowOpacity, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Hydrate auto-update state from the main-process preference file on mount.
|
||||
// This reconciles localStorage (renderer) with auto-update-pref.json (main)
|
||||
// in case localStorage was cleared or is stale.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
void bridge?.getAutoUpdate?.().then((result) => {
|
||||
if (result && typeof result.enabled === 'boolean') {
|
||||
@@ -118,11 +126,12 @@ export function useSystemSettingsEffects({
|
||||
});
|
||||
}
|
||||
}).catch(() => { /* bridge unavailable */ });
|
||||
}, [setAutoUpdateEnabled]);
|
||||
}, [enabled, setAutoUpdateEnabled]);
|
||||
|
||||
// Persist auto-update enabled setting.
|
||||
// Initial mount still writes localStorage, but skips cross-window/main-process IPC.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled);
|
||||
@@ -131,7 +140,7 @@ export function useSystemSettingsEffects({
|
||||
bridge?.setAutoUpdate?.(autoUpdateEnabled).catch((err: unknown) => {
|
||||
console.warn('[AutoUpdate] Failed to set auto-update:', err);
|
||||
});
|
||||
}, [autoUpdateEnabled, notifySettingsChanged, persistMountedRef]);
|
||||
}, [enabled, autoUpdateEnabled, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export const WORKSPACE_SESSION_DRAG_TYPE = 'application/x-netcatty-workspace-session';
|
||||
|
||||
type DataTransferLike = {
|
||||
types: DOMStringList | readonly string[];
|
||||
getData: (format: string) => string;
|
||||
};
|
||||
|
||||
export function dataTransferHasType(dataTransfer: Pick<DataTransferLike, 'types'>, type: string): boolean {
|
||||
return Array.prototype.includes.call(dataTransfer.types, type);
|
||||
}
|
||||
|
||||
export function hasWorkspaceSessionDrag(dataTransfer: Pick<DataTransferLike, 'types'>): boolean {
|
||||
return dataTransferHasType(dataTransfer, WORKSPACE_SESSION_DRAG_TYPE);
|
||||
}
|
||||
|
||||
export function getWorkspaceSessionDragId(dataTransfer: DataTransferLike): string {
|
||||
return dataTransfer.getData(WORKSPACE_SESSION_DRAG_TYPE) || dataTransfer.getData('session-id');
|
||||
}
|
||||
|
||||
export function isPointInsideRect(
|
||||
point: { clientX: number; clientY: number },
|
||||
rect: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>,
|
||||
): boolean {
|
||||
return point.clientX >= rect.left
|
||||
&& point.clientX <= rect.right
|
||||
&& point.clientY >= rect.top
|
||||
&& point.clientY <= rect.bottom;
|
||||
}
|
||||
|
||||
export type TopTabInsertionTarget = {
|
||||
tabId: string;
|
||||
position: 'before' | 'after';
|
||||
};
|
||||
|
||||
export function getTopTabInsertionTarget(
|
||||
point: { clientX: number; clientY: number },
|
||||
topTabsRoot: HTMLElement | null,
|
||||
): TopTabInsertionTarget | null {
|
||||
if (!topTabsRoot || !isPointInsideRect(point, topTabsRoot.getBoundingClientRect())) return null;
|
||||
|
||||
const tabs = Array.from(topTabsRoot.querySelectorAll<HTMLElement>('[data-tab-id]'))
|
||||
.filter((tab) => tab.dataset.tabType !== 'root');
|
||||
|
||||
if (tabs.length === 0) return null;
|
||||
|
||||
for (const tab of tabs) {
|
||||
const rect = tab.getBoundingClientRect();
|
||||
const midpoint = rect.left + rect.width / 2;
|
||||
const tabId = tab.dataset.tabId;
|
||||
if (!tabId) continue;
|
||||
if (point.clientX <= midpoint) {
|
||||
return { tabId, position: 'before' };
|
||||
}
|
||||
if (point.clientX <= rect.right) {
|
||||
return { tabId, position: 'after' };
|
||||
}
|
||||
}
|
||||
|
||||
const lastTab = tabs[tabs.length - 1];
|
||||
const lastTabId = lastTab?.dataset.tabId;
|
||||
return lastTabId ? { tabId: lastTabId, position: 'after' } : null;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
|
||||
|
||||
test("terminalHiddenRendererStore evicts oldest soft-hidden session", () => {
|
||||
terminalHiddenRendererStore.clearSoftHidden("a");
|
||||
terminalHiddenRendererStore.clearSoftHidden("b");
|
||||
terminalHiddenRendererStore.markSoftHidden("a");
|
||||
terminalHiddenRendererStore.markSoftHidden("b");
|
||||
assert.equal(terminalHiddenRendererStore.pickEvictionCandidate(2), "a");
|
||||
terminalHiddenRendererStore.clearSoftHidden("a");
|
||||
terminalHiddenRendererStore.clearSoftHidden("b");
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
type Listener = () => void;
|
||||
|
||||
const softHiddenSessions = new Map<string, number>();
|
||||
const listeners = new Set<Listener>();
|
||||
let evictionRequestSessionId: string | null = null;
|
||||
|
||||
function emit(): void {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export const terminalHiddenRendererStore = {
|
||||
getSoftHiddenCount: () => softHiddenSessions.size,
|
||||
|
||||
isSoftHidden: (sessionId: string) => softHiddenSessions.has(sessionId),
|
||||
|
||||
markSoftHidden: (sessionId: string) => {
|
||||
softHiddenSessions.set(sessionId, Date.now());
|
||||
emit();
|
||||
},
|
||||
|
||||
clearSoftHidden: (sessionId: string) => {
|
||||
if (!softHiddenSessions.delete(sessionId)) return;
|
||||
emit();
|
||||
},
|
||||
|
||||
/** Returns the oldest soft-hidden session id when over the keep limit. */
|
||||
pickEvictionCandidate: (keepCount: number): string | null => {
|
||||
if (softHiddenSessions.size < keepCount) return null;
|
||||
let oldestId: string | null = null;
|
||||
let oldestTs = Number.POSITIVE_INFINITY;
|
||||
for (const [sessionId, ts] of softHiddenSessions) {
|
||||
if (ts < oldestTs) {
|
||||
oldestTs = ts;
|
||||
oldestId = sessionId;
|
||||
}
|
||||
}
|
||||
return oldestId;
|
||||
},
|
||||
|
||||
requestEviction: (sessionId: string) => {
|
||||
evictionRequestSessionId = sessionId;
|
||||
emit();
|
||||
},
|
||||
|
||||
consumeEvictionRequest: (sessionId: string): boolean => {
|
||||
if (evictionRequestSessionId !== sessionId) return false;
|
||||
evictionRequestSessionId = null;
|
||||
return true;
|
||||
},
|
||||
|
||||
subscribe: (listener: Listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
@@ -14,3 +14,26 @@ test('terminalLayoutSuppressStore tracks nested begin/end', () => {
|
||||
terminalLayoutSuppressStore.end();
|
||||
assert.equal(terminalLayoutSuppressStore.getActive(), false);
|
||||
});
|
||||
|
||||
test('terminalLayoutSuppressStore defers subscriber notifications', async () => {
|
||||
let notifyCount = 0;
|
||||
const unsubscribe = terminalLayoutSuppressStore.subscribe(() => {
|
||||
notifyCount += 1;
|
||||
});
|
||||
|
||||
terminalLayoutSuppressStore.begin();
|
||||
assert.equal(notifyCount, 0, 'begin should not notify synchronously');
|
||||
assert.equal(terminalLayoutSuppressStore.getActive(), true);
|
||||
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(notifyCount, 1, 'begin should notify after the current turn');
|
||||
|
||||
terminalLayoutSuppressStore.end();
|
||||
assert.equal(notifyCount, 1, 'end should not notify synchronously');
|
||||
assert.equal(terminalLayoutSuppressStore.getActive(), false);
|
||||
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(notifyCount, 2, 'end should notify after the current turn');
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
@@ -4,9 +4,19 @@ type Listener = () => void;
|
||||
|
||||
let suppressDepth = 0;
|
||||
const listeners = new Set<Listener>();
|
||||
let notifyRafId: number | null = null;
|
||||
|
||||
function emit() {
|
||||
listeners.forEach((listener) => listener());
|
||||
const scheduleFrame =
|
||||
typeof requestAnimationFrame === 'function'
|
||||
? requestAnimationFrame
|
||||
: (cb: () => void) => setTimeout(cb, 0) as unknown as number;
|
||||
|
||||
function scheduleEmit() {
|
||||
if (notifyRafId !== null) return;
|
||||
notifyRafId = scheduleFrame(() => {
|
||||
notifyRafId = null;
|
||||
listeners.forEach((listener) => listener());
|
||||
});
|
||||
}
|
||||
|
||||
export const terminalLayoutSuppressStore = {
|
||||
@@ -19,14 +29,14 @@ export const terminalLayoutSuppressStore = {
|
||||
|
||||
begin: () => {
|
||||
suppressDepth += 1;
|
||||
emit();
|
||||
scheduleEmit();
|
||||
},
|
||||
|
||||
end: () => {
|
||||
const wasActive = suppressDepth > 0;
|
||||
suppressDepth = Math.max(0, suppressDepth - 1);
|
||||
if (wasActive) {
|
||||
emit();
|
||||
scheduleEmit();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_ORDER } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
export type TerminalSidePanelTabId = 'sftp' | 'scripts' | 'history' | 'theme' | 'system' | 'notes' | 'ai';
|
||||
|
||||
export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: TerminalSidePanelTabId[] = [
|
||||
'sftp',
|
||||
'scripts',
|
||||
'history',
|
||||
'theme',
|
||||
'system',
|
||||
'notes',
|
||||
'ai',
|
||||
];
|
||||
|
||||
export const TERMINAL_SIDE_PANEL_TAB_IDS = new Set<TerminalSidePanelTabId>(TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER);
|
||||
|
||||
export function normalizeTerminalSidePanelTabOrder(value: unknown): TerminalSidePanelTabId[] {
|
||||
if (!Array.isArray(value) || value.length !== TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER.length) {
|
||||
return [...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER];
|
||||
}
|
||||
|
||||
const seen = new Set<TerminalSidePanelTabId>();
|
||||
const normalized: TerminalSidePanelTabId[] = [];
|
||||
for (const candidate of value) {
|
||||
if (typeof candidate !== 'string') return [...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER];
|
||||
if (!TERMINAL_SIDE_PANEL_TAB_IDS.has(candidate as TerminalSidePanelTabId)) {
|
||||
return [...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER];
|
||||
}
|
||||
const tab = candidate as TerminalSidePanelTabId;
|
||||
if (seen.has(tab)) return [...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER];
|
||||
seen.add(tab);
|
||||
normalized.push(tab);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function reorderTerminalSidePanelTab(
|
||||
order: TerminalSidePanelTabId[],
|
||||
draggedTab: TerminalSidePanelTabId,
|
||||
targetTab: TerminalSidePanelTabId,
|
||||
placement: 'before' | 'after' = 'before',
|
||||
): TerminalSidePanelTabId[] {
|
||||
if (draggedTab === targetTab) return order;
|
||||
if (!order.includes(draggedTab) || !order.includes(targetTab)) return order;
|
||||
|
||||
const withoutDragged = order.filter((tab) => tab !== draggedTab);
|
||||
const targetIndex = withoutDragged.indexOf(targetTab);
|
||||
if (targetIndex === -1) return order;
|
||||
const insertionIndex = placement === 'after' ? targetIndex + 1 : targetIndex;
|
||||
return [
|
||||
...withoutDragged.slice(0, insertionIndex),
|
||||
draggedTab,
|
||||
...withoutDragged.slice(insertionIndex),
|
||||
];
|
||||
}
|
||||
|
||||
function readTerminalSidePanelTabOrder(): TerminalSidePanelTabId[] {
|
||||
try {
|
||||
return normalizeTerminalSidePanelTabOrder(
|
||||
localStorageAdapter.read<TerminalSidePanelTabId[]>(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_ORDER),
|
||||
);
|
||||
} catch {
|
||||
return [...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER];
|
||||
}
|
||||
}
|
||||
|
||||
function persistTerminalSidePanelTabOrder(order: TerminalSidePanelTabId[]): void {
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_ORDER, order);
|
||||
} catch {
|
||||
// Best effort only; the toolbar still works with the in-memory order.
|
||||
}
|
||||
}
|
||||
|
||||
export function useTerminalSidePanelTabOrder(): {
|
||||
sidePanelTabOrder: TerminalSidePanelTabId[];
|
||||
setSidePanelTabOrder: (order: TerminalSidePanelTabId[]) => void;
|
||||
} {
|
||||
const [sidePanelTabOrder, setSidePanelTabOrderRaw] = useState<TerminalSidePanelTabId[]>(
|
||||
readTerminalSidePanelTabOrder,
|
||||
);
|
||||
|
||||
const setSidePanelTabOrder = useCallback((order: TerminalSidePanelTabId[]) => {
|
||||
setSidePanelTabOrderRaw(order);
|
||||
persistTerminalSidePanelTabOrder(order);
|
||||
}, []);
|
||||
|
||||
return { sidePanelTabOrder, setSidePanelTabOrder };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
themeFingerprint,
|
||||
} from "./useActiveChromeTheme.ts";
|
||||
import { TERMINAL_THEMES } from "../../infrastructure/config/terminalThemes.ts";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
function createInlineStyle() {
|
||||
const values = new Map<string, string>();
|
||||
@@ -93,3 +94,10 @@ test("syncActiveChromeTheme refreshes top tabs when the active theme fingerprint
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("active chrome theme foreground uses contrast calculation", () => {
|
||||
const source = readFileSync(new URL("./useActiveChromeTheme.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /resolveReadableForegroundForHsl\(cursor\)/);
|
||||
assert.doesNotMatch(source, /cursorLightness > 55/);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { runThemeTransition } from "./themeTransition";
|
||||
import { TERMINAL_THEMES } from "../../infrastructure/config/terminalThemes";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import { resolveReadableForegroundForHsl } from "../../domain/colorContrast";
|
||||
|
||||
function hexToHsl(hex: string): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
@@ -79,8 +80,7 @@ function buildChromeCss(theme: TerminalTheme): string {
|
||||
const muted = adjustLightness(bg, isDark ? 10 : -8);
|
||||
const mutedFg = adjustSaturation(adjustLightness(fg, isDark ? -20 : 20), 0.5);
|
||||
const border = adjustLightness(bg, isDark ? 12 : -10);
|
||||
const cursorLightness = parseFloat(cursor.split(" ")[2] ?? "50");
|
||||
const primaryFg = cursorLightness > 55 ? "0 0% 0%" : "0 0% 100%";
|
||||
const primaryFg = resolveReadableForegroundForHsl(cursor);
|
||||
|
||||
const values = [
|
||||
bg, fg, card, fg,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
test("auto-sync establishes the initial data baseline before debouncing edits", () => {
|
||||
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
||||
const baselineCommentIndex = source.indexOf("Establish the initial baseline immediately");
|
||||
const initializationGuardIndex = source.indexOf("if (!isInitializedRef.current)", baselineCommentIndex);
|
||||
const initializedAssignmentIndex = source.indexOf("isInitializedRef.current = true;", initializationGuardIndex);
|
||||
const hashReadIndex = source.indexOf("const currentHash = await getDataHash();", initializationGuardIndex);
|
||||
const debounceCommentIndex = source.indexOf("Debounce first, then build the expensive full-data hash", initializationGuardIndex);
|
||||
const debounceTimerIndex = source.indexOf("syncTimeoutRef.current = setTimeout", debounceCommentIndex);
|
||||
|
||||
assert.notEqual(baselineCommentIndex, -1);
|
||||
assert.notEqual(initializationGuardIndex, -1);
|
||||
assert.notEqual(initializedAssignmentIndex, -1);
|
||||
assert.notEqual(hashReadIndex, -1);
|
||||
assert.notEqual(debounceCommentIndex, -1);
|
||||
assert.notEqual(debounceTimerIndex, -1);
|
||||
assert.ok(
|
||||
initializedAssignmentIndex < hashReadIndex,
|
||||
"initialization must be marked synchronously before reading the baseline hash",
|
||||
);
|
||||
assert.ok(
|
||||
initializationGuardIndex < debounceTimerIndex,
|
||||
"the first baseline hash must be captured before scheduling the debounced auto-sync timer",
|
||||
);
|
||||
});
|
||||
|
||||
test("auto-sync skips only the exact remote-applied data hash", () => {
|
||||
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
||||
const helperIndex = source.indexOf("const getSyncPayloadDataHash = (payload: SyncPayload): string");
|
||||
const skipRefIndex = source.indexOf("const skipNextSyncHashRef = useRef<string | null>(null)");
|
||||
const assignmentIndex = source.indexOf("skipNextSyncHashRef.current = getSyncPayloadDataHash(remotePayload)");
|
||||
const debounceTimerIndex = source.indexOf("syncTimeoutRef.current = setTimeout", assignmentIndex);
|
||||
const skipHashIndex = source.indexOf("const skipHash = skipNextSyncHashRef.current", debounceTimerIndex);
|
||||
const decisionIndex = source.indexOf("resolveAutoSyncHashDecision({", skipHashIndex);
|
||||
const appliedSkipIndex = source.indexOf("appliedSkipHash: skipHash", decisionIndex);
|
||||
const syncingGuardIndex = source.indexOf("if (sync.isSyncing || isSyncRunningRef.current)", decisionIndex);
|
||||
const restoreGuardIndex = source.indexOf("if (isRestoreInProgress())", decisionIndex);
|
||||
const interruptedGuardIndex = source.indexOf("if (readInterruptedVaultApply())", decisionIndex);
|
||||
const syncNowIndex = source.indexOf("const didSync = await syncNow();", interruptedGuardIndex);
|
||||
const didSyncGuardIndex = source.indexOf("if (didSync && skipHash !== null", syncNowIndex);
|
||||
const clearAfterSyncIndex = source.indexOf("skipNextSyncHashRef.current = null;", didSyncGuardIndex);
|
||||
const booleanSkipIndex = source.indexOf("skipNextSyncRef");
|
||||
|
||||
assert.notEqual(helperIndex, -1);
|
||||
assert.notEqual(skipRefIndex, -1);
|
||||
assert.notEqual(assignmentIndex, -1);
|
||||
assert.notEqual(debounceTimerIndex, -1);
|
||||
assert.notEqual(skipHashIndex, -1);
|
||||
assert.notEqual(decisionIndex, -1);
|
||||
assert.notEqual(appliedSkipIndex, -1);
|
||||
assert.notEqual(syncingGuardIndex, -1);
|
||||
assert.notEqual(restoreGuardIndex, -1);
|
||||
assert.notEqual(interruptedGuardIndex, -1);
|
||||
assert.notEqual(syncNowIndex, -1);
|
||||
assert.notEqual(didSyncGuardIndex, -1);
|
||||
assert.notEqual(clearAfterSyncIndex, -1);
|
||||
assert.equal(booleanSkipIndex, -1);
|
||||
assert.ok(
|
||||
skipHashIndex < decisionIndex,
|
||||
"remote-apply skip must pass through the hash decision helper before suppressing a sync",
|
||||
);
|
||||
assert.ok(
|
||||
interruptedGuardIndex < syncNowIndex && syncNowIndex < clearAfterSyncIndex,
|
||||
"remote-apply skip hash must survive temporary sync blockers and clear only after a successful sync",
|
||||
);
|
||||
});
|
||||
@@ -20,9 +20,10 @@ import { mergeSyncPayloads } from '../../domain/syncMerge';
|
||||
import { resolveCloudSyncConflictAction } from '../../domain/syncStrategy';
|
||||
import {
|
||||
SYNCABLE_SETTING_STORAGE_KEYS,
|
||||
collectSyncableSettings,
|
||||
collectCloudSyncableSettings,
|
||||
getEffectivePortForwardingRulesForSync,
|
||||
hasMeaningfulCloudSyncData,
|
||||
sanitizePortForwardingRulesForSync,
|
||||
shouldPromptCloudVaultRecovery,
|
||||
} from '../syncPayload';
|
||||
import { readInterruptedVaultApply } from '../localVaultBackups';
|
||||
@@ -38,8 +39,10 @@ import {
|
||||
getRuntimeRemoteCheckIntervalMs,
|
||||
shouldRunRuntimeRemoteCheck,
|
||||
} from './autoSyncRemoteSchedule';
|
||||
import { resolveAutoSyncHashDecision } from './autoSyncHashDecision';
|
||||
|
||||
interface AutoSyncConfig {
|
||||
enabled?: boolean;
|
||||
// Data to sync
|
||||
hosts: SyncPayload['hosts'];
|
||||
keys: SyncPayload['keys'];
|
||||
@@ -48,6 +51,8 @@ interface AutoSyncConfig {
|
||||
snippets: SyncPayload['snippets'];
|
||||
customGroups: SyncPayload['customGroups'];
|
||||
snippetPackages?: SyncPayload['snippetPackages'];
|
||||
notes?: SyncPayload['notes'];
|
||||
noteGroups?: SyncPayload['noteGroups'];
|
||||
portForwardingRules?: SyncPayload['portForwardingRules'];
|
||||
groupConfigs?: SyncPayload['groupConfigs'];
|
||||
/** Opaque token that changes whenever a synced setting changes. */
|
||||
@@ -96,6 +101,23 @@ const isRestoreInProgress = (): boolean => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const getSyncPayloadDataHash = (payload: SyncPayload): string => {
|
||||
return JSON.stringify({
|
||||
hosts: payload.hosts,
|
||||
keys: payload.keys,
|
||||
identities: payload.identities,
|
||||
proxyProfiles: payload.proxyProfiles,
|
||||
snippets: payload.snippets,
|
||||
customGroups: payload.customGroups,
|
||||
snippetPackages: payload.snippetPackages,
|
||||
notes: payload.notes,
|
||||
noteGroups: payload.noteGroups,
|
||||
portForwardingRules: sanitizePortForwardingRulesForSync(payload.portForwardingRules),
|
||||
groupConfigs: payload.groupConfigs,
|
||||
settings: payload.settings,
|
||||
});
|
||||
};
|
||||
|
||||
type SyncTrigger = 'auto' | 'manual';
|
||||
|
||||
interface SyncNowOptions {
|
||||
@@ -108,6 +130,7 @@ interface RemoteVersionCheckOptions {
|
||||
}
|
||||
|
||||
export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const enabled = config.enabled !== false;
|
||||
const { t } = useI18n();
|
||||
const tRef = useRef(t);
|
||||
useEffect(() => {
|
||||
@@ -126,7 +149,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const remoteCheckDoneRef = useRef(false);
|
||||
const isInitializedRef = useRef(false);
|
||||
const isSyncRunningRef = useRef(false);
|
||||
const skipNextSyncRef = useRef(false);
|
||||
const skipNextSyncHashRef = useRef<string | null>(null);
|
||||
|
||||
// State for the empty-vault-vs-cloud confirmation dialog (Fix D).
|
||||
// When checkRemoteVersion detects that the local vault is empty but
|
||||
@@ -138,6 +161,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
keyCount: number;
|
||||
proxyProfileCount: number;
|
||||
snippetCount: number;
|
||||
noteCount: number;
|
||||
} | null>(null);
|
||||
const emptyVaultResolveRef = useRef<((action: 'restore' | 'keep-empty') => void) | null>(null);
|
||||
|
||||
@@ -181,6 +205,8 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
snippets: config.snippets,
|
||||
customGroups: config.customGroups,
|
||||
snippetPackages: config.snippetPackages,
|
||||
notes: config.notes,
|
||||
noteGroups: config.noteGroups,
|
||||
portForwardingRules: getEffectivePortForwardingRulesForSync(config.portForwardingRules),
|
||||
groupConfigs: config.groupConfigs,
|
||||
};
|
||||
@@ -192,26 +218,29 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
config.snippets,
|
||||
config.customGroups,
|
||||
config.snippetPackages,
|
||||
config.notes,
|
||||
config.noteGroups,
|
||||
config.portForwardingRules,
|
||||
config.groupConfigs,
|
||||
]);
|
||||
|
||||
// Build sync payload
|
||||
const buildPayload = useCallback((): SyncPayload => {
|
||||
const buildPayload = useCallback(async (): Promise<SyncPayload> => {
|
||||
return {
|
||||
...getSyncSnapshot(),
|
||||
settings: collectSyncableSettings(),
|
||||
settings: await collectCloudSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
}, [getSyncSnapshot]);
|
||||
|
||||
// Create a hash of current data for comparison (includes settings)
|
||||
const getDataHash = useCallback(() => {
|
||||
return JSON.stringify({ ...getSyncSnapshot(), settings: collectSyncableSettings() });
|
||||
const getDataHash = useCallback(async () => {
|
||||
return JSON.stringify({ ...getSyncSnapshot(), settings: await collectCloudSyncableSettings() });
|
||||
}, [getSyncSnapshot]);
|
||||
|
||||
// Sync now handler - get fresh state directly from manager
|
||||
const syncNow = useCallback(async (options?: SyncNowOptions) => {
|
||||
const syncNow = useCallback(async (options?: SyncNowOptions): Promise<boolean> => {
|
||||
if (!enabled) return false;
|
||||
const trigger: SyncTrigger = options?.trigger ?? 'auto';
|
||||
|
||||
isSyncRunningRef.current = true;
|
||||
@@ -228,7 +257,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (syncing) {
|
||||
if (trigger === 'auto') {
|
||||
console.info('[AutoSync] Skipping overlapping auto-sync because another sync is already running.');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
throw new Error(t('sync.autoSync.alreadySyncing'));
|
||||
}
|
||||
@@ -248,7 +277,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (isRestoreInProgress()) {
|
||||
if (trigger === 'auto') {
|
||||
console.info('[AutoSync] Skipping: a vault restore is in progress in another window.');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
throw new Error(t('sync.autoSync.restoreInProgress'));
|
||||
}
|
||||
@@ -272,7 +301,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
'[AutoSync] Skipping: previous apply was interrupted — refusing to push partial state.',
|
||||
interruptedApply,
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
throw new Error(t('sync.autoSync.interruptedApplyMessage'));
|
||||
}
|
||||
@@ -295,8 +324,8 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
throw new Error(t('sync.autoSync.vaultLocked'));
|
||||
}
|
||||
|
||||
const dataHash = getDataHash();
|
||||
const payload = buildPayload();
|
||||
const dataHash = await getDataHash();
|
||||
const payload = await buildPayload();
|
||||
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
|
||||
if (encryptedCredentialPaths.length > 0) {
|
||||
console.warn('[AutoSync] Blocked: encrypted credential placeholders found at:', encryptedCredentialPaths.join(', '));
|
||||
@@ -317,7 +346,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (!hasMeaningfulCloudSyncData(payload)) {
|
||||
if (trigger === 'auto') {
|
||||
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
throw new Error(t('sync.autoSync.emptyVaultManual'));
|
||||
}
|
||||
@@ -338,7 +367,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
recordDownload: true,
|
||||
});
|
||||
}
|
||||
skipNextSyncRef.current = allProvidersSynced;
|
||||
skipNextSyncHashRef.current = allProvidersSynced ? getSyncPayloadDataHash(result.mergedPayload) : null;
|
||||
if (!allProvidersSynced) {
|
||||
console.warn('[AutoSync] Remote payload applied locally, but not every provider synced; leaving next auto-sync enabled for retry.');
|
||||
}
|
||||
@@ -368,6 +397,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// would require the user to manually sync after every edit.
|
||||
hasCheckedRemoteRef.current = true;
|
||||
remoteCheckDoneRef.current = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (trigger === 'manual') {
|
||||
throw error;
|
||||
@@ -377,10 +407,11 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('sync.autoSync.failedTitle'),
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
isSyncRunningRef.current = false;
|
||||
}
|
||||
}, [sync, buildPayload, getDataHash, onApplyPayload, t]);
|
||||
}, [enabled, sync, buildPayload, getDataHash, onApplyPayload, t]);
|
||||
|
||||
// One-shot toast per mount when a previous apply was interrupted, so the
|
||||
// user understands why auto-sync is silently paused and where to go to
|
||||
@@ -389,6 +420,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// after the user completes a recovery.
|
||||
const interruptedApplyNotifiedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (interruptedApplyNotifiedRef.current) return;
|
||||
if (!sync.isUnlocked) return;
|
||||
const interrupted = readInterruptedVaultApply();
|
||||
@@ -398,7 +430,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
t('sync.autoSync.interruptedApplyMessage'),
|
||||
t('sync.autoSync.interruptedApplyTitle'),
|
||||
);
|
||||
}, [sync.isUnlocked, t]);
|
||||
}, [enabled, sync.isUnlocked, t]);
|
||||
|
||||
// Stabilize the fields `checkRemoteVersion` reads from `config`.
|
||||
// AutoSyncConfig is a fresh object literal on every App render, so a
|
||||
@@ -439,6 +471,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
// Check remote version and pull if newer (on startup)
|
||||
const checkRemoteVersion = useCallback(async (options?: RemoteVersionCheckOptions) => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
if (checkRemoteInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -487,7 +522,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
const remoteFile = inspection.remoteFile;
|
||||
const remotePayload = inspection.payload;
|
||||
const localPayload = buildPayloadRef.current();
|
||||
const localPayload = await buildPayloadRef.current();
|
||||
|
||||
// If local vault is empty but cloud has data, this almost certainly
|
||||
// means the user's data was lost (update, storage corruption, etc.).
|
||||
@@ -501,6 +536,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
keyCount: remotePayload.keys?.length ?? 0,
|
||||
proxyProfileCount: remotePayload.proxyProfiles?.length ?? 0,
|
||||
snippetCount: remotePayload.snippets?.length ?? 0,
|
||||
noteCount: remotePayload.notes?.length ?? 0,
|
||||
});
|
||||
});
|
||||
setEmptyVaultConflict(null);
|
||||
@@ -516,7 +552,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload, {
|
||||
recordDownload: true,
|
||||
});
|
||||
skipNextSyncRef.current = true;
|
||||
skipNextSyncHashRef.current = getSyncPayloadDataHash(remotePayload);
|
||||
startupConsistent = true;
|
||||
notify.success(tRef.current('sync.autoSync.restoredMessage'), tRef.current('sync.autoSync.restoredTitle'));
|
||||
} else {
|
||||
@@ -553,7 +589,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const wasShrinkBlocked = roundTripResultList.some((result) => result.shrinkBlocked === true);
|
||||
const roundTripFullySynced = roundTripResultList.length > 0
|
||||
&& roundTripResultList.every((result) => result.success);
|
||||
skipNextSyncRef.current = roundTripFullySynced || wasShrinkBlocked;
|
||||
skipNextSyncHashRef.current = (roundTripFullySynced || wasShrinkBlocked)
|
||||
? getSyncPayloadDataHash(remotePayload)
|
||||
: null;
|
||||
markCurrentDataSynced = roundTripFullySynced || wasShrinkBlocked;
|
||||
if (wasShrinkBlocked) {
|
||||
console.warn('[AutoSync] Cloud-wins round-trip was shrink-blocked; cloud data applied locally, leaving sync blocked for user review.');
|
||||
@@ -633,7 +671,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// once React commits the applied state, since we've just
|
||||
// already pushed that exact payload upstream. If some provider
|
||||
// failed, allow the follow-up tick to retry the applied payload.
|
||||
skipNextSyncRef.current = roundTripFullySynced || wasShrinkBlocked;
|
||||
skipNextSyncHashRef.current = (roundTripFullySynced || wasShrinkBlocked)
|
||||
? getSyncPayloadDataHash(mergeResult.payload)
|
||||
: null;
|
||||
markCurrentDataSynced = roundTripFullySynced || wasShrinkBlocked;
|
||||
} catch (error) {
|
||||
// Non-fatal: the next user edit will drive another sync cycle.
|
||||
@@ -659,7 +699,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
isInitializedRef.current = true;
|
||||
}
|
||||
if (markCurrentDataSynced) {
|
||||
lastSyncedDataRef.current = getDataHashRef.current();
|
||||
lastSyncedDataRef.current = await getDataHashRef.current();
|
||||
} else {
|
||||
lastSyncedDataRef.current = '';
|
||||
}
|
||||
@@ -683,7 +723,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// callback and restart the retry-timer's exponential backoff.
|
||||
// `t` is read through tRef so locale updates don't rebuild this
|
||||
// callback and re-fire the startup retry effect on unrelated renders.
|
||||
}, []);
|
||||
}, [enabled]);
|
||||
|
||||
const checkRemoteVersionRef = useRef(checkRemoteVersion);
|
||||
useEffect(() => {
|
||||
@@ -692,6 +732,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
// Debounced auto-sync when data changes
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
// Skip if not ready
|
||||
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
|
||||
return;
|
||||
@@ -705,62 +746,87 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip initial render
|
||||
if (!isInitializedRef.current) {
|
||||
isInitializedRef.current = true;
|
||||
lastSyncedDataRef.current = getDataHash();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentHash = getDataHash();
|
||||
|
||||
// After a merge, onApplyPayload changes local state which triggers
|
||||
// this effect. Skip that cycle and just update the hash baseline.
|
||||
if (skipNextSyncRef.current) {
|
||||
skipNextSyncRef.current = false;
|
||||
lastSyncedDataRef.current = currentHash;
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if data hasn't changed
|
||||
if (currentHash === lastSyncedDataRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for the current sync to finish, then this effect will re-run
|
||||
// because sync.isSyncing changed.
|
||||
if (sync.isSyncing || isSyncRunningRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hold off on scheduling a new push while another window is applying
|
||||
// a restore — the restore is about to land via localStorage and the
|
||||
// debounce-fired syncNow would otherwise race it. The next data-
|
||||
// change tick after the restore barrier clears will re-enter here.
|
||||
if (isRestoreInProgress()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't even schedule a push while the apply-in-progress sentinel
|
||||
// is held. The syncNow path re-checks and refuses too, but dropping
|
||||
// the debounced schedule here avoids spinning a 3-second timer for
|
||||
// every keystroke while the user is in the Restore UI working
|
||||
// through recovery.
|
||||
if (readInterruptedVaultApply()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing timeout
|
||||
let cancelled = false;
|
||||
if (syncTimeoutRef.current) {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce sync by 3 seconds
|
||||
|
||||
// Establish the initial baseline immediately. If this were delayed by
|
||||
// the debounce below, an edit made right after startup could become the
|
||||
// baseline and never be pushed.
|
||||
if (!isInitializedRef.current) {
|
||||
isInitializedRef.current = true;
|
||||
void (async () => {
|
||||
const currentHash = await getDataHash();
|
||||
if (cancelled) return;
|
||||
lastSyncedDataRef.current = currentHash;
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
// Debounce first, then build the expensive full-data hash. This keeps
|
||||
// rapid edit bursts from serializing the whole vault on every keystroke.
|
||||
syncTimeoutRef.current = setTimeout(() => {
|
||||
syncNow();
|
||||
syncTimeoutRef.current = null;
|
||||
void (async () => {
|
||||
const currentHash = await getDataHash();
|
||||
if (cancelled) return;
|
||||
|
||||
// After a remote apply or merge, skip only that exact applied data.
|
||||
// If the user edits before this timer fires, the hash differs and the
|
||||
// edit still syncs normally.
|
||||
const skipHash = skipNextSyncHashRef.current;
|
||||
|
||||
const hashDecision = resolveAutoSyncHashDecision({
|
||||
currentHash,
|
||||
lastSyncedHash: lastSyncedDataRef.current,
|
||||
appliedSkipHash: skipHash,
|
||||
});
|
||||
if (hashDecision === 'skip-applied') {
|
||||
if (skipNextSyncHashRef.current === skipHash) {
|
||||
skipNextSyncHashRef.current = null;
|
||||
}
|
||||
lastSyncedDataRef.current = currentHash;
|
||||
return;
|
||||
}
|
||||
if (hashDecision === 'unchanged') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for the current sync to finish, then this effect will re-run
|
||||
// because sync.isSyncing changed.
|
||||
if (sync.isSyncing || isSyncRunningRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hold off on scheduling a new push while another window is applying
|
||||
// a restore — the restore is about to land via localStorage and the
|
||||
// debounce-fired syncNow would otherwise race it. The next data-
|
||||
// change tick after the restore barrier clears will re-enter here.
|
||||
if (isRestoreInProgress()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't even schedule a push while the apply-in-progress sentinel
|
||||
// is held. The syncNow path re-checks and refuses too, but dropping
|
||||
// the debounced schedule here avoids spinning a 3-second timer for
|
||||
// every keystroke while the user is in the Restore UI working
|
||||
// through recovery.
|
||||
if (readInterruptedVaultApply()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const didSync = await syncNow();
|
||||
if (didSync && skipHash !== null && skipNextSyncHashRef.current === skipHash) {
|
||||
skipNextSyncHashRef.current = null;
|
||||
}
|
||||
})();
|
||||
}, 3000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (syncTimeoutRef.current) {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
@@ -773,6 +839,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
getDataHash,
|
||||
syncNow,
|
||||
config.settingsVersion,
|
||||
enabled,
|
||||
bookmarksVersion,
|
||||
syncableSettingsStorageVersion,
|
||||
]);
|
||||
@@ -784,6 +851,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// sync from Settings — the 30s/60s/90s cadence below lets a short
|
||||
// outage (network blip, provider rate-limit) self-heal.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (
|
||||
!sync.hasAnyConnectedProvider ||
|
||||
!sync.isUnlocked ||
|
||||
@@ -838,9 +906,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
cancelled = true;
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady]);
|
||||
}, [enabled, sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady]);
|
||||
|
||||
const runRuntimeRemoteCheck = useCallback(async (options?: { force?: boolean }) => {
|
||||
if (!enabled) return;
|
||||
const now = Date.now();
|
||||
const minIntervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
|
||||
if (!shouldRunRuntimeRemoteCheck({
|
||||
@@ -863,6 +932,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
await checkRemoteVersion({ force: true, notifyOnFailure: false });
|
||||
}, [
|
||||
checkRemoteVersion,
|
||||
enabled,
|
||||
sync.autoSyncEnabled,
|
||||
sync.autoSyncInterval,
|
||||
sync.hasAnyConnectedProvider,
|
||||
@@ -874,6 +944,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// another device uploads changes after our startup inspection but before
|
||||
// this device edits anything locally.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
|
||||
return;
|
||||
}
|
||||
@@ -885,6 +956,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
return () => window.clearInterval(timerId);
|
||||
}, [
|
||||
enabled,
|
||||
runRuntimeRemoteCheck,
|
||||
sync.autoSyncEnabled,
|
||||
sync.autoSyncInterval,
|
||||
@@ -894,6 +966,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
// Also re-check when the user returns to the app or the network comes back.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
@@ -911,16 +984,17 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.removeEventListener('online', handleOnline);
|
||||
};
|
||||
}, [runRuntimeRemoteCheck]);
|
||||
}, [enabled, runRuntimeRemoteCheck]);
|
||||
|
||||
// Reset check flags when provider disconnects
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (!sync.hasAnyConnectedProvider) {
|
||||
hasCheckedRemoteRef.current = false;
|
||||
remoteCheckDoneRef.current = false;
|
||||
lastRuntimeRemoteCheckAtRef.current = null;
|
||||
}
|
||||
}, [sync.hasAnyConnectedProvider]);
|
||||
}, [enabled, sync.hasAnyConnectedProvider]);
|
||||
|
||||
// On unmount, release any pending empty-vault confirmation. Without
|
||||
// this, an unmount mid-dialog (window close, workspace switch) leaves
|
||||
|
||||
@@ -111,6 +111,7 @@ export interface CloudSyncHook {
|
||||
hostCount: number;
|
||||
keyCount: number;
|
||||
snippetCount: number;
|
||||
noteCount: number;
|
||||
identityCount: number;
|
||||
portForwardingRuleCount: number;
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ export const getAppLevelActions = (): Set<string> => {
|
||||
'nextTab',
|
||||
'prevTab',
|
||||
'closeTab',
|
||||
'closeSession',
|
||||
'newTab',
|
||||
'openHosts',
|
||||
'openSftp',
|
||||
@@ -35,6 +36,7 @@ export const getAppLevelActions = (): Set<string> => {
|
||||
'splitVertical',
|
||||
'moveFocus',
|
||||
'broadcast',
|
||||
'togglePaneZoom',
|
||||
'openLocal',
|
||||
'openSettings',
|
||||
]);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { logger } from "../../lib/logger";
|
||||
|
||||
export interface UsePortForwardingAutoStartOptions {
|
||||
enabled?: boolean;
|
||||
isVaultInitialized: boolean;
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
@@ -98,6 +99,7 @@ export const getAutoStartRuleBlockReason = (
|
||||
* This hook should be called at the App level to run on app launch.
|
||||
*/
|
||||
export const usePortForwardingAutoStart = ({
|
||||
enabled = true,
|
||||
isVaultInitialized,
|
||||
hosts,
|
||||
keys,
|
||||
@@ -202,6 +204,7 @@ export const usePortForwardingAutoStart = ({
|
||||
|
||||
// Set up the reconnect callback
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const handleReconnect = async (
|
||||
ruleId: string,
|
||||
onStatusChange: (status: PortForwardingRule["status"], error?: string) => void,
|
||||
@@ -249,10 +252,11 @@ export const usePortForwardingAutoStart = ({
|
||||
return () => {
|
||||
setReconnectCallback(null);
|
||||
};
|
||||
}, [isHostAuthReady, resolveEffectiveHost, resolveEffectiveHosts]);
|
||||
}, [enabled, isHostAuthReady, resolveEffectiveHost, resolveEffectiveHosts]);
|
||||
|
||||
// Auto-start rules on app launch
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (autoStartExecutedRef.current) return;
|
||||
if (!isVaultInitialized) return;
|
||||
|
||||
@@ -319,6 +323,7 @@ export const usePortForwardingAutoStart = ({
|
||||
void runAutoStart();
|
||||
}, [
|
||||
groupConfigs,
|
||||
enabled,
|
||||
hosts,
|
||||
identities,
|
||||
isHostAuthReady,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MouseEvent,useCallback,useMemo,useRef,useState } from 'react';
|
||||
import { MouseEvent,useCallback,useEffect,useMemo,useRef,useState } from 'react';
|
||||
import { ConnectionLog,Host,SerialConfig,Snippet,TerminalSession,Workspace,WorkspaceViewMode } from '../../domain/models';
|
||||
import { addLogView, getLogViewTabId, removeLogView, type LogView } from './logViewState';
|
||||
import { createHostTerminalSession, createLocalTerminalSession, createSerialTerminalSession, type LocalTerminalOptions } from './sessionFactories';
|
||||
@@ -17,17 +17,48 @@ SplitHint,
|
||||
updateWorkspaceSplitSizes,
|
||||
} from '../../domain/workspace';
|
||||
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
|
||||
import { buildOrderedWorkTabIds } from '../app/workTabSurface';
|
||||
import { buildOrderedWorkTabIds, reorderWorkTabIds } from '../app/workTabSurface';
|
||||
import { activeTabStore } from './activeTabStore';
|
||||
import {
|
||||
closeSessionWorkspaceLayoutState,
|
||||
detachSessionFromWorkspaceState,
|
||||
replaceDissolvedWorkspaceTabOrder,
|
||||
} from './sessionWorkspaceDetach';
|
||||
import {
|
||||
createCopiedTerminalSessionClone,
|
||||
createSplitTerminalSessionClone,
|
||||
} from './terminalConnectionReuse';
|
||||
import { STORAGE_KEY_RESTORE_PREVIOUS_SESSION } from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
|
||||
localStorageAdapter,
|
||||
} from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { sessionRestoreStorage } from './sessionRestoreStorage';
|
||||
import {
|
||||
buildAndWriteSessionRestorePayload,
|
||||
createInitialRestoredSessionState,
|
||||
shouldPersistSessionRestoreState,
|
||||
updateRestoredSessionStatusState,
|
||||
} from './sessionRestoreState';
|
||||
import { resolveRestorePreviousSessionSetting } from './sessionRestoreSettings';
|
||||
import type { CodingCliProviderId } from '../../domain/codingCliProviders';
|
||||
import { normalizeCodingCliDynamicTitleForStorage } from '../../domain/codingCliTitleParse';
|
||||
|
||||
|
||||
export const useSessionState = () => {
|
||||
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
export const useSessionState = ({
|
||||
persistSessionRestore = true,
|
||||
}: {
|
||||
persistSessionRestore?: boolean;
|
||||
} = {}) => {
|
||||
const initialRestoreState = useMemo(() => createInitialRestoredSessionState({
|
||||
restoreEnabled: persistSessionRestore && resolveRestorePreviousSessionSetting(
|
||||
localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION),
|
||||
),
|
||||
payload: persistSessionRestore ? sessionRestoreStorage.read() : null,
|
||||
}), [persistSessionRestore]);
|
||||
const [sessions, setSessions] = useState<TerminalSession[]>(initialRestoreState.sessions);
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>(initialRestoreState.workspaces);
|
||||
// Latest workspaces snapshot for synchronous existence checks outside
|
||||
// setWorkspaces updaters — React doesn't guarantee updaters run
|
||||
// synchronously, so relying on a flag flipped inside them to decide
|
||||
@@ -42,11 +73,189 @@ export const useSessionState = () => {
|
||||
const [workspaceRenameTarget, setWorkspaceRenameTarget] = useState<Workspace | null>(null);
|
||||
const [workspaceRenameValue, setWorkspaceRenameValue] = useState('');
|
||||
// Tab order: stores ordered list of tab IDs (orphan session IDs and workspace IDs)
|
||||
const [tabOrder, setTabOrder] = useState<string[]>([]);
|
||||
const [tabOrder, setTabOrder] = useState<string[]>(initialRestoreState.tabOrder);
|
||||
// Broadcast mode: stores workspace IDs that have broadcast enabled
|
||||
const [broadcastWorkspaceIds, setBroadcastWorkspaceIds] = useState<Set<string>>(new Set());
|
||||
// Log views: stores open log replay tabs
|
||||
const [logViews, setLogViews] = useState<LogView[]>([]);
|
||||
const [restorePreviousSessionRevision, setRestorePreviousSessionRevision] = useState(0);
|
||||
const sessionsRef = useRef(sessions);
|
||||
const tabOrderRef = useRef(tabOrder);
|
||||
const scheduleSessionRestorePersistRef = useRef<() => void>(() => {});
|
||||
const sessionRestoreCwdByIdRef = useRef(
|
||||
new Map(
|
||||
initialRestoreState.sessions
|
||||
.filter((session) => Boolean(session.lastCwd))
|
||||
.map((session) => [session.id, session.lastCwd as string]),
|
||||
),
|
||||
);
|
||||
const hasSeenRestorableSessionRestoreStateRef = useRef(
|
||||
persistSessionRestore && shouldPersistSessionRestoreState(
|
||||
initialRestoreState.sessions,
|
||||
initialRestoreState.workspaces,
|
||||
initialRestoreState.tabOrder,
|
||||
),
|
||||
);
|
||||
sessionsRef.current = sessions;
|
||||
tabOrderRef.current = tabOrder;
|
||||
if (persistSessionRestore && shouldPersistSessionRestoreState(sessions, workspaces, tabOrder)) {
|
||||
hasSeenRestorableSessionRestoreStateRef.current = true;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (initialRestoreState.activeTabId !== 'vault') {
|
||||
activeTabStore.setActiveTabId(initialRestoreState.activeTabId);
|
||||
}
|
||||
}, [initialRestoreState.activeTabId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleRestorePreviousSessionChanged = (key?: string) => {
|
||||
if (key !== STORAGE_KEY_RESTORE_PREVIOUS_SESSION) return;
|
||||
setRestorePreviousSessionRevision((revision) => revision + 1);
|
||||
};
|
||||
const handleLocalStorageAdapterChanged = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ key?: string }>).detail;
|
||||
handleRestorePreviousSessionChanged(detail?.key);
|
||||
};
|
||||
|
||||
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
|
||||
const unsubscribeSettingsSync = netcattyBridge.get()?.onSettingsChanged?.((payload) => {
|
||||
handleRestorePreviousSessionChanged(payload?.key);
|
||||
});
|
||||
return () => {
|
||||
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
|
||||
unsubscribeSettingsSync?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!persistSessionRestore) return;
|
||||
|
||||
const restoreEnabled = resolveRestorePreviousSessionSetting(
|
||||
localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION),
|
||||
);
|
||||
if (!restoreEnabled) {
|
||||
scheduleSessionRestorePersistRef.current = () => {};
|
||||
sessionRestoreStorage.clear();
|
||||
hasSeenRestorableSessionRestoreStateRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let timeout: number | undefined;
|
||||
|
||||
const persistNow = () => {
|
||||
const sessionsForRestore = sessionsRef.current.map((session) => {
|
||||
const cwd = sessionRestoreCwdByIdRef.current.get(session.id);
|
||||
if (cwd) {
|
||||
return session.lastCwd === cwd ? session : { ...session, lastCwd: cwd };
|
||||
}
|
||||
if (session.lastCwd === undefined) return session;
|
||||
const { lastCwd: _lastCwd, ...rest } = session;
|
||||
return rest;
|
||||
});
|
||||
const hasRestorableState = shouldPersistSessionRestoreState(
|
||||
sessionsForRestore,
|
||||
workspacesRef.current,
|
||||
tabOrderRef.current,
|
||||
);
|
||||
const clearOnEmpty = hasSeenRestorableSessionRestoreStateRef.current && !hasRestorableState;
|
||||
buildAndWriteSessionRestorePayload({
|
||||
restoreEnabled: resolveRestorePreviousSessionSetting(
|
||||
localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION),
|
||||
),
|
||||
clearOnEmpty,
|
||||
sessions: sessionsForRestore,
|
||||
workspaces: workspacesRef.current,
|
||||
tabOrder: tabOrderRef.current,
|
||||
activeTabId: activeTabStore.getActiveTabId(),
|
||||
storage: sessionRestoreStorage,
|
||||
});
|
||||
};
|
||||
|
||||
const schedulePersist = () => {
|
||||
if (timeout !== undefined) {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
timeout = window.setTimeout(() => {
|
||||
timeout = undefined;
|
||||
persistNow();
|
||||
}, 250);
|
||||
};
|
||||
|
||||
schedulePersist();
|
||||
scheduleSessionRestorePersistRef.current = schedulePersist;
|
||||
const unsubscribeActiveTab = activeTabStore.subscribeSync(schedulePersist);
|
||||
|
||||
const handlePageHide = () => {
|
||||
if (timeout !== undefined) {
|
||||
window.clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
}
|
||||
persistNow();
|
||||
};
|
||||
|
||||
window.addEventListener("pagehide", handlePageHide);
|
||||
window.addEventListener("beforeunload", handlePageHide);
|
||||
|
||||
return () => {
|
||||
scheduleSessionRestorePersistRef.current = () => {};
|
||||
if (timeout !== undefined) {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
unsubscribeActiveTab();
|
||||
window.removeEventListener("pagehide", handlePageHide);
|
||||
window.removeEventListener("beforeunload", handlePageHide);
|
||||
};
|
||||
}, [sessions, workspaces, tabOrder, restorePreviousSessionRevision, persistSessionRestore]);
|
||||
|
||||
const updateSessionRestoreCwd = useCallback((sessionId: string, cwd: string | null) => {
|
||||
const nextCwd = cwd && cwd.trim().length > 0 ? cwd : null;
|
||||
const currentCwd = sessionRestoreCwdByIdRef.current.get(sessionId) ?? null;
|
||||
if (currentCwd === nextCwd) return;
|
||||
if (nextCwd) {
|
||||
sessionRestoreCwdByIdRef.current.set(sessionId, nextCwd);
|
||||
} else {
|
||||
sessionRestoreCwdByIdRef.current.delete(sessionId);
|
||||
}
|
||||
scheduleSessionRestorePersistRef.current();
|
||||
}, []);
|
||||
|
||||
const updateSessionDynamicTitle = useCallback((sessionId: string, title: string | null) => {
|
||||
const normalizedTitle = title ? normalizeCodingCliDynamicTitleForStorage(title) : '';
|
||||
const nextTitle = normalizedTitle.length > 0 ? normalizedTitle : null;
|
||||
setSessions((prev) => {
|
||||
const session = prev.find((candidate) => candidate.id === sessionId);
|
||||
if (!session) return prev;
|
||||
if ((session.dynamicTitle ?? null) === nextTitle) return prev;
|
||||
return prev.map((candidate) => {
|
||||
if (candidate.id !== sessionId) return candidate;
|
||||
if (!nextTitle) {
|
||||
const { dynamicTitle: _removed, ...rest } = candidate;
|
||||
return rest;
|
||||
}
|
||||
return { ...candidate, dynamicTitle: nextTitle };
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateSessionCodingCliProvider = useCallback((
|
||||
sessionId: string,
|
||||
providerId: CodingCliProviderId | null,
|
||||
) => {
|
||||
setSessions((prev) => {
|
||||
const session = prev.find((candidate) => candidate.id === sessionId);
|
||||
if (!session) return prev;
|
||||
if ((session.codingCliProviderId ?? null) === providerId) return prev;
|
||||
return prev.map((candidate) => {
|
||||
if (candidate.id !== sessionId) return candidate;
|
||||
if (!providerId) {
|
||||
const { codingCliProviderId: _removed, ...rest } = candidate;
|
||||
return rest;
|
||||
}
|
||||
return { ...candidate, codingCliProviderId: providerId };
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const createLocalTerminal = useCallback((options?: LocalTerminalOptions) => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
@@ -70,7 +279,7 @@ export const useSessionState = () => {
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const updateSessionStatus = useCallback((sessionId: string, status: TerminalSession['status']) => {
|
||||
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));
|
||||
setSessions(prev => updateRestoredSessionStatusState(prev, sessionId, status));
|
||||
}, []);
|
||||
|
||||
const updateSessionFontSize = useCallback((sessionId: string, fontSize: number) => {
|
||||
@@ -122,33 +331,12 @@ export const useSessionState = () => {
|
||||
const wsId = targetSession?.workspaceId;
|
||||
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
let removedWorkspaceId: string | null = null;
|
||||
let nextWorkspaces = prevWorkspaces;
|
||||
let dissolvedWorkspaceId: string | null = null;
|
||||
let lastRemainingSessionId: string | null = null;
|
||||
|
||||
if (wsId) {
|
||||
nextWorkspaces = prevWorkspaces
|
||||
.map(ws => {
|
||||
if (ws.id !== wsId) return ws;
|
||||
const pruned = pruneWorkspaceNode(ws.root, sessionId);
|
||||
if (!pruned) {
|
||||
removedWorkspaceId = ws.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if only 1 session remains - dissolve workspace
|
||||
const remainingSessionIds = collectSessionIds(pruned);
|
||||
if (remainingSessionIds.length === 1) {
|
||||
dissolvedWorkspaceId = ws.id;
|
||||
lastRemainingSessionId = remainingSessionIds[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
return { ...ws, root: pruned };
|
||||
})
|
||||
.filter((ws): ws is Workspace => Boolean(ws));
|
||||
}
|
||||
const {
|
||||
workspaces: nextWorkspaces,
|
||||
removedWorkspaceId,
|
||||
dissolvedWorkspaceId,
|
||||
lastRemainingSessionId,
|
||||
} = closeSessionWorkspaceLayoutState(prevWorkspaces, wsId, sessionId);
|
||||
|
||||
const remainingSessions = prevSessions.filter(s => s.id !== sessionId);
|
||||
const fallbackWorkspace = nextWorkspaces[nextWorkspaces.length - 1];
|
||||
@@ -162,6 +350,14 @@ export const useSessionState = () => {
|
||||
return 'vault';
|
||||
};
|
||||
|
||||
if (dissolvedWorkspaceId && lastRemainingSessionId) {
|
||||
setTabOrder(prevTabOrder => replaceDissolvedWorkspaceTabOrder(
|
||||
prevTabOrder,
|
||||
dissolvedWorkspaceId,
|
||||
[lastRemainingSessionId],
|
||||
));
|
||||
}
|
||||
|
||||
if (dissolvedWorkspaceId && currentActiveTabId === dissolvedWorkspaceId) {
|
||||
setActiveTabId(getFallback());
|
||||
} else if (currentActiveTabId === sessionId) {
|
||||
@@ -205,20 +401,39 @@ export const useSessionState = () => {
|
||||
const target = prevSessions.find(s => s.id === sessionId);
|
||||
if (target) {
|
||||
setSessionRenameTarget(target);
|
||||
setSessionRenameValue(target.hostLabel);
|
||||
setSessionRenameValue(target.customName || target.hostLabel);
|
||||
}
|
||||
return prevSessions;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const submitSessionRename = useCallback(() => {
|
||||
const renameSessionInline = useCallback((sessionId: string, name: string) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setSessions(prev => prev.map(s => (
|
||||
s.id === sessionId ? { ...s, customName: trimmed, hostLabel: trimmed } : s
|
||||
)));
|
||||
}, []);
|
||||
|
||||
const submitSessionRename = useCallback((sessionId?: string, name?: string) => {
|
||||
if (sessionId !== undefined && name !== undefined) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setSessions(prev => prev.map(s => (
|
||||
s.id === sessionId ? { ...s, customName: trimmed, hostLabel: trimmed } : s
|
||||
)));
|
||||
return;
|
||||
}
|
||||
|
||||
setSessionRenameValue(prevValue => {
|
||||
const name = prevValue.trim();
|
||||
if (!name) return prevValue;
|
||||
const trimmed = prevValue.trim();
|
||||
if (!trimmed) return prevValue;
|
||||
|
||||
setSessionRenameTarget(prevTarget => {
|
||||
if (!prevTarget) return prevTarget;
|
||||
setSessions(prev => prev.map(s => s.id === prevTarget.id ? { ...s, hostLabel: name } : s));
|
||||
setSessions(prev => prev.map(s => (
|
||||
s.id === prevTarget.id ? { ...s, customName: trimmed, hostLabel: trimmed } : s
|
||||
)));
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -888,6 +1103,50 @@ export const useSessionState = () => {
|
||||
[getOrderedWorkTabs],
|
||||
);
|
||||
|
||||
const removeSessionFromWorkspace = useCallback((
|
||||
sessionId: string,
|
||||
tabInsertionTarget?: {
|
||||
tabId: string;
|
||||
position: 'before' | 'after';
|
||||
additionalTabIds?: readonly string[];
|
||||
},
|
||||
) => {
|
||||
setSessions(prevSessions => {
|
||||
const result = detachSessionFromWorkspaceState({
|
||||
sessions: prevSessions,
|
||||
workspaces: workspacesRef.current,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
if (!result.changed) return prevSessions;
|
||||
setWorkspaces(result.workspaces);
|
||||
setTabOrder(prevTabOrder => {
|
||||
const replacedOrder = replaceDissolvedWorkspaceTabOrder(
|
||||
prevTabOrder,
|
||||
result.dissolvedWorkspaceId,
|
||||
result.replacementTabIds,
|
||||
);
|
||||
if (!tabInsertionTarget) return replacedOrder;
|
||||
|
||||
const allTabIds = [
|
||||
...result.sessions.filter(s => !s.workspaceId).map(s => s.id),
|
||||
...result.workspaces.map(w => w.id),
|
||||
...logViews.map(lv => lv.id),
|
||||
...(tabInsertionTarget.additionalTabIds ?? []),
|
||||
];
|
||||
return reorderWorkTabIds(
|
||||
replacedOrder,
|
||||
allTabIds,
|
||||
sessionId,
|
||||
tabInsertionTarget.tabId,
|
||||
tabInsertionTarget.position,
|
||||
);
|
||||
});
|
||||
if (result.activeTabId) setActiveTabId(result.activeTabId);
|
||||
return result.sessions;
|
||||
});
|
||||
}, [logViews, setActiveTabId]);
|
||||
|
||||
const reorderTabs = useCallback((
|
||||
draggedId: string,
|
||||
targetId: string,
|
||||
@@ -896,39 +1155,13 @@ export const useSessionState = () => {
|
||||
) => {
|
||||
if (draggedId === targetId) return;
|
||||
|
||||
setTabOrder(prevTabOrder => {
|
||||
const allTabIds = [...baseWorkTabIds, ...additionalTabIds];
|
||||
const allTabIdSet = new Set(allTabIds);
|
||||
|
||||
// Build current effective order: existing order + new tabs at end
|
||||
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
|
||||
const orderedIdSet = new Set(orderedIds);
|
||||
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
|
||||
const currentOrder = [...orderedIds, ...newIds];
|
||||
|
||||
const draggedIndex = currentOrder.indexOf(draggedId);
|
||||
const targetIndex = currentOrder.indexOf(targetId);
|
||||
|
||||
if (draggedIndex === -1 || targetIndex === -1) return prevTabOrder;
|
||||
|
||||
// Remove dragged item first
|
||||
currentOrder.splice(draggedIndex, 1);
|
||||
|
||||
// Calculate new target index (adjusted after removal)
|
||||
let newTargetIndex = targetIndex;
|
||||
if (draggedIndex < targetIndex) {
|
||||
newTargetIndex -= 1;
|
||||
}
|
||||
|
||||
// Insert at the correct position
|
||||
if (position === 'after') {
|
||||
newTargetIndex += 1;
|
||||
}
|
||||
|
||||
currentOrder.splice(newTargetIndex, 0, draggedId);
|
||||
|
||||
return currentOrder;
|
||||
});
|
||||
setTabOrder(prevTabOrder => reorderWorkTabIds(
|
||||
prevTabOrder,
|
||||
[...baseWorkTabIds, ...additionalTabIds],
|
||||
draggedId,
|
||||
targetId,
|
||||
position,
|
||||
));
|
||||
}, [baseWorkTabIds]);
|
||||
|
||||
return {
|
||||
@@ -942,6 +1175,7 @@ export const useSessionState = () => {
|
||||
sessionRenameValue,
|
||||
setSessionRenameValue,
|
||||
startSessionRename,
|
||||
renameSessionInline,
|
||||
submitSessionRename,
|
||||
resetSessionRename,
|
||||
workspaceRenameTarget,
|
||||
@@ -962,6 +1196,7 @@ export const useSessionState = () => {
|
||||
createWorkspaceFromTargets,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
removeSessionFromWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendLocalTerminalToWorkspace,
|
||||
updateSplitSizes,
|
||||
@@ -985,5 +1220,8 @@ export const useSessionState = () => {
|
||||
// Copy session
|
||||
copySession,
|
||||
createSessionFromCloneSource,
|
||||
updateSessionRestoreCwd,
|
||||
updateSessionDynamicTitle,
|
||||
updateSessionCodingCliProvider,
|
||||
};
|
||||
};
|
||||
@@ -32,10 +32,13 @@ import {
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
STORAGE_KEY_EDITOR_WORD_WRAP,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
|
||||
STORAGE_KEY_RESTORE_TERMINAL_CWD,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
@@ -63,10 +66,11 @@ import { TERMINAL_THEME_AUTO } from '../../domain/terminalAppearance';
|
||||
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
|
||||
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
|
||||
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
|
||||
import { DEFAULT_UI_FONT_ID, withWindowsEmojiFallback } from '../../infrastructure/config/uiFonts';
|
||||
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { resolveSftpTransferConcurrency } from './sftp/transferConcurrency';
|
||||
import {
|
||||
DEFAULT_ACCENT_MODE,
|
||||
DEFAULT_CUSTOM_ACCENT,
|
||||
@@ -92,6 +96,7 @@ import {
|
||||
DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
DEFAULT_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
|
||||
DEFAULT_SSH_DEEP_LINK_ENABLED,
|
||||
DEFAULT_TERMINAL_THEME,
|
||||
DEFAULT_THEME,
|
||||
DEFAULT_WINDOW_OPACITY,
|
||||
@@ -108,13 +113,17 @@ import {
|
||||
readStoredString,
|
||||
serializeTerminalSettings,
|
||||
} from './settingsStateDefaults';
|
||||
import { resolveRestorePreviousSessionSetting, resolveRestoreTerminalCwdSetting } from './sessionRestoreSettings';
|
||||
import { sessionRestoreStorage } from './sessionRestoreStorage';
|
||||
import { useSettingsStorageSync } from './settingsStorageSync';
|
||||
import { useSettingsIpcSync } from './settingsIpcSync';
|
||||
import { resolveCurrentTerminalTheme } from './settingsTerminalTheme';
|
||||
import { useSystemSettingsEffects } from './systemSettingsEffects';
|
||||
import { applyCustomCssToDocument } from '../../lib/customCss';
|
||||
|
||||
export const useSettingsState = () => {
|
||||
export const useSettingsState = (options: { enableSettingsSync?: boolean; enableSystemEffects?: boolean } = {}) => {
|
||||
const enableSettingsSync = options.enableSettingsSync !== false;
|
||||
const enableSystemEffects = options.enableSystemEffects !== false;
|
||||
const initialCustomKeyBindingsRecord =
|
||||
parseCustomKeyBindingsStorageRecord(localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS));
|
||||
const uiFontsLoaded = useUIFontsLoaded();
|
||||
@@ -250,9 +259,18 @@ export const useSettingsState = () => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
|
||||
return stored ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM;
|
||||
});
|
||||
const [restorePreviousSession, setRestorePreviousSessionState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION);
|
||||
return resolveRestorePreviousSessionSetting(stored);
|
||||
});
|
||||
const [restoreTerminalCwd, setRestoreTerminalCwdState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_TERMINAL_CWD);
|
||||
return resolveRestoreTerminalCwdSetting(stored);
|
||||
});
|
||||
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
|
||||
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
|
||||
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
|
||||
return resolveSftpTransferConcurrency(() =>
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
|
||||
);
|
||||
});
|
||||
|
||||
// Editor Settings
|
||||
@@ -282,6 +300,10 @@ export const useSettingsState = () => {
|
||||
const stored = readStoredString(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED);
|
||||
return stored === 'true' ? true : DEFAULT_SSH_DEBUG_LOGS_ENABLED;
|
||||
});
|
||||
const [sshDeepLinkEnabled, setSshDeepLinkEnabledState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SSH_DEEP_LINK_ENABLED);
|
||||
return stored ?? DEFAULT_SSH_DEEP_LINK_ENABLED;
|
||||
});
|
||||
|
||||
// Global Toggle Window Settings (Quake Mode)
|
||||
const [toggleWindowHotkey, setToggleWindowHotkey] = useState<string>(() => {
|
||||
@@ -328,6 +350,9 @@ export const useSettingsState = () => {
|
||||
const customKeyBindingsOriginRef = useRef(initialCustomKeyBindingsRecord?.origin || 'legacy');
|
||||
const customKeyBindingsLocalOriginRef = useRef(createCustomKeyBindingsSyncOrigin());
|
||||
const customKeyBindingsMutationSourceRef = useRef<'local' | 'incoming'>('local');
|
||||
const sshDeepLinkMutationSourceRef = useRef<'local' | 'incoming'>('local');
|
||||
const sshDeepLinkEnabledRef = useRef(sshDeepLinkEnabled);
|
||||
const sshDeepLinkSetRequestIdRef = useRef(0);
|
||||
|
||||
// Fix 1: Mount guard — skip redundant IPC broadcasts & localStorage writes on initial mount.
|
||||
// Set to true by the LAST useEffect declaration; all persist effects see false on first render.
|
||||
@@ -411,14 +436,24 @@ export const useSettingsState = () => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const applyIncomingSshDeepLinkEnabled = useCallback((enabled: boolean) => {
|
||||
sshDeepLinkSetRequestIdRef.current += 1;
|
||||
setSshDeepLinkEnabledState((prev) => {
|
||||
if (prev === enabled) return prev;
|
||||
sshDeepLinkMutationSourceRef.current = 'incoming';
|
||||
return enabled;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Helper to notify other windows about settings changes via IPC
|
||||
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
|
||||
if (!enableSettingsSync) return;
|
||||
try {
|
||||
netcattyBridge.get()?.notifySettingsChanged?.({ key, value });
|
||||
} catch {
|
||||
// ignore - bridge may not be available
|
||||
}
|
||||
}, []);
|
||||
}, [enableSettingsSync]);
|
||||
|
||||
|
||||
const setSftpTransferConcurrency = useCallback((value: number) => {
|
||||
@@ -529,6 +564,8 @@ export const useSettingsState = () => {
|
||||
if (storedSshDebugLogsEnabled === 'true' || storedSshDebugLogsEnabled === 'false') {
|
||||
setSshDebugLogsEnabled(storedSshDebugLogsEnabled === 'true');
|
||||
}
|
||||
const storedSshDeepLinkEnabled = localStorageAdapter.readBoolean(STORAGE_KEY_SSH_DEEP_LINK_ENABLED);
|
||||
applyIncomingSshDeepLinkEnabled(storedSshDeepLinkEnabled ?? DEFAULT_SSH_DEEP_LINK_ENABLED);
|
||||
|
||||
// SFTP
|
||||
const storedDblClick = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
|
||||
@@ -559,6 +596,10 @@ export const useSettingsState = () => {
|
||||
setShellOnlyTabNumberShortcutsState(storedShellOnlyTabNumberShortcuts ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
const storedDisableTerminalFontZoom = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
|
||||
setDisableTerminalFontZoomState(storedDisableTerminalFontZoom ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM);
|
||||
const storedRestorePreviousSession = localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION);
|
||||
setRestorePreviousSessionState(resolveRestorePreviousSessionSetting(storedRestorePreviousSession));
|
||||
const storedRestoreTerminalCwd = localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_TERMINAL_CWD);
|
||||
setRestoreTerminalCwdState(resolveRestoreTerminalCwdSetting(storedRestoreTerminalCwd));
|
||||
|
||||
// Workspace focus style
|
||||
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
@@ -566,7 +607,7 @@ export const useSettingsState = () => {
|
||||
|
||||
// Custom terminal themes
|
||||
customThemeStore.loadFromStorage();
|
||||
}, [applyIncomingCustomKeyBindings, syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
|
||||
}, [applyIncomingCustomKeyBindings, applyIncomingSshDeepLinkEnabled, syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
@@ -613,7 +654,7 @@ export const useSettingsState = () => {
|
||||
// Re-run when fonts finish loading to get correct family for local fonts
|
||||
useLayoutEffect(() => {
|
||||
const font = uiFontStore.getFontById(uiFontFamilyId);
|
||||
document.documentElement.style.setProperty('--font-sans', font.family);
|
||||
document.documentElement.style.setProperty('--font-sans', withWindowsEmojiFallback(font.family));
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
|
||||
// Fix 1: Skip IPC broadcast on initial mount
|
||||
if (persistMountedRef.current) {
|
||||
@@ -622,6 +663,7 @@ export const useSettingsState = () => {
|
||||
}, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]);
|
||||
|
||||
useSettingsIpcSync({
|
||||
enabled: enableSettingsSync,
|
||||
syncAppearanceFromStorage,
|
||||
syncCustomCssFromStorage,
|
||||
setUiLanguage,
|
||||
@@ -639,6 +681,7 @@ export const useSettingsState = () => {
|
||||
setSessionLogsFormat,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled,
|
||||
setHotkeyScheme,
|
||||
applyIncomingCustomKeyBindings,
|
||||
setIsHotkeyRecordingState,
|
||||
@@ -651,10 +694,13 @@ export const useSettingsState = () => {
|
||||
setWorkspaceFocusStyleState,
|
||||
setShowHostTreeSidebarState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
setSftpTransferConcurrencyState,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableSettingsSync) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onLanguageChanged) return;
|
||||
const unsubscribe = bridge.onLanguageChanged((language) => {
|
||||
@@ -669,16 +715,17 @@ export const useSettingsState = () => {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [enableSettingsSync]);
|
||||
|
||||
useSettingsStorageSync({
|
||||
enabled: enableSettingsSync,
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
@@ -686,8 +733,8 @@ export const useSettingsState = () => {
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
});
|
||||
@@ -814,6 +861,23 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setRestorePreviousSession = useCallback((enabled: boolean) => {
|
||||
setRestorePreviousSessionState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION, enabled);
|
||||
if (!enabled) {
|
||||
sessionRestoreStorage.clear();
|
||||
}
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_RESTORE_PREVIOUS_SESSION, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setRestoreTerminalCwd = useCallback((enabled: boolean) => {
|
||||
setRestoreTerminalCwdState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_RESTORE_TERMINAL_CWD, enabled);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_RESTORE_TERMINAL_CWD, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
// Apply and persist custom CSS
|
||||
useEffect(() => {
|
||||
applyCustomCssToDocument(customCSS);
|
||||
@@ -903,7 +967,68 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED, sshDebugLogsEnabled);
|
||||
}, [sshDebugLogsEnabled, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
sshDeepLinkEnabledRef.current = sshDeepLinkEnabled;
|
||||
}, [sshDeepLinkEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const requestIdAtStart = sshDeepLinkSetRequestIdRef.current;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSshDeepLinkEnabled) return;
|
||||
void bridge.getSshDeepLinkEnabled().then((enabled) => {
|
||||
if (cancelled || typeof enabled !== 'boolean') return;
|
||||
if (sshDeepLinkSetRequestIdRef.current !== requestIdAtStart) return;
|
||||
sshDeepLinkMutationSourceRef.current = 'incoming';
|
||||
setSshDeepLinkEnabledState((prev) => (prev === enabled ? prev : enabled));
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SSH_DEEP_LINK_ENABLED, enabled);
|
||||
}).catch(() => {
|
||||
// The renderer can still use its cached setting when the bridge is unavailable.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setSshDeepLinkEnabled = useCallback((enabled: boolean) => {
|
||||
const previous = sshDeepLinkEnabledRef.current;
|
||||
const requestId = sshDeepLinkSetRequestIdRef.current + 1;
|
||||
sshDeepLinkSetRequestIdRef.current = requestId;
|
||||
sshDeepLinkMutationSourceRef.current = 'local';
|
||||
setSshDeepLinkEnabledState(enabled);
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.setSshDeepLinkEnabled) return;
|
||||
void bridge.setSshDeepLinkEnabled(enabled).then((result) => {
|
||||
if (sshDeepLinkSetRequestIdRef.current !== requestId) return;
|
||||
const success = typeof result === 'object' ? result.success : result;
|
||||
if (success !== false) return;
|
||||
const finalEnabled = typeof result === 'object' && typeof result.enabled === 'boolean'
|
||||
? result.enabled
|
||||
: previous;
|
||||
sshDeepLinkMutationSourceRef.current = 'incoming';
|
||||
setSshDeepLinkEnabledState(finalEnabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SSH_DEEP_LINK_ENABLED, finalEnabled);
|
||||
}).catch(() => {
|
||||
if (sshDeepLinkSetRequestIdRef.current !== requestId) return;
|
||||
sshDeepLinkMutationSourceRef.current = 'incoming';
|
||||
setSshDeepLinkEnabledState(previous);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SSH_DEEP_LINK_ENABLED, previous);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SSH_DEEP_LINK_ENABLED, sshDeepLinkEnabled);
|
||||
if (sshDeepLinkMutationSourceRef.current === 'incoming') {
|
||||
sshDeepLinkMutationSourceRef.current = 'local';
|
||||
return;
|
||||
}
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SSH_DEEP_LINK_ENABLED, sshDeepLinkEnabled);
|
||||
}, [sshDeepLinkEnabled, notifySettingsChanged]);
|
||||
|
||||
useSystemSettingsEffects({
|
||||
enabled: enableSystemEffects,
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
@@ -1056,6 +1181,10 @@ export const useSettingsState = () => {
|
||||
setShellOnlyTabNumberShortcuts,
|
||||
disableTerminalFontZoom,
|
||||
setDisableTerminalFontZoom,
|
||||
restorePreviousSession,
|
||||
setRestorePreviousSession,
|
||||
restoreTerminalCwd,
|
||||
setRestoreTerminalCwd,
|
||||
sftpTransferConcurrency,
|
||||
setSftpTransferConcurrency,
|
||||
// Editor Settings
|
||||
@@ -1076,6 +1205,8 @@ export const useSettingsState = () => {
|
||||
setSessionLogsTimestampsEnabled,
|
||||
sshDebugLogsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
sshDeepLinkEnabled,
|
||||
setSshDeepLinkEnabled,
|
||||
// Global Toggle Window (Quake Mode)
|
||||
toggleWindowHotkey,
|
||||
setToggleWindowHotkey,
|
||||
@@ -1101,7 +1232,7 @@ export const useSettingsState = () => {
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
|
||||
]),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
test("ssh deep link native startup sync cannot overwrite a later setting change", () => {
|
||||
const source = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
|
||||
const getEnabledIndex = source.indexOf("bridge.getSshDeepLinkEnabled()");
|
||||
const requestIdCaptureIndex = source.lastIndexOf(
|
||||
"const requestIdAtStart = sshDeepLinkSetRequestIdRef.current",
|
||||
getEnabledIndex,
|
||||
);
|
||||
const staleGuardIndex = source.indexOf(
|
||||
"if (sshDeepLinkSetRequestIdRef.current !== requestIdAtStart) return;",
|
||||
getEnabledIndex,
|
||||
);
|
||||
|
||||
assert.notEqual(getEnabledIndex, -1);
|
||||
assert.notEqual(requestIdCaptureIndex, -1);
|
||||
assert.notEqual(staleGuardIndex, -1);
|
||||
assert.ok(requestIdCaptureIndex < getEnabledIndex);
|
||||
assert.ok(staleGuardIndex > getEnabledIndex);
|
||||
});
|
||||
|
||||
test("incoming ssh deep link setting changes invalidate pending local responses", () => {
|
||||
const source = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
|
||||
const setterIndex = source.indexOf("const applyIncomingSshDeepLinkEnabled = useCallback");
|
||||
const incrementIndex = source.indexOf("sshDeepLinkSetRequestIdRef.current += 1;", setterIndex);
|
||||
const stateUpdateIndex = source.indexOf("setSshDeepLinkEnabledState((prev)", setterIndex);
|
||||
|
||||
assert.notEqual(setterIndex, -1);
|
||||
assert.notEqual(incrementIndex, -1);
|
||||
assert.notEqual(stateUpdateIndex, -1);
|
||||
assert.ok(incrementIndex < stateUpdateIndex);
|
||||
});
|
||||
@@ -14,6 +14,7 @@ export const useStoredNumber = (
|
||||
clamp?: { min: number; max: number },
|
||||
) => {
|
||||
const [value, setValue] = useState<number>(() => {
|
||||
if (typeof globalThis.localStorage === "undefined") return fallback;
|
||||
const stored = localStorageAdapter.readNumber(storageKey);
|
||||
if (stored === null) return fallback;
|
||||
if (clamp) return Math.max(clamp.min, Math.min(clamp.max, stored));
|
||||
@@ -21,7 +22,10 @@ export const useStoredNumber = (
|
||||
});
|
||||
|
||||
const persist = useCallback(
|
||||
(v: number) => localStorageAdapter.writeNumber(storageKey, v),
|
||||
(v: number) => {
|
||||
if (typeof globalThis.localStorage === "undefined") return false;
|
||||
return localStorageAdapter.writeNumber(storageKey, v);
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
createStoredStringSyncHandlers,
|
||||
readStoredStringValue,
|
||||
resolveStoredStringUpdate,
|
||||
} from "./useStoredString.ts";
|
||||
|
||||
type TestMode = "edit" | "preview";
|
||||
|
||||
const isTestMode = (value: string | null): value is TestMode =>
|
||||
value === "edit" || value === "preview";
|
||||
|
||||
function installLocalStorage() {
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
const backing = new Map<string, string>();
|
||||
const storage: Storage = {
|
||||
get length() {
|
||||
return backing.size;
|
||||
},
|
||||
clear() {
|
||||
backing.clear();
|
||||
},
|
||||
getItem(key: string) {
|
||||
return backing.get(key) ?? null;
|
||||
},
|
||||
key(index: number) {
|
||||
return Array.from(backing.keys())[index] ?? null;
|
||||
},
|
||||
removeItem(key: string) {
|
||||
backing.delete(key);
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
backing.set(key, value);
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: storage,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
return {
|
||||
storage,
|
||||
restore() {
|
||||
if (previousLocalStorage) {
|
||||
Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
|
||||
return;
|
||||
}
|
||||
Reflect.deleteProperty(globalThis, "localStorage");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("stored string sync handlers refresh from same-window and browser storage events", (t) => {
|
||||
const env = installLocalStorage();
|
||||
t.after(() => env.restore());
|
||||
|
||||
const storageKey = "netcatty:test-mode";
|
||||
const syncedValues: TestMode[] = [];
|
||||
const handlers = createStoredStringSyncHandlers<TestMode>({
|
||||
storageKey,
|
||||
fallback: "edit",
|
||||
isAllowedValue: isTestMode,
|
||||
onValue: (value) => syncedValues.push(value),
|
||||
});
|
||||
|
||||
env.storage.setItem(storageKey, "preview");
|
||||
handlers.handleAdapterChange({ detail: { key: storageKey } } as CustomEvent<{ key: string }>);
|
||||
assert.deepEqual(syncedValues, ["preview"]);
|
||||
|
||||
env.storage.setItem(storageKey, "invalid");
|
||||
handlers.handleBrowserStorage({ type: "storage", key: storageKey } as StorageEvent);
|
||||
assert.deepEqual(syncedValues, ["preview", "edit"]);
|
||||
|
||||
env.storage.setItem(storageKey, "preview");
|
||||
handlers.handleAdapterChange({ detail: { key: "other-key" } } as CustomEvent<{ key: string }>);
|
||||
assert.deepEqual(syncedValues, ["preview", "edit"]);
|
||||
});
|
||||
|
||||
test("stored string helpers read fallback and resolve updater-style toggles", (t) => {
|
||||
const env = installLocalStorage();
|
||||
t.after(() => env.restore());
|
||||
|
||||
const storageKey = "netcatty:test-mode";
|
||||
assert.equal(readStoredStringValue(storageKey, "edit", isTestMode), "edit");
|
||||
|
||||
env.storage.setItem(storageKey, "preview");
|
||||
assert.equal(readStoredStringValue(storageKey, "edit", isTestMode), "preview");
|
||||
|
||||
env.storage.setItem(storageKey, "invalid");
|
||||
assert.equal(readStoredStringValue(storageKey, "edit", isTestMode), "edit");
|
||||
|
||||
assert.equal(resolveStoredStringUpdate<TestMode>("edit", "preview"), "preview");
|
||||
assert.equal(
|
||||
resolveStoredStringUpdate<TestMode>("preview", (currentValue) => (
|
||||
currentValue === "edit" ? "preview" : "edit"
|
||||
)),
|
||||
"edit",
|
||||
);
|
||||
});
|
||||
@@ -1,28 +1,105 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
|
||||
localStorageAdapter,
|
||||
} from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
/**
|
||||
* Hook for persisting a string value to localStorage.
|
||||
* @param storageKey - The key to use for localStorage
|
||||
* @param fallback - The default value if no stored value exists
|
||||
* @param validate - Optional function to validate stored value; returns fallback if invalid
|
||||
* @returns A tuple of [value, setValue] similar to useState
|
||||
*/
|
||||
export const useStoredString = <T extends string = string>(
|
||||
storageKey: string,
|
||||
fallback: T,
|
||||
validate?: (value: string) => value is T,
|
||||
type StoredStringSetter<T extends string> = (nextValue: T | ((currentValue: T) => T)) => void;
|
||||
|
||||
const canUseLocalStorage = () => typeof globalThis.localStorage !== "undefined";
|
||||
|
||||
export const readStoredStringValue = <T extends string>(
|
||||
storageKey: string,
|
||||
fallback: T,
|
||||
isAllowedValue: (value: string | null) => value is T,
|
||||
): T => {
|
||||
if (!canUseLocalStorage()) return fallback;
|
||||
const stored = localStorageAdapter.readString(storageKey);
|
||||
return isAllowedValue(stored) ? stored : fallback;
|
||||
};
|
||||
|
||||
export const resolveStoredStringUpdate = <T extends string>(
|
||||
currentValue: T,
|
||||
nextValue: T | ((currentValue: T) => T),
|
||||
): T => (typeof nextValue === "function" ? nextValue(currentValue) : nextValue);
|
||||
|
||||
export const shouldSyncStoredStringEvent = (storageKey: string, event: Event): boolean => {
|
||||
const changedKey = event.type === "storage"
|
||||
? (event as StorageEvent).key
|
||||
: (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
return changedKey === storageKey;
|
||||
};
|
||||
|
||||
export const createStoredStringSyncHandlers = <T extends string>({
|
||||
storageKey,
|
||||
fallback,
|
||||
isAllowedValue,
|
||||
onValue,
|
||||
}: {
|
||||
storageKey: string;
|
||||
fallback: T;
|
||||
isAllowedValue: (value: string | null) => value is T;
|
||||
onValue: (value: T) => void;
|
||||
}) => {
|
||||
const syncFromStorage = () => {
|
||||
onValue(readStoredStringValue(storageKey, fallback, isAllowedValue));
|
||||
};
|
||||
|
||||
return {
|
||||
handleAdapterChange(event: Event) {
|
||||
if (shouldSyncStoredStringEvent(storageKey, event)) syncFromStorage();
|
||||
},
|
||||
handleBrowserStorage(event: Event) {
|
||||
if (shouldSyncStoredStringEvent(storageKey, event)) syncFromStorage();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const useStoredString = <T extends string>(
|
||||
storageKey: string,
|
||||
fallback: T,
|
||||
isAllowedValue: (value: string | null) => value is T,
|
||||
) => {
|
||||
const [value, setValue] = useState<T>(() => {
|
||||
const stored = localStorageAdapter.readString(storageKey);
|
||||
if (stored === null) return fallback;
|
||||
if (validate) return validate(stored) ? stored : fallback;
|
||||
return stored as T;
|
||||
const [value, setValue] = useState<T>(() => readStoredStringValue(
|
||||
storageKey,
|
||||
fallback,
|
||||
isAllowedValue,
|
||||
));
|
||||
|
||||
const setAndPersist = useCallback<StoredStringSetter<T>>((nextValue) => {
|
||||
setValue((currentValue) => {
|
||||
const resolvedValue = resolveStoredStringUpdate(currentValue, nextValue);
|
||||
if (canUseLocalStorage()) {
|
||||
localStorageAdapter.writeString(storageKey, resolvedValue);
|
||||
}
|
||||
return resolvedValue;
|
||||
});
|
||||
}, [storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const target = globalThis as typeof globalThis & {
|
||||
addEventListener?: (type: string, listener: EventListener) => void;
|
||||
removeEventListener?: (type: string, listener: EventListener) => void;
|
||||
};
|
||||
if (typeof target.addEventListener !== "function") return;
|
||||
|
||||
const {
|
||||
handleAdapterChange,
|
||||
handleBrowserStorage,
|
||||
} = createStoredStringSyncHandlers({
|
||||
storageKey,
|
||||
fallback,
|
||||
isAllowedValue,
|
||||
onValue: setValue,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(storageKey, value);
|
||||
}, [storageKey, value]);
|
||||
target.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleAdapterChange);
|
||||
target.addEventListener("storage", handleBrowserStorage);
|
||||
return () => {
|
||||
target.removeEventListener?.(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleAdapterChange);
|
||||
target.removeEventListener?.("storage", handleBrowserStorage);
|
||||
};
|
||||
}, [fallback, isAllowedValue, storageKey]);
|
||||
|
||||
return [value, setValue] as const;
|
||||
return [value, setAndPersist] as const;
|
||||
};
|
||||
@@ -74,7 +74,7 @@ export const useTerminalBackend = () => {
|
||||
return bridge.execCommand(options);
|
||||
}, []);
|
||||
|
||||
const writeToSession = useCallback((sessionId: string, data: string, options?: { automated?: boolean }) => {
|
||||
const writeToSession = useCallback((sessionId: string, data: string, options?: Parameters<NonNullable<NetcattyBridge["writeToSession"]>>[2]) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.writeToSession?.(sessionId, data, options);
|
||||
}, []);
|
||||
|
||||
@@ -53,7 +53,8 @@ export interface UseUpdateCheckResult {
|
||||
* - Respects dismissed version to avoid nagging
|
||||
* - Provides manual check capability
|
||||
*/
|
||||
export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsSave?: () => void }): UseUpdateCheckResult {
|
||||
export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; enabled?: boolean; onNeedsSave?: () => void }): UseUpdateCheckResult {
|
||||
const enabled = options?.enabled !== false;
|
||||
// Accept auto-update toggle from the caller (e.g. useSettingsState) so it
|
||||
// reacts immediately in the same window. Falls back to reading localStorage
|
||||
// when no caller provides the value (e.g. in non-settings contexts).
|
||||
@@ -115,6 +116,7 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsS
|
||||
|
||||
// Get current app version
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const loadVersion = async () => {
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -127,12 +129,13 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsS
|
||||
}
|
||||
};
|
||||
void loadVersion();
|
||||
}, []);
|
||||
}, [enabled]);
|
||||
|
||||
// Hydrate auto-download status from the main process so windows opened
|
||||
// after the download started (e.g. Settings) immediately reflect the
|
||||
// current state instead of showing stale 'idle'.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
void bridge?.getUpdateStatus?.().then((snapshot) => {
|
||||
if (!snapshot || snapshot.status === 'idle') return;
|
||||
@@ -173,11 +176,12 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsS
|
||||
};
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
}, [enabled]);
|
||||
|
||||
// Subscribe to electron-updater auto-download IPC events.
|
||||
// These fire automatically when autoDownload=true in the main process.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
|
||||
// When electron-updater confirms no update in its feed, don't write
|
||||
@@ -276,7 +280,7 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsS
|
||||
cleanupError?.();
|
||||
cleanupNeedsSave?.();
|
||||
};
|
||||
}, []);
|
||||
}, [enabled]);
|
||||
|
||||
const performCheck = useCallback(async (currentVersion: string): Promise<UpdateCheckResult | null> => {
|
||||
debugLog('performCheck called', { currentVersion, IS_UPDATE_DEMO_MODE });
|
||||
@@ -372,6 +376,8 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsS
|
||||
}, []);
|
||||
|
||||
const checkNow = useCallback(async (): Promise<UpdateCheckResult | null> => {
|
||||
if (!enabled) return null;
|
||||
|
||||
// Prevent concurrent checks (performCheck owns isCheckingRef)
|
||||
if (isCheckingRef.current) {
|
||||
debugLog('checkNow: already checking, skipping');
|
||||
@@ -499,7 +505,7 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsS
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [performCheck]);
|
||||
}, [enabled, performCheck]);
|
||||
|
||||
const dismissUpdate = useCallback(() => {
|
||||
if (updateState.latestRelease?.version) {
|
||||
@@ -529,10 +535,12 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsS
|
||||
}, [updateState.latestRelease]);
|
||||
|
||||
const installUpdate = useCallback(() => {
|
||||
if (!enabled) return;
|
||||
netcattyBridge.get()?.installUpdate?.();
|
||||
}, []);
|
||||
}, [enabled]);
|
||||
|
||||
const startDownload = useCallback(async () => {
|
||||
if (!enabled) return;
|
||||
if (autoDownloadStatusRef.current === 'downloading' || autoDownloadStatusRef.current === 'ready') return;
|
||||
const bridge = netcattyBridge.get();
|
||||
try {
|
||||
@@ -570,10 +578,11 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsS
|
||||
downloadError: 'Download failed',
|
||||
}));
|
||||
});
|
||||
}, [openReleasePage]);
|
||||
}, [enabled, openReleasePage]);
|
||||
|
||||
// Startup check with delay - runs once on mount
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
debugLog('Startup check effect mounted, IS_UPDATE_DEMO_MODE:', IS_UPDATE_DEMO_MODE);
|
||||
|
||||
// In demo mode, trigger check immediately after a short delay
|
||||
@@ -594,10 +603,11 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsS
|
||||
|
||||
// Normal mode: wait for version to be loaded, then check
|
||||
// This is handled by the version-dependent effect below
|
||||
}, [performCheck]);
|
||||
}, [enabled, performCheck]);
|
||||
|
||||
// Normal mode startup check - depends on currentVersion
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
// Skip in demo mode (handled above)
|
||||
if (IS_UPDATE_DEMO_MODE) {
|
||||
return;
|
||||
@@ -703,7 +713,7 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsS
|
||||
clearTimeout(startupCheckTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [updateState.currentVersion, autoUpdateEnabled, performCheck]);
|
||||
}, [enabled, updateState.currentVersion, autoUpdateEnabled, performCheck]);
|
||||
|
||||
return {
|
||||
updateState,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { migrateHostsFromLegacyLineTimestamps, normalizeDistroId, sanitizeHost } from "../../domain/host";
|
||||
import { sanitizeGroupConfig } from "../../domain/groupConfig";
|
||||
import { normalizeKnownHosts } from "../../domain/knownHosts";
|
||||
import { normalizeNoteGroups, normalizeVaultNotes } from "../../domain/notes";
|
||||
import {
|
||||
ConnectionLog,
|
||||
GroupConfig,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
ShellHistoryEntry,
|
||||
Snippet,
|
||||
SSHKey,
|
||||
VaultNote,
|
||||
} from "../../domain/models";
|
||||
import {
|
||||
INITIAL_HOSTS,
|
||||
@@ -29,6 +31,8 @@ import {
|
||||
STORAGE_KEY_KNOWN_HOSTS,
|
||||
STORAGE_KEY_LEGACY_KEYS,
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
STORAGE_KEY_NOTE_GROUPS,
|
||||
STORAGE_KEY_NOTES,
|
||||
STORAGE_KEY_PROXY_PROFILES,
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
@@ -60,6 +64,8 @@ type ExportableVaultData = {
|
||||
snippets: Snippet[];
|
||||
customGroups: string[];
|
||||
snippetPackages?: string[];
|
||||
notes?: VaultNote[];
|
||||
noteGroups?: string[];
|
||||
knownHosts?: KnownHost[];
|
||||
groupConfigs?: GroupConfig[];
|
||||
};
|
||||
@@ -151,6 +157,8 @@ export const useVaultState = () => {
|
||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||
const [customGroups, setCustomGroups] = useState<string[]>([]);
|
||||
const [snippetPackages, setSnippetPackages] = useState<string[]>([]);
|
||||
const [notes, setNotes] = useState<VaultNote[]>([]);
|
||||
const [noteGroups, setNoteGroups] = useState<string[]>([]);
|
||||
const [knownHosts, setKnownHosts] = useState<KnownHost[]>([]);
|
||||
const [shellHistory, setShellHistory] = useState<ShellHistoryEntry[]>([]);
|
||||
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
|
||||
@@ -264,6 +272,18 @@ export const useVaultState = () => {
|
||||
localStorageAdapter.write(STORAGE_KEY_SNIPPET_PACKAGES, data);
|
||||
}, []);
|
||||
|
||||
const updateNotes = useCallback((data: Partial<VaultNote>[]) => {
|
||||
const cleaned = normalizeVaultNotes(data);
|
||||
setNotes(cleaned);
|
||||
localStorageAdapter.write(STORAGE_KEY_NOTES, cleaned);
|
||||
}, []);
|
||||
|
||||
const updateNoteGroups = useCallback((data: unknown) => {
|
||||
const cleaned = normalizeNoteGroups(data);
|
||||
setNoteGroups(cleaned);
|
||||
localStorageAdapter.write(STORAGE_KEY_NOTE_GROUPS, cleaned);
|
||||
}, []);
|
||||
|
||||
const updateCustomGroups = useCallback((data: string[]) => {
|
||||
setCustomGroups(data);
|
||||
localStorageAdapter.write(STORAGE_KEY_GROUPS, data);
|
||||
@@ -329,6 +349,8 @@ export const useVaultState = () => {
|
||||
updateProxyProfiles([]);
|
||||
updateSnippets([]);
|
||||
updateSnippetPackages([]);
|
||||
updateNotes([]);
|
||||
updateNoteGroups([]);
|
||||
updateCustomGroups([]);
|
||||
updateKnownHosts([]);
|
||||
updateManagedSources([]);
|
||||
@@ -341,6 +363,8 @@ export const useVaultState = () => {
|
||||
updateProxyProfiles,
|
||||
updateSnippets,
|
||||
updateSnippetPackages,
|
||||
updateNotes,
|
||||
updateNoteGroups,
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
@@ -569,6 +593,8 @@ export const useVaultState = () => {
|
||||
const savedSnippetPackages = localStorageAdapter.read<string[]>(
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
);
|
||||
const savedNotes = localStorageAdapter.read<VaultNote[]>(STORAGE_KEY_NOTES);
|
||||
const savedNoteGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_NOTE_GROUPS);
|
||||
|
||||
if (savedSnippets) {
|
||||
const orderedSnippets = normalizeVaultOrder(savedSnippets);
|
||||
@@ -579,6 +605,16 @@ export const useVaultState = () => {
|
||||
|
||||
if (savedGroups) setCustomGroups(savedGroups);
|
||||
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
|
||||
if (savedNotes) {
|
||||
const cleanedNotes = normalizeVaultNotes(savedNotes);
|
||||
setNotes(cleanedNotes);
|
||||
localStorageAdapter.write(STORAGE_KEY_NOTES, cleanedNotes);
|
||||
}
|
||||
if (savedNoteGroups) {
|
||||
const cleanedNoteGroups = normalizeNoteGroups(savedNoteGroups);
|
||||
setNoteGroups(cleanedNoteGroups);
|
||||
localStorageAdapter.write(STORAGE_KEY_NOTE_GROUPS, cleanedNoteGroups);
|
||||
}
|
||||
|
||||
// Load known hosts. Records imported from `~/.ssh/known_hosts` and
|
||||
// records saved by older builds may be missing the `fingerprint` /
|
||||
@@ -723,6 +759,18 @@ export const useVaultState = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_NOTES) {
|
||||
const next = safeParse<VaultNote[]>(event.newValue) ?? [];
|
||||
setNotes(normalizeVaultNotes(next));
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_NOTE_GROUPS) {
|
||||
const next = safeParse<string[]>(event.newValue) ?? [];
|
||||
setNoteGroups(normalizeNoteGroups(next));
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_KNOWN_HOSTS) {
|
||||
const next = safeParse<KnownHost[]>(event.newValue) ?? [];
|
||||
setKnownHosts(normalizeVaultOrder(normalizeKnownHosts(next)));
|
||||
@@ -804,10 +852,12 @@ export const useVaultState = () => {
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
notes,
|
||||
noteGroups,
|
||||
knownHosts,
|
||||
groupConfigs,
|
||||
}),
|
||||
[hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
|
||||
[hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, notes, noteGroups, knownHosts, groupConfigs],
|
||||
);
|
||||
|
||||
const importData = useCallback(
|
||||
@@ -820,6 +870,8 @@ export const useVaultState = () => {
|
||||
if (payload.snippets) updateSnippets(payload.snippets);
|
||||
if (payload.customGroups) updateCustomGroups(payload.customGroups);
|
||||
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
|
||||
if (payload.notes) updateNotes(payload.notes);
|
||||
if (payload.noteGroups) updateNoteGroups(payload.noteGroups);
|
||||
if (payload.knownHosts) updateKnownHosts(payload.knownHosts);
|
||||
if (Array.isArray(payload.groupConfigs)) encryptedWrites.push(updateGroupConfigs(payload.groupConfigs));
|
||||
return Promise.all(encryptedWrites).then(() => undefined);
|
||||
@@ -832,6 +884,8 @@ export const useVaultState = () => {
|
||||
updateSnippets,
|
||||
updateCustomGroups,
|
||||
updateSnippetPackages,
|
||||
updateNotes,
|
||||
updateNoteGroups,
|
||||
updateKnownHosts,
|
||||
updateGroupConfigs,
|
||||
],
|
||||
@@ -854,6 +908,8 @@ export const useVaultState = () => {
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
notes,
|
||||
noteGroups,
|
||||
knownHosts,
|
||||
shellHistory,
|
||||
connectionLogs,
|
||||
@@ -866,6 +922,8 @@ export const useVaultState = () => {
|
||||
updateProxyProfiles,
|
||||
updateSnippets,
|
||||
updateSnippetPackages,
|
||||
updateNotes,
|
||||
updateNoteGroups,
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
|
||||
@@ -40,6 +40,7 @@ const {
|
||||
applyLocalVaultPayload,
|
||||
applySyncPayload,
|
||||
buildLocalVaultPayload,
|
||||
buildCloudSyncPayload,
|
||||
buildSyncPayload,
|
||||
hasCloudSyncEntityData,
|
||||
hasMeaningfulCloudSyncData,
|
||||
@@ -64,12 +65,20 @@ const vault = (knownHosts: KnownHost[] = [knownHost()]): SyncableVaultData => ({
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
snippetPackages: [],
|
||||
notes: [],
|
||||
noteGroups: [],
|
||||
knownHosts,
|
||||
groupConfigs: [],
|
||||
});
|
||||
|
||||
test.beforeEach(() => {
|
||||
localStorage.clear();
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
dispatchEvent: () => true,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSyncPayload treats known hosts as local-only data", () => {
|
||||
@@ -97,6 +106,24 @@ test("buildSyncPayload includes reusable proxy profiles", () => {
|
||||
assert.deepEqual(payload.proxyProfiles, proxyProfiles);
|
||||
});
|
||||
|
||||
test("buildCloudSyncPayload includes notes and note groups", async () => {
|
||||
const payload = await buildCloudSyncPayload({
|
||||
...vault([]),
|
||||
notes: [{
|
||||
id: "note-1",
|
||||
title: "Runbook",
|
||||
content: "# Runbook",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
}],
|
||||
noteGroups: ["Ops"],
|
||||
});
|
||||
|
||||
assert.equal(payload.notes?.length, 1);
|
||||
assert.equal(payload.notes?.[0]?.title, "Runbook");
|
||||
assert.deepEqual(payload.noteGroups, ["Ops"]);
|
||||
});
|
||||
|
||||
test("buildSyncPayload includes AI configuration settings", () => {
|
||||
const providers = [{
|
||||
id: "openai-main",
|
||||
@@ -192,6 +219,63 @@ test("buildSyncPayload omits device-bound encrypted AI API keys", () => {
|
||||
assert.equal("apiKey" in (payload.settings?.ai?.webSearchConfig ?? {}), false);
|
||||
});
|
||||
|
||||
test("buildCloudSyncPayload includes decrypted AI API keys for portable cloud sync", async () => {
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
netcatty: {
|
||||
credentialsDecrypt: async (value: string) => {
|
||||
if (value === "enc:v1:djEwPROVIDER") return "sk-provider";
|
||||
if (value === "enc:v1:djEwWEB") return "sk-web";
|
||||
return value;
|
||||
},
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI",
|
||||
apiKey: "enc:v1:djEwPROVIDER",
|
||||
enabled: true,
|
||||
}]));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwWEB",
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
const payload = await buildCloudSyncPayload(vault([]));
|
||||
|
||||
assert.equal(payload.settings?.ai?.providers?.[0]?.apiKey, "sk-provider");
|
||||
assert.equal(payload.settings?.ai?.webSearchConfig?.apiKey, "sk-web");
|
||||
});
|
||||
|
||||
test("buildCloudSyncPayload fails instead of deleting API keys when decrypt fails", async () => {
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
netcatty: {
|
||||
credentialsDecrypt: async (value: string) => value,
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI",
|
||||
apiKey: "enc:v1:djEwPROVIDER",
|
||||
enabled: true,
|
||||
}]));
|
||||
|
||||
await assert.rejects(
|
||||
() => buildCloudSyncPayload(vault([])),
|
||||
/Unable to decrypt AI API key/,
|
||||
);
|
||||
});
|
||||
|
||||
test("applySyncPayload restores AI configuration settings", async () => {
|
||||
const providers = [{
|
||||
id: "anthropic-main",
|
||||
@@ -249,6 +333,42 @@ test("applySyncPayload restores AI configuration settings", async () => {
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION), "false");
|
||||
});
|
||||
|
||||
test("applySyncPayload encrypts synced plaintext AI API keys before saving locally", async () => {
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
netcatty: {
|
||||
credentialsEncrypt: async (value: string) => `enc:v1:djEwLOCAL_${value}`,
|
||||
},
|
||||
dispatchEvent: () => true,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
settings: {
|
||||
ai: {
|
||||
providers: [
|
||||
{ id: "openai-main", providerId: "openai", name: "OpenAI", apiKey: "sk-provider", enabled: true },
|
||||
],
|
||||
webSearchConfig: { providerId: "tavily", apiKey: "sk-web", enabled: true },
|
||||
},
|
||||
},
|
||||
syncedAt: 1,
|
||||
} as SyncPayload;
|
||||
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
|
||||
const provider = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!)[0];
|
||||
const webSearch = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!);
|
||||
assert.equal(provider.apiKey, "enc:v1:djEwLOCAL_sk-provider");
|
||||
assert.equal(webSearch.apiKey, "enc:v1:djEwLOCAL_sk-web");
|
||||
});
|
||||
|
||||
test("applySyncPayload restores host tree sidebar visibility setting", async () => {
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
@@ -543,6 +663,7 @@ test("buildSyncPayload includes syncable terminal options from settings", () =>
|
||||
terminalEmulationType: "vt100",
|
||||
altAsMeta: true,
|
||||
middleClickBehavior: "context-menu",
|
||||
fontSmoothing: false,
|
||||
showServerStats: false,
|
||||
serverStatsRefreshInterval: 12,
|
||||
rendererType: "dom",
|
||||
@@ -556,6 +677,7 @@ test("buildSyncPayload includes syncable terminal options from settings", () =>
|
||||
terminalEmulationType: "vt100",
|
||||
altAsMeta: true,
|
||||
middleClickBehavior: "context-menu",
|
||||
fontSmoothing: false,
|
||||
showServerStats: false,
|
||||
serverStatsRefreshInterval: 12,
|
||||
rendererType: "dom",
|
||||
@@ -615,6 +737,26 @@ test("buildLocalVaultPayload preserves known hosts for local backups", () => {
|
||||
assert.deepEqual(payload.knownHosts, [knownHost("kh-local")]);
|
||||
});
|
||||
|
||||
test("buildLocalVaultPayload preserves local AI API keys for protective backups", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI",
|
||||
apiKey: "enc:v1:djEwPROVIDER",
|
||||
enabled: true,
|
||||
}]));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwWEB",
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
const payload = buildLocalVaultPayload(vault([]));
|
||||
|
||||
assert.equal(payload.settings?.ai?.providers?.[0]?.apiKey, "enc:v1:djEwPROVIDER");
|
||||
assert.equal(payload.settings?.ai?.webSearchConfig?.apiKey, "enc:v1:djEwWEB");
|
||||
});
|
||||
|
||||
test("applySyncPayload ignores legacy cloud known hosts", async () => {
|
||||
let imported: Record<string, unknown> | null = null;
|
||||
const proxyProfiles = [
|
||||
|
||||
+115
-7
@@ -17,6 +17,7 @@ import type {
|
||||
SftpBookmark,
|
||||
Snippet,
|
||||
SSHKey,
|
||||
VaultNote,
|
||||
} from '../domain/models';
|
||||
import {
|
||||
CLOUD_SYNC_PAYLOAD_ENTITY_KEYS,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
} from '../domain/customKeyBindings';
|
||||
import { isEncryptedCredentialPlaceholder } from '../domain/credentials';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import { decryptField, encryptField } from '../infrastructure/persistence/secureFieldAdapter';
|
||||
import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
|
||||
import { emitAIStateChanged } from './state/aiStateEvents';
|
||||
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
|
||||
@@ -102,6 +104,8 @@ export interface SyncableVaultData {
|
||||
snippets: Snippet[];
|
||||
customGroups: string[];
|
||||
snippetPackages?: string[];
|
||||
notes?: VaultNote[];
|
||||
noteGroups?: string[];
|
||||
/** Local trust records. Kept in local backups, excluded from cloud sync. */
|
||||
knownHosts: KnownHost[];
|
||||
groupConfigs?: GroupConfig[];
|
||||
@@ -191,7 +195,7 @@ interface SyncPayloadImporters {
|
||||
const SYNCABLE_TERMINAL_KEYS = [
|
||||
'startupCommandDelayMs',
|
||||
'scrollback', 'drawBoldInBrightColors', 'terminalEmulationType',
|
||||
'fontLigatures', 'fontWeight', 'fontWeightBold', 'fallbackFont',
|
||||
'fontLigatures', 'fontSmoothing', 'fontWeight', 'fontWeightBold', 'fallbackFont',
|
||||
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
|
||||
'altAsMeta', 'optionArrowWordJump', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'smoothScrolling',
|
||||
@@ -276,6 +280,32 @@ const stripDeviceBoundApiKey = <T extends Record<string, unknown>>(value: T): T
|
||||
return next;
|
||||
};
|
||||
|
||||
const getApiKeyLabel = (value: Record<string, unknown>): string => {
|
||||
if (typeof value.name === 'string' && value.name.trim()) return value.name;
|
||||
if (typeof value.id === 'string' && value.id.trim()) return value.id;
|
||||
if (typeof value.providerId === 'string' && value.providerId.trim()) return value.providerId;
|
||||
return 'configured provider';
|
||||
};
|
||||
|
||||
const withPortableApiKey = async <T extends Record<string, unknown>>(value: T): Promise<T> => {
|
||||
const apiKey = value.apiKey;
|
||||
if (typeof apiKey !== 'string' || !isEncryptedCredentialPlaceholder(apiKey)) return value;
|
||||
|
||||
const decrypted = await decryptField(apiKey).catch(() => undefined);
|
||||
if (!decrypted || decrypted === apiKey || isEncryptedCredentialPlaceholder(decrypted)) {
|
||||
throw new Error(`Unable to decrypt AI API key for ${getApiKeyLabel(value)}. Sync was stopped to avoid removing the key from cloud sync.`);
|
||||
}
|
||||
return { ...value, apiKey: decrypted };
|
||||
};
|
||||
|
||||
const withLocalEncryptedApiKey = async <T extends Record<string, unknown>>(value: T): Promise<T> => {
|
||||
const apiKey = value.apiKey;
|
||||
if (typeof apiKey !== 'string' || isEncryptedCredentialPlaceholder(apiKey)) return value;
|
||||
|
||||
const encrypted = await encryptField(apiKey).catch(() => undefined);
|
||||
return { ...value, apiKey: encrypted ?? apiKey };
|
||||
};
|
||||
|
||||
/**
|
||||
* `collectSyncableSettings` strips device-bound encrypted apiKeys before upload,
|
||||
* so an incoming providers array typically has no apiKey for providers that
|
||||
@@ -461,7 +491,7 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
const webSearchConfig = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
if (webSearchConfig) ai.webSearchConfig = stripDeviceBoundApiKey(webSearchConfig);
|
||||
const quickMessages = readArraySetting(STORAGE_KEY_AI_QUICK_MESSAGES);
|
||||
if (quickMessages) ai.quickMessages = sanitizeQuickMessages(quickMessages);
|
||||
if (quickMessages) ai.quickMessages = sanitizeQuickMessages(quickMessages) as unknown as Array<Record<string, unknown>>;
|
||||
const showTerminalSelectionAction = localStorageAdapter.readBoolean(STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION);
|
||||
if (showTerminalSelectionAction != null) {
|
||||
ai.showTerminalSelectionAction = showTerminalSelectionAction;
|
||||
@@ -471,11 +501,57 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
return Object.keys(settings).length > 0 ? settings : undefined;
|
||||
}
|
||||
|
||||
export async function collectCloudSyncableSettings(): Promise<SyncPayload['settings']> {
|
||||
const settings = collectSyncableSettings();
|
||||
|
||||
const providers = readArraySetting(STORAGE_KEY_AI_PROVIDERS);
|
||||
const webSearchConfig = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
if (!providers && !webSearchConfig) return settings;
|
||||
|
||||
const nextSettings: SyncPayload['settings'] = settings ? { ...settings } : {};
|
||||
const ai: NonNullable<SyncPayload['settings']>['ai'] = {
|
||||
...(settings?.ai ?? {}),
|
||||
};
|
||||
|
||||
if (providers) {
|
||||
ai.providers = await Promise.all(providers.map(withPortableApiKey));
|
||||
}
|
||||
if (webSearchConfig) {
|
||||
ai.webSearchConfig = await withPortableApiKey(webSearchConfig);
|
||||
}
|
||||
|
||||
nextSettings.ai = ai;
|
||||
return Object.keys(nextSettings).length > 0 ? nextSettings : undefined;
|
||||
}
|
||||
|
||||
function collectLocalBackupSettings(): SyncPayload['settings'] {
|
||||
const settings = collectSyncableSettings();
|
||||
|
||||
const providers = readArraySetting(STORAGE_KEY_AI_PROVIDERS);
|
||||
const webSearchConfig = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
if (!providers && !webSearchConfig) return settings;
|
||||
|
||||
const nextSettings: SyncPayload['settings'] = settings ? { ...settings } : {};
|
||||
const ai: NonNullable<SyncPayload['settings']>['ai'] = {
|
||||
...(settings?.ai ?? {}),
|
||||
};
|
||||
|
||||
if (providers) {
|
||||
ai.providers = providers;
|
||||
}
|
||||
if (webSearchConfig) {
|
||||
ai.webSearchConfig = webSearchConfig;
|
||||
}
|
||||
|
||||
nextSettings.ai = ai;
|
||||
return Object.keys(nextSettings).length > 0 ? nextSettings : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply synced settings to localStorage. Merges terminal settings
|
||||
* to preserve platform-specific fields.
|
||||
*/
|
||||
function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): void {
|
||||
async function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): Promise<void> {
|
||||
// Theme & Appearance
|
||||
if (settings.theme != null) localStorageAdapter.writeString(STORAGE_KEY_THEME, settings.theme);
|
||||
if (settings.lightUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, settings.lightUiThemeId);
|
||||
@@ -591,9 +667,10 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
const ai = settings.ai;
|
||||
if (ai) {
|
||||
if (ai.providers != null) {
|
||||
const providers = await Promise.all(ai.providers.map(withLocalEncryptedApiKey));
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
mergeAiProvidersPreservingLocalApiKeys(ai.providers),
|
||||
mergeAiProvidersPreservingLocalApiKeys(providers),
|
||||
);
|
||||
}
|
||||
if (ai.activeProviderId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, ai.activeProviderId);
|
||||
@@ -613,9 +690,10 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
if (ai.webSearchConfig === null) {
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
} else {
|
||||
const webSearchConfig = await withLocalEncryptedApiKey(ai.webSearchConfig);
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
mergeWebSearchConfigPreservingLocalApiKey(ai.webSearchConfig),
|
||||
mergeWebSearchConfigPreservingLocalApiKey(webSearchConfig),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -735,6 +813,8 @@ export function buildSyncPayload(
|
||||
snippets: vault.snippets,
|
||||
customGroups: vault.customGroups,
|
||||
snippetPackages: vault.snippetPackages,
|
||||
notes: vault.notes,
|
||||
noteGroups: vault.noteGroups,
|
||||
groupConfigs: vault.groupConfigs,
|
||||
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
|
||||
settings: collectSyncableSettings(),
|
||||
@@ -742,6 +822,27 @@ export function buildSyncPayload(
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildCloudSyncPayload(
|
||||
vault: SyncableVaultData,
|
||||
portForwardingRules?: PortForwardingRule[],
|
||||
): Promise<SyncPayload> {
|
||||
return {
|
||||
hosts: vault.hosts,
|
||||
keys: vault.keys,
|
||||
identities: vault.identities,
|
||||
proxyProfiles: vault.proxyProfiles,
|
||||
snippets: vault.snippets,
|
||||
customGroups: vault.customGroups,
|
||||
snippetPackages: vault.snippetPackages,
|
||||
notes: vault.notes,
|
||||
noteGroups: vault.noteGroups,
|
||||
groupConfigs: vault.groupConfigs,
|
||||
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
|
||||
settings: await collectCloudSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a local backup/restore payload, including local-only trust records. */
|
||||
export function buildLocalVaultPayload(
|
||||
vault: SyncableVaultData,
|
||||
@@ -749,6 +850,7 @@ export function buildLocalVaultPayload(
|
||||
): SyncPayload {
|
||||
return {
|
||||
...buildSyncPayload(vault, portForwardingRules),
|
||||
settings: collectLocalBackupSettings(),
|
||||
knownHosts: vault.knownHosts,
|
||||
};
|
||||
}
|
||||
@@ -778,6 +880,12 @@ function applyPayload(
|
||||
if (payload.snippetPackages !== undefined) {
|
||||
vaultImport.snippetPackages = payload.snippetPackages;
|
||||
}
|
||||
if (payload.notes !== undefined) {
|
||||
vaultImport.notes = payload.notes;
|
||||
}
|
||||
if (payload.noteGroups !== undefined) {
|
||||
vaultImport.noteGroups = payload.noteGroups;
|
||||
}
|
||||
if (options.includeLocalOnlyData && payload.knownHosts !== undefined) {
|
||||
vaultImport.knownHosts = payload.knownHosts;
|
||||
}
|
||||
@@ -785,7 +893,7 @@ function applyPayload(
|
||||
vaultImport.groupConfigs = payload.groupConfigs;
|
||||
}
|
||||
|
||||
return Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(() => {
|
||||
return Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(async () => {
|
||||
// Only import port-forwarding rules when the payload explicitly carries
|
||||
// them. Absent field = "payload was created before this feature existed",
|
||||
// so local rules are preserved. Explicitly present [] = "remote has no
|
||||
@@ -796,7 +904,7 @@ function applyPayload(
|
||||
|
||||
// Apply synced settings
|
||||
if (payload.settings) {
|
||||
applySyncableSettings(payload.settings);
|
||||
await applySyncableSettings(payload.settings);
|
||||
// Rehydrate in-memory bookmark snapshot after localStorage was updated
|
||||
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
|
||||
importers.onSettingsApplied?.();
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { I18nProvider } from '../application/i18n/I18nProvider.tsx';
|
||||
import type { AIDraft, AISession } from '../infrastructure/ai/types';
|
||||
import { TooltipProvider } from './ui/tooltip.tsx';
|
||||
import {
|
||||
aiChatSidePanelPropsAreEqual,
|
||||
AIChatSidePanel,
|
||||
hasAIChatSidePanelRetainedContent,
|
||||
shouldKeepAIChatSidePanelMounted,
|
||||
} from './AIChatSidePanel.tsx';
|
||||
@@ -102,6 +107,32 @@ test('visible AI side panel is always mounted even when empty', () => {
|
||||
assert.equal(shouldKeepAIChatSidePanelMounted(baseProps({ isVisible: true })), true);
|
||||
});
|
||||
|
||||
test('visible empty draft renders the input immediately without preparing state', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: 'en' },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(AIChatSidePanel, baseProps({
|
||||
isVisible: true,
|
||||
sessions: [session({ id: 'session-history' })],
|
||||
draftsByScope: {
|
||||
'terminal:terminal-1': draft(),
|
||||
},
|
||||
panelViewByScope: {
|
||||
'terminal:terminal-1': { mode: 'draft' },
|
||||
},
|
||||
})),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(markup, /textarea/);
|
||||
assert.doesNotMatch(markup, /data-section="ai-chat-panel-preparing"/);
|
||||
});
|
||||
|
||||
test('AI side panel re-renders when retained content becomes visible again', () => {
|
||||
const hiddenProps = baseProps({
|
||||
isVisible: false,
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
AgentModelPreset,
|
||||
AISessionScope,
|
||||
DiscoveredAgent,
|
||||
ExternalAgentConfig,
|
||||
} from '../infrastructure/ai/types';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
import { getAgentModelPresets } from '../infrastructure/ai/types';
|
||||
@@ -50,7 +51,13 @@ import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligib
|
||||
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
|
||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
import type { AIChatSidePanelProps } from './AIChatSidePanel.types';
|
||||
import { generateId, modelPresetsContainId, shouldLoadSdkRuntimeModels } from './AIChatSidePanelHelpers';
|
||||
import {
|
||||
generateId,
|
||||
normalizeSdkRuntimeModelPresets,
|
||||
shouldAdoptSdkCurrentModel,
|
||||
shouldLoadSdkRuntimeModels,
|
||||
shouldUseStoredAgentModel,
|
||||
} from './AIChatSidePanelHelpers';
|
||||
import { AIChatPanelContent } from './AIChatPanelContent';
|
||||
import {
|
||||
getAIPanelProfilerProps,
|
||||
@@ -88,6 +95,11 @@ if (typeof window !== 'undefined') {
|
||||
subscribeUserSkillsStatusChanged(invalidateUserSkillsStatusCache);
|
||||
}
|
||||
|
||||
function getManualAgentCommand(config: ExternalAgentConfig | null | undefined): string | undefined {
|
||||
const command = String(config?.command || '').trim();
|
||||
return config?.commandSource === 'manual' && command ? command : undefined;
|
||||
}
|
||||
|
||||
function loadUserSkillsStatus(
|
||||
bridge: ReturnType<typeof getNetcattyBridge>,
|
||||
): Promise<UserSkillsStatusLoadResult> {
|
||||
@@ -166,6 +178,9 @@ export function shouldKeepAIChatSidePanelMounted(props: AIChatSidePanelProps): b
|
||||
function shouldDelayAIChatSidePanelActivation(props: AIChatSidePanelProps): boolean {
|
||||
if (!(props.isVisible ?? true)) return false;
|
||||
const scopeKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
|
||||
if (props.draftsByScope[scopeKey] || props.panelViewByScope[scopeKey]?.mode === 'draft') {
|
||||
return false;
|
||||
}
|
||||
const sessionId = props.activeSessionIdMap[scopeKey] ?? null;
|
||||
if (isAIChatSessionStreaming(sessionId)) return false;
|
||||
return !hasAIChatSidePanelRetainedContent(props);
|
||||
@@ -618,7 +633,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
if (!bridge?.aiCodexGetIntegration) return;
|
||||
let cancelled = false;
|
||||
void Promise.resolve(
|
||||
bridge.aiCodexGetIntegration() as Promise<CodexIntegrationStatus>,
|
||||
bridge.aiCodexGetIntegration({ codexPath: getManualAgentCommand(currentAgentConfig) }) as Promise<CodexIntegrationStatus>,
|
||||
).then((info) => {
|
||||
if (cancelled) return;
|
||||
const hasCustom = info?.state === 'connected_custom_config';
|
||||
@@ -631,7 +646,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [isVisible, isCodexManagedAgent, currentAgentId]);
|
||||
}, [isVisible, isCodexManagedAgent, currentAgentId, currentAgentConfig]);
|
||||
|
||||
const agentModelMapRef = useRef(agentModelMap);
|
||||
agentModelMapRef.current = agentModelMap;
|
||||
@@ -652,23 +667,27 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
undefined,
|
||||
`models_${currentAgentId}`,
|
||||
currentAgentConfig.env,
|
||||
getManualAgentCommand(currentAgentConfig),
|
||||
).then((result) => {
|
||||
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
|
||||
if (result.models.length === 0) {
|
||||
const runtimePresets = normalizeSdkRuntimeModelPresets(result.models, result.currentModelId);
|
||||
const storedModelId = agentModelMapRef.current[currentAgentId];
|
||||
if (runtimePresets.length === 0) {
|
||||
setRuntimeAgentModelPresets((prev) => {
|
||||
if (!(currentAgentId in prev)) return prev;
|
||||
const { [currentAgentId]: _removed, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
if (shouldAdoptSdkCurrentModel(result.currentModelId, storedModelId, runtimePresets)) {
|
||||
setAgentModel(currentAgentId, result.currentModelId!);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const runtimePresets = result.models ?? [];
|
||||
setRuntimeAgentModelPresets((prev) => ({
|
||||
...prev,
|
||||
[currentAgentId]: runtimePresets,
|
||||
}));
|
||||
const storedModelId = agentModelMapRef.current[currentAgentId];
|
||||
if (result.currentModelId && (!storedModelId || !modelPresetsContainId(runtimePresets, storedModelId))) {
|
||||
if (shouldAdoptSdkCurrentModel(result.currentModelId, storedModelId, runtimePresets)) {
|
||||
setAgentModel(currentAgentId, result.currentModelId);
|
||||
}
|
||||
}).catch((err) => {
|
||||
@@ -700,7 +719,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
const selectedAgentModel = useMemo(() => {
|
||||
const stored = agentModelMap[currentAgentId];
|
||||
if (stored && modelPresetsContainId(agentModelPresets, stored)) {
|
||||
if (shouldUseStoredAgentModel(stored, agentModelPresets, currentAgentConfig)) {
|
||||
return stored;
|
||||
}
|
||||
if (agentModelPresets.length > 0) {
|
||||
@@ -711,7 +730,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
return first.id;
|
||||
}
|
||||
return undefined;
|
||||
}, [currentAgentId, agentModelMap, agentModelPresets]);
|
||||
}, [currentAgentConfig, currentAgentId, agentModelMap, agentModelPresets]);
|
||||
|
||||
const inputAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? currentAgentId;
|
||||
const canSendCurrentAgent = useMemo(
|
||||
|
||||
@@ -3,7 +3,10 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
modelPresetsContainId,
|
||||
normalizeSdkRuntimeModelPresets,
|
||||
shouldAdoptSdkCurrentModel,
|
||||
shouldLoadSdkRuntimeModels,
|
||||
shouldUseStoredAgentModel,
|
||||
} from './AIChatSidePanelHelpers';
|
||||
import type { AgentModelPreset, ExternalAgentConfig } from '../infrastructure/ai/types';
|
||||
|
||||
@@ -30,6 +33,55 @@ test('shouldLoadSdkRuntimeModels includes SDK agents with model catalogs', () =>
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('claude')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('copilot')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('codebuddy')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('opencode')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('codex')), false);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(undefined), false);
|
||||
});
|
||||
|
||||
test('shouldAdoptSdkCurrentModel keeps SDK defaults when no runtime list is returned', () => {
|
||||
assert.equal(shouldAdoptSdkCurrentModel('openai/gpt-5.1', undefined, []), true);
|
||||
assert.equal(shouldAdoptSdkCurrentModel('openai/gpt-5.1', 'openai/gpt-5.1', []), true);
|
||||
assert.equal(
|
||||
shouldAdoptSdkCurrentModel('openai/gpt-5.1', 'anthropic/claude-sonnet', [
|
||||
{ id: 'anthropic/claude-sonnet', name: 'Claude' },
|
||||
]),
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldAdoptSdkCurrentModel(null, undefined, []), false);
|
||||
});
|
||||
|
||||
test('normalizeSdkRuntimeModelPresets preserves SDK current model without a catalog', () => {
|
||||
assert.deepEqual(normalizeSdkRuntimeModelPresets([], 'custom/provider-model'), [
|
||||
{ id: 'custom/provider-model', name: 'custom/provider-model' },
|
||||
]);
|
||||
assert.deepEqual(normalizeSdkRuntimeModelPresets([], null), []);
|
||||
assert.deepEqual(
|
||||
normalizeSdkRuntimeModelPresets([{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }], 'custom/provider-model'),
|
||||
[{ id: 'openai/gpt-5.1', name: 'GPT-5.1' }],
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldUseStoredAgentModel trusts SDK defaults when no runtime list is returned', () => {
|
||||
const opencodeAgent: ExternalAgentConfig = {
|
||||
id: 'managed_opencode',
|
||||
name: 'OpenCode',
|
||||
command: 'opencode',
|
||||
enabled: true,
|
||||
sdkBackend: 'opencode',
|
||||
};
|
||||
|
||||
assert.equal(shouldUseStoredAgentModel('openai/gpt-5.1', [], opencodeAgent), true);
|
||||
assert.equal(shouldUseStoredAgentModel('openai/gpt-5.1', [], undefined), false);
|
||||
assert.equal(
|
||||
shouldUseStoredAgentModel('anthropic/claude-sonnet', [
|
||||
{ id: 'anthropic/claude-sonnet', name: 'Claude' },
|
||||
], opencodeAgent),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldUseStoredAgentModel('openai/gpt-5.1', [
|
||||
{ id: 'anthropic/claude-sonnet', name: 'Claude' },
|
||||
], opencodeAgent),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -30,7 +30,40 @@ export function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
|
||||
|
||||
export function shouldLoadSdkRuntimeModels(agent?: ExternalAgentConfig): boolean {
|
||||
const sdkBackend = getExternalAgentSdkBackend(agent);
|
||||
return sdkBackend === 'claude' || sdkBackend === 'copilot' || sdkBackend === 'codebuddy';
|
||||
return sdkBackend === 'claude'
|
||||
|| sdkBackend === 'copilot'
|
||||
|| sdkBackend === 'codebuddy'
|
||||
|| sdkBackend === 'opencode';
|
||||
}
|
||||
|
||||
export function shouldAdoptSdkCurrentModel(
|
||||
currentModelId: string | null | undefined,
|
||||
storedModelId: string | null | undefined,
|
||||
runtimePresets: AgentModelPreset[],
|
||||
): boolean {
|
||||
if (!currentModelId) return false;
|
||||
return !storedModelId
|
||||
|| runtimePresets.length === 0
|
||||
|| !modelPresetsContainId(runtimePresets, storedModelId);
|
||||
}
|
||||
|
||||
export function normalizeSdkRuntimeModelPresets(
|
||||
models: AgentModelPreset[],
|
||||
currentModelId: string | null | undefined,
|
||||
): AgentModelPreset[] {
|
||||
if (models.length > 0) return models;
|
||||
if (!currentModelId) return [];
|
||||
return [{ id: currentModelId, name: currentModelId }];
|
||||
}
|
||||
|
||||
export function shouldUseStoredAgentModel(
|
||||
storedModelId: string | null | undefined,
|
||||
presets: AgentModelPreset[],
|
||||
agent?: ExternalAgentConfig,
|
||||
): boolean {
|
||||
if (!storedModelId) return false;
|
||||
return modelPresetsContainId(presets, storedModelId)
|
||||
|| (presets.length === 0 && shouldLoadSdkRuntimeModels(agent));
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
|
||||
@@ -32,7 +32,7 @@ import { LocalBackupsPanel } from './cloud-sync/CloudSyncLocalBackupsPanel';
|
||||
import { CloudSyncDialogs } from './cloud-sync/CloudSyncDialogs';
|
||||
import { CloudSyncDashboardTabs } from './cloud-sync/CloudSyncDashboardTabs';
|
||||
interface SyncDashboardProps {
|
||||
onBuildPayload: () => SyncPayload;
|
||||
onBuildPayload: () => SyncPayload | Promise<SyncPayload>;
|
||||
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
onApplyLocalPayload?: (payload: SyncPayload) => void | Promise<void>;
|
||||
onClearLocalData?: () => void;
|
||||
@@ -138,7 +138,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
const [historyPreview, setHistoryPreview] = useState<{
|
||||
sha: string;
|
||||
payload: SyncPayload;
|
||||
preview: { hostCount: number; keyCount: number; snippetCount: number; identityCount: number; portForwardingRuleCount: number };
|
||||
preview: { hostCount: number; keyCount: number; snippetCount: number; noteCount: number; identityCount: number; portForwardingRuleCount: number };
|
||||
deviceName?: string;
|
||||
version?: number;
|
||||
} | null>(null);
|
||||
@@ -519,7 +519,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// Sync to provider
|
||||
const handleSync = async (provider: CloudProvider) => {
|
||||
try {
|
||||
const payload = onBuildPayload();
|
||||
const payload = await onBuildPayload();
|
||||
if (!ensureSyncablePayload(payload)) return;
|
||||
const result = await sync.syncToProvider(provider, payload);
|
||||
|
||||
@@ -572,7 +572,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// renderer's in-memory state (no onApplyPayload call), so
|
||||
// the barrier is belt-and-suspenders against the other
|
||||
// window's push, not ours.
|
||||
const localPayload = onBuildPayload();
|
||||
const localPayload = await onBuildPayload();
|
||||
if (!ensureSyncablePayload(localPayload)) return;
|
||||
|
||||
let results: Map<CloudProvider, SyncResult> | null = null;
|
||||
@@ -858,7 +858,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// ============================================================================
|
||||
|
||||
interface CloudSyncSettingsProps {
|
||||
onBuildPayload: () => SyncPayload;
|
||||
onBuildPayload: () => SyncPayload | Promise<SyncPayload>;
|
||||
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
onApplyLocalPayload?: (payload: SyncPayload) => void | Promise<void>;
|
||||
onClearLocalData?: () => void;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import type { ConnectionLog } from "../types.ts";
|
||||
import ConnectionLogsManager from "./ConnectionLogsManager.tsx";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
|
||||
const baseLog: ConnectionLog = {
|
||||
id: "log-1",
|
||||
hostId: "host-1",
|
||||
hostLabel: "Database",
|
||||
hostname: "db.example.com",
|
||||
username: "root",
|
||||
protocol: "ssh",
|
||||
hostOs: "linux",
|
||||
hostDistro: "ubuntu",
|
||||
startTime: 1_700_000_000_000,
|
||||
localUsername: "alice",
|
||||
localHostname: "workstation",
|
||||
saved: false,
|
||||
};
|
||||
|
||||
const renderLogs = (log: ConnectionLog) =>
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<TooltipProvider>
|
||||
<ConnectionLogsManager
|
||||
logs={[log]}
|
||||
hosts={[]}
|
||||
onToggleSaved={() => {}}
|
||||
onDelete={() => {}}
|
||||
onClearUnsaved={() => {}}
|
||||
onOpenLogView={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
test("ConnectionLogsManager renders saved custom host icon snapshots", () => {
|
||||
const markup = renderLogs({
|
||||
...baseLog,
|
||||
hostIconMode: "custom",
|
||||
hostIconId: "database",
|
||||
hostIconColor: "blue",
|
||||
});
|
||||
|
||||
assert.match(markup, /background-color:#2563EB/i);
|
||||
assert.doesNotMatch(markup, /bg-\[#E95420\]/);
|
||||
});
|
||||
|
||||
test("ConnectionLogsManager renders saved distro icon snapshots with custom colors", () => {
|
||||
const markup = renderLogs({
|
||||
...baseLog,
|
||||
hostIconMode: "auto",
|
||||
hostIconColor: "violet",
|
||||
});
|
||||
|
||||
assert.match(markup, /background-color:#7C3AED/i);
|
||||
assert.match(markup, /src="\/distro\/ubuntu.svg"/);
|
||||
assert.doesNotMatch(markup, /bg-\[#E95420\]/);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import React, { memo, useCallback, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { resolveHostIconAppearance } from "../domain/hostIcon";
|
||||
import { cn } from "../lib/utils";
|
||||
import { ConnectionLog, Host } from "../types";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
@@ -67,7 +68,14 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const isLocal = log.protocol === "local" || log.hostname === "localhost";
|
||||
const isSerial = log.protocol === "serial";
|
||||
const hasPersistedHostIcon = !isLocal && !isSerial && !!log.hostDistro;
|
||||
const customHostIcon = resolveHostIconAppearance({
|
||||
iconMode: log.hostIconMode,
|
||||
iconId: log.hostIconId,
|
||||
iconColorMode: log.hostIconColorMode,
|
||||
iconColor: log.hostIconColor,
|
||||
iconColorCustom: log.hostIconColorCustom,
|
||||
});
|
||||
const hasPersistedHostIcon = !isLocal && !isSerial && (!!log.hostDistro || !!customHostIcon);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -101,6 +109,11 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
|
||||
os: log.hostOs ?? "linux",
|
||||
distro: log.hostDistro,
|
||||
distroMode: "auto",
|
||||
iconMode: log.hostIconMode,
|
||||
iconId: log.hostIconId,
|
||||
iconColorMode: log.hostIconColorMode,
|
||||
iconColor: log.hostIconColor,
|
||||
iconColorCustom: log.hostIconColorCustom,
|
||||
}}
|
||||
fallback={(log.hostOs ?? "linux")[0].toUpperCase()}
|
||||
size="log"
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { DistroAvatar } from "./DistroAvatar.tsx";
|
||||
import type { Host } from "../types.ts";
|
||||
|
||||
const baseHost: Pick<Host, "distro" | "manualDistro" | "distroMode" | "os" | "protocol" | "iconMode" | "iconId" | "iconColor"> = {
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
};
|
||||
|
||||
test("DistroAvatar renders custom host icon before distro color", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DistroAvatar
|
||||
host={{ ...baseHost, distro: "ubuntu", iconMode: "custom", iconId: "database", iconColor: "blue" }}
|
||||
fallback="DB"
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /background-color:#2563EB/i);
|
||||
assert.doesNotMatch(markup, /bg-\[#E95420\]/);
|
||||
});
|
||||
|
||||
test("DistroAvatar keeps serial hosts on the USB icon", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DistroAvatar
|
||||
host={{ ...baseHost, protocol: "serial", iconMode: "custom", iconId: "database", iconColor: "blue" }}
|
||||
fallback="S"
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /bg-amber-600/);
|
||||
assert.doesNotMatch(markup, /background-color:#2563EB/i);
|
||||
});
|
||||
|
||||
test("DistroAvatar tree size uses compact host icon corners", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DistroAvatar
|
||||
host={{ ...baseHost, distro: "ubuntu" }}
|
||||
fallback="U"
|
||||
size="tree"
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /h-6 w-6 rounded/);
|
||||
assert.doesNotMatch(markup, /rounded-md/);
|
||||
});
|
||||
|
||||
test("DistroAvatar keeps distro icon and applies custom palette color when icon mode is automatic", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DistroAvatar
|
||||
host={{ ...baseHost, distro: "ubuntu", iconMode: "auto", iconId: "database", iconColor: "blue" }}
|
||||
fallback="U"
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /background-color:#2563EB/i);
|
||||
assert.match(markup, /src="\/distro\/ubuntu.svg"/);
|
||||
assert.doesNotMatch(markup, /bg-\[#E95420\]/);
|
||||
});
|
||||
|
||||
test("DistroAvatar renders H3C logo without monochrome inversion", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DistroAvatar
|
||||
host={{ ...baseHost, distro: "h3c" }}
|
||||
fallback="H"
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /src="\/distro\/h3c.svg"/);
|
||||
assert.match(markup, /bg-white/);
|
||||
assert.doesNotMatch(markup, /invert brightness-0/);
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Server, Usb } from "lucide-react";
|
||||
import React, { memo } from "react";
|
||||
import { getEffectiveHostDistro } from "../domain/host";
|
||||
import { resolveHostIconAppearance, resolveHostIconColorAppearance } from "../domain/hostIcon";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host } from "../types";
|
||||
import { renderHostIconGlyph } from "./hostIconRenderer";
|
||||
|
||||
export const DISTRO_LOGOS: Record<string, string> = {
|
||||
ubuntu: "/distro/ubuntu.svg",
|
||||
@@ -19,6 +21,7 @@ export const DISTRO_LOGOS: Record<string, string> = {
|
||||
kali: "/distro/kali.svg",
|
||||
almalinux: "/distro/almalinux.svg",
|
||||
alinux: "/distro/alinux.svg",
|
||||
openeuler: "/distro/openeuler.svg",
|
||||
// OS-level logos (used by local terminal tab icons)
|
||||
macos: "/distro/macos.svg",
|
||||
windows: "/distro/windows.svg",
|
||||
@@ -28,6 +31,7 @@ export const DISTRO_LOGOS: Record<string, string> = {
|
||||
cisco: "/distro/cisco.svg",
|
||||
juniper: "/distro/juniper.svg",
|
||||
huawei: "/distro/huawei.svg",
|
||||
h3c: "/distro/h3c.svg",
|
||||
hpe: "/distro/hpe.svg",
|
||||
mikrotik: "/distro/mikrotik.svg",
|
||||
fortinet: "/distro/fortinet.svg",
|
||||
@@ -50,6 +54,7 @@ export const DISTRO_COLORS: Record<string, string> = {
|
||||
kali: "bg-[#0F6DB3]",
|
||||
almalinux: "bg-[#173B66]",
|
||||
alinux: "bg-[#FF6A00]",
|
||||
openeuler: "bg-[#002FA7]",
|
||||
// OS-level colors
|
||||
macos: "bg-[#333333]",
|
||||
windows: "bg-[#0078D4]",
|
||||
@@ -58,6 +63,7 @@ export const DISTRO_COLORS: Record<string, string> = {
|
||||
cisco: "bg-[#1BA0D7]",
|
||||
juniper: "bg-[#0A6EB4]",
|
||||
huawei: "bg-[#CF0A2C]",
|
||||
h3c: "bg-white",
|
||||
hpe: "bg-[#01A982]",
|
||||
mikrotik: "bg-[#293239]",
|
||||
fortinet: "bg-[#EE3124]",
|
||||
@@ -69,7 +75,7 @@ export const DISTRO_COLORS: Record<string, string> = {
|
||||
|
||||
type DistroAvatarProps = {
|
||||
host: Pick<Host, "distro" | "manualDistro" | "distroMode" | "os"> &
|
||||
Partial<Pick<Host, "protocol">>;
|
||||
Partial<Pick<Host, "protocol" | "iconMode" | "iconId" | "iconColorMode" | "iconColor" | "iconColorCustom">>;
|
||||
fallback: string;
|
||||
className?: string;
|
||||
/** xs matches top tab bar icons (h-4 rounded rect) */
|
||||
@@ -92,7 +98,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
xs: "h-4 w-4 rounded",
|
||||
sm: "h-5 w-5 rounded",
|
||||
md: "h-8 w-8 rounded",
|
||||
tree: "h-8 w-8 rounded-lg",
|
||||
tree: "h-6 w-6 rounded",
|
||||
log: "h-9 w-9 rounded-xl",
|
||||
lg: "h-11 w-11 rounded-xl",
|
||||
};
|
||||
@@ -100,7 +106,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
xs: "h-2.5 w-2.5",
|
||||
sm: "h-3 w-3",
|
||||
md: "h-4 w-4",
|
||||
tree: "h-4 w-4",
|
||||
tree: "h-3.5 w-3.5",
|
||||
log: "h-5 w-5",
|
||||
lg: "h-5 w-5",
|
||||
};
|
||||
@@ -123,20 +129,38 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const customAppearance = resolveHostIconAppearance(host);
|
||||
const customColor = resolveHostIconColorAppearance(host);
|
||||
if (customAppearance) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded flex items-center justify-center text-white",
|
||||
containerClass,
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundColor: customAppearance.colorHex }}
|
||||
>
|
||||
{renderHostIconGlyph(customAppearance.iconId, iconSize)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (logo && !errored) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded flex items-center justify-center overflow-hidden",
|
||||
containerClass,
|
||||
bg,
|
||||
!customColor && bg,
|
||||
className,
|
||||
)}
|
||||
style={customColor ? { backgroundColor: customColor.colorHex } : undefined}
|
||||
>
|
||||
<img
|
||||
src={logo}
|
||||
alt={distro || host.os}
|
||||
className={cn("object-contain invert brightness-0", iconSize)}
|
||||
className={distro === "h3c" ? "object-contain w-[80%]" : cn("object-contain invert brightness-0", iconSize)}
|
||||
onError={() => setErrored(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -376,6 +376,12 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
enabled={!!form.showLineTimestamps}
|
||||
onToggle={() => update("showLineTimestamps", !form.showLineTimestamps)}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.disableDynamicTabTitle")}
|
||||
hint={t("hostDetails.disableDynamicTabTitle.desc")}
|
||||
enabled={!!form.disableDynamicTabTitle}
|
||||
onToggle={() => update("disableDynamicTabTitle", !form.disableDynamicTabTitle)}
|
||||
/>
|
||||
<HostDetailsSettingRow label={t("hostDetails.backspaceBehavior")}>
|
||||
<Select
|
||||
value={form.backspaceBehavior ?? "default"}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import type { SSHKey } from "../types.ts";
|
||||
import { HostDetailsConnectionSections } from "./HostDetailsConnectionSections.tsx";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
|
||||
const longCredentialLabel =
|
||||
"D:\\download\\acdrOgxses_wuzh02.hpccube.com_really_really_long_private_key_name.pem";
|
||||
|
||||
const availableKey: SSHKey = {
|
||||
id: "key-1",
|
||||
label: longCredentialLabel,
|
||||
type: "ED25519",
|
||||
privateKey: "",
|
||||
publicKey: "",
|
||||
source: "imported",
|
||||
category: "key",
|
||||
created: 1,
|
||||
};
|
||||
|
||||
const renderConnectionSections = (formOverrides: Record<string, unknown> = {}) =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(HostDetailsConnectionSections, {
|
||||
t: (key: string) => key,
|
||||
form: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
os: "linux",
|
||||
authMethod: "key",
|
||||
identityFileId: availableKey.id,
|
||||
...formOverrides,
|
||||
},
|
||||
update: () => {},
|
||||
groupDefaults: undefined,
|
||||
selectedIdentity: undefined,
|
||||
clearIdentity: () => {},
|
||||
identities: [],
|
||||
identitySuggestionsOpen: false,
|
||||
filteredIdentitySuggestions: [],
|
||||
setIdentitySuggestionsOpen: () => {},
|
||||
availableKeys: [availableKey],
|
||||
applyIdentity: () => {},
|
||||
showPassword: false,
|
||||
setShowPassword: () => {},
|
||||
pendingReferenceKeyPath: null,
|
||||
setPendingReferenceKeyPath: () => {},
|
||||
selectedCredentialType: null,
|
||||
setSelectedCredentialType: () => {},
|
||||
credentialPopoverOpen: false,
|
||||
setCredentialPopoverOpen: () => {},
|
||||
keysByCategory: { key: [availableKey], certificate: [] },
|
||||
newKeyFilePath: "",
|
||||
setNewKeyFilePath: () => {},
|
||||
addLocalKeyFilePath: () => {},
|
||||
handleDistroModeChange: () => {},
|
||||
distroOptions: [],
|
||||
effectiveFormDistro: undefined,
|
||||
getDistroOptionLabel: () => "",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
test("selected host credential keeps the remove button visible with a long name", () => {
|
||||
const markup = renderConnectionSections();
|
||||
|
||||
assert.match(markup, new RegExp(longCredentialLabel.replaceAll("\\", "\\\\")));
|
||||
assert.match(
|
||||
markup,
|
||||
/class="flex items-center gap-2 min-w-0 overflow-hidden p-2 rounded-md bg-secondary\/50 border border-border\/60"/,
|
||||
);
|
||||
assert.match(markup, /class="text-sm min-w-0 flex-1 truncate"/);
|
||||
assert.match(markup, /class="[^"]*h-6 w-6 shrink-0[^"]*"/);
|
||||
});
|
||||
|
||||
test("color and icon settings render for non-Linux hosts", () => {
|
||||
const markup = renderConnectionSections({
|
||||
os: "macos",
|
||||
distro: "macos",
|
||||
iconMode: "custom",
|
||||
iconId: "terminal",
|
||||
});
|
||||
|
||||
assert.match(markup, /hostDetails\.icon\.sectionTitle/);
|
||||
assert.match(markup, /hostDetails\.icon\.colorLabel/);
|
||||
assert.match(markup, /hostDetails\.icon\.manualLabel/);
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from "react";
|
||||
import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRound, MapPin, Plus, Shield, Trash2, User, X } from "lucide-react";
|
||||
import type { Host } from "../types";
|
||||
import { cn } from "../lib/utils";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import { HostIconPicker } from "./HostIconPicker";
|
||||
import { Button } from "./ui/button";
|
||||
import { Combobox } from "./ui/combobox";
|
||||
import { HostDetailsSection, HostDetailsSettingRow } from "./host-details";
|
||||
@@ -41,7 +41,6 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
newKeyFilePath,
|
||||
setNewKeyFilePath,
|
||||
addLocalKeyFilePath,
|
||||
handleDistroModeChange,
|
||||
distroOptions,
|
||||
effectiveFormDistro,
|
||||
getDistroOptionLabel,
|
||||
@@ -362,20 +361,20 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
|
||||
{/* Selected credential display */}
|
||||
{!selectedIdentity && form.identityFileId && (
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
|
||||
<div className="flex items-center gap-2 min-w-0 overflow-hidden p-2 rounded-md bg-secondary/50 border border-border/60">
|
||||
{form.authMethod === "certificate" ? (
|
||||
<Shield size={14} className="text-primary" />
|
||||
<Shield size={14} className="text-primary shrink-0" />
|
||||
) : (
|
||||
<Key size={14} className="text-primary" />
|
||||
<Key size={14} className="text-primary shrink-0" />
|
||||
)}
|
||||
<span className="text-sm flex-1 truncate">
|
||||
<span className="text-sm min-w-0 flex-1 truncate">
|
||||
{availableKeys.find((k) => k.id === form.identityFileId)
|
||||
?.label || "Key"}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => {
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", "password");
|
||||
@@ -630,108 +629,33 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
</HostDetailsSettingRow>
|
||||
</HostDetailsSection>
|
||||
|
||||
{form.os === "linux" && (
|
||||
<HostDetailsSection
|
||||
icon={<img src="/distro/linux.svg" alt="Linux" className="h-3.5 w-3.5 opacity-70 dark:invert" />}
|
||||
title={t("hostDetails.distro.title")}
|
||||
hint={t("hostDetails.distro.desc")}
|
||||
>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.mode")}</span>
|
||||
<Select
|
||||
value={form.distroMode || "auto"}
|
||||
onValueChange={(val) => handleDistroModeChange(val as "auto" | "manual")}
|
||||
>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.mode")}>
|
||||
<span className="truncate whitespace-nowrap pr-2 text-left">
|
||||
{form.distroMode === "manual"
|
||||
? t("hostDetails.distro.mode.manual")
|
||||
: t("hostDetails.distro.mode.auto")}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("hostDetails.distro.mode.auto")}</SelectItem>
|
||||
<SelectItem value="manual">{t("hostDetails.distro.mode.manual")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{form.distroMode === "manual" ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.manualLabel")}</span>
|
||||
<Select
|
||||
value={form.manualDistro}
|
||||
onValueChange={(val) => update("manualDistro", val)}
|
||||
>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.manualLabel")}>
|
||||
{(() => {
|
||||
const selectedOption = distroOptions.find((option) => option.value === form.manualDistro);
|
||||
return selectedOption ? (
|
||||
<div className="flex min-w-0 items-center gap-2 pr-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
|
||||
selectedOption.bgClass,
|
||||
)}
|
||||
>
|
||||
{selectedOption.icon ? (
|
||||
<img
|
||||
src={selectedOption.icon}
|
||||
alt={selectedOption.label}
|
||||
className="h-3 w-3 object-contain invert brightness-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-white/70" />
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate whitespace-nowrap">{selectedOption.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder={t("hostDetails.distro.unknown")} />
|
||||
);
|
||||
})()}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-[14rem]">
|
||||
{distroOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
|
||||
option.bgClass,
|
||||
)}
|
||||
>
|
||||
{option.icon ? (
|
||||
<img
|
||||
src={option.icon}
|
||||
alt={option.label}
|
||||
className="h-3 w-3 object-contain invert brightness-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-white/70" />
|
||||
)}
|
||||
</div>
|
||||
<span className="whitespace-nowrap">{option.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.detectedLabel")}</span>
|
||||
<div className="flex h-8 items-center rounded-md border border-border/60 bg-background/50 px-3 text-sm">
|
||||
{effectiveFormDistro
|
||||
? getDistroOptionLabel(effectiveFormDistro)
|
||||
: t("hostDetails.distro.unknown")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</HostDetailsSection>
|
||||
)}
|
||||
<HostDetailsSection
|
||||
icon={<DistroAvatar host={form as Host} fallback="H" size="sm" />}
|
||||
title={t("hostDetails.icon.sectionTitle")}
|
||||
hint={t("hostDetails.icon.desc")}
|
||||
>
|
||||
<HostIconPicker
|
||||
distroMode={form.distroMode}
|
||||
manualDistro={form.manualDistro}
|
||||
effectiveDistro={effectiveFormDistro}
|
||||
distroOptions={distroOptions}
|
||||
getDistroOptionLabel={getDistroOptionLabel}
|
||||
iconMode={form.iconMode}
|
||||
iconId={form.iconId}
|
||||
iconColorMode={form.iconColorMode}
|
||||
iconColor={form.iconColor}
|
||||
iconColorCustom={form.iconColorCustom}
|
||||
onChange={(next) => {
|
||||
if ("distroMode" in next) update("distroMode", next.distroMode);
|
||||
if ("manualDistro" in next) update("manualDistro", next.manualDistro);
|
||||
if ("iconMode" in next) update("iconMode", next.iconMode);
|
||||
if ("iconId" in next) update("iconId", next.iconId);
|
||||
if ("iconColorMode" in next) update("iconColorMode", next.iconColorMode);
|
||||
if ("iconColor" in next) update("iconColor", next.iconColor);
|
||||
if ("iconColorCustom" in next) update("iconColorCustom", next.iconColorCustom);
|
||||
}}
|
||||
/>
|
||||
</HostDetailsSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -296,3 +296,21 @@ test("HostDetailsPanel does not offer to disable telnet when telnet is the prima
|
||||
assert.ok(telnetHeader);
|
||||
assert.doesNotMatch(telnetHeader[0], /hover:text-destructive/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel shows color and icon controls in the connection settings", () => {
|
||||
const markup = renderHostDetails({
|
||||
...hostWithMissingProxyProfile,
|
||||
proxyProfileId: undefined,
|
||||
distroMode: "manual",
|
||||
manualDistro: "linux",
|
||||
iconColorMode: "manual",
|
||||
iconColor: "blue",
|
||||
});
|
||||
|
||||
assert.match(markup, /Color & Icon/);
|
||||
assert.match(markup, /Manual icon/);
|
||||
assert.match(markup, /Generic Linux/);
|
||||
assert.match(markup, /Icon color/);
|
||||
assert.match(markup, /Blue/);
|
||||
assert.match(markup, /IP or Hostname/);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import { HostIconPicker } from "./HostIconPicker.tsx";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
|
||||
const distroOptions = [
|
||||
{
|
||||
value: "linux",
|
||||
label: "Generic Linux",
|
||||
icon: "/distro/linux.svg",
|
||||
bgClass: "bg-[#333333]",
|
||||
},
|
||||
{
|
||||
value: "ubuntu",
|
||||
label: "Ubuntu",
|
||||
icon: "/distro/ubuntu.svg",
|
||||
bgClass: "bg-[#E95420]",
|
||||
},
|
||||
];
|
||||
|
||||
const hostIconPickerSource = readFileSync(new URL("./HostIconPicker.tsx", import.meta.url), "utf8");
|
||||
|
||||
const renderPicker = (props: Partial<React.ComponentProps<typeof HostIconPicker>> = {}) =>
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<TooltipProvider>
|
||||
<HostIconPicker
|
||||
distroMode={props.distroMode}
|
||||
manualDistro={props.manualDistro}
|
||||
effectiveDistro={props.effectiveDistro}
|
||||
distroOptions={props.distroOptions || distroOptions}
|
||||
getDistroOptionLabel={props.getDistroOptionLabel || ((value) => distroOptions.find((option) => option.value === value)?.label || "Unknown")}
|
||||
iconMode={props.iconMode}
|
||||
iconId={props.iconId}
|
||||
iconColorMode={props.iconColorMode}
|
||||
iconColor={props.iconColor}
|
||||
iconColorCustom={props.iconColorCustom}
|
||||
manualIconMenuOpen={props.manualIconMenuOpen}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
test("HostIconPicker renders automatic source and automatic color by default", () => {
|
||||
const markup = renderPicker({ effectiveDistro: "ubuntu" });
|
||||
|
||||
assert.match(markup, /Source/);
|
||||
assert.match(markup, /Auto-detect/);
|
||||
assert.match(markup, /Current/);
|
||||
assert.match(markup, /Ubuntu/);
|
||||
assert.match(markup, /Icon color/);
|
||||
assert.match(markup, /Automatic/);
|
||||
assert.match(markup, /Current color/);
|
||||
assert.doesNotMatch(markup, /Brand/);
|
||||
assert.doesNotMatch(markup, /Custom color/);
|
||||
});
|
||||
|
||||
test("HostIconPicker renders a single manual icon dropdown in manual source mode", () => {
|
||||
const markup = renderPicker({ distroMode: "manual", manualDistro: "linux", manualIconMenuOpen: true });
|
||||
|
||||
assert.match(markup, /Manual icon/);
|
||||
assert.match(markup, /Generic Linux/);
|
||||
assert.match(markup, /aria-label="Manual icon"/);
|
||||
});
|
||||
|
||||
test("HostIconPicker renders built-in type icons when a custom type is selected", () => {
|
||||
const markup = renderPicker({ distroMode: "manual", iconMode: "custom", iconId: "database", manualIconMenuOpen: true });
|
||||
|
||||
assert.match(markup, /Database/);
|
||||
assert.match(markup, /Current color/);
|
||||
assert.match(markup, /background-color:#0891B2/);
|
||||
});
|
||||
|
||||
test("HostIconPicker shows the automatic brand color preview", () => {
|
||||
const markup = renderPicker({ distroMode: "manual", manualDistro: "ubuntu" });
|
||||
|
||||
assert.match(markup, /Current color/);
|
||||
assert.match(markup, /bg-\[#E95420\]/);
|
||||
assert.match(markup, /Ubuntu/);
|
||||
});
|
||||
|
||||
test("HostIconPicker ignores stale colors when color mode is automatic", () => {
|
||||
const markup = renderPicker({
|
||||
distroMode: "manual",
|
||||
iconMode: "custom",
|
||||
iconId: "database",
|
||||
iconColorMode: "auto",
|
||||
iconColor: "violet",
|
||||
iconColorCustom: "#12ABEF",
|
||||
});
|
||||
|
||||
assert.match(markup, /Automatic/);
|
||||
assert.match(markup, /Current color/);
|
||||
assert.match(markup, /Database/);
|
||||
assert.match(markup, /background-color:#0891B2/);
|
||||
assert.doesNotMatch(markup, /#12ABEF/);
|
||||
assert.doesNotMatch(markup, /Violet/);
|
||||
});
|
||||
|
||||
test("HostIconPicker keeps brand/type tabs inside the manual icon dropdown menu", () => {
|
||||
assert.match(hostIconPickerSource, /<SelectContent className="min-w-\[16rem\]" hideScrollButtons>[\s\S]*role="tab"[\s\S]*hostDetails\.icon\.tab\.brand/);
|
||||
assert.match(hostIconPickerSource, /<SelectContent className="min-w-\[16rem\]" hideScrollButtons>[\s\S]*hostDetails\.icon\.tab\.type/);
|
||||
assert.doesNotMatch(hostIconPickerSource, /<TabsList/);
|
||||
});
|
||||
|
||||
test("HostIconPicker renders preset and custom color choices in manual color mode", () => {
|
||||
const markup = renderPicker({ iconColorMode: "manual", iconColor: "violet" });
|
||||
|
||||
assert.match(markup, /Manual/);
|
||||
assert.match(markup, /Violet/);
|
||||
});
|
||||
|
||||
test("HostIconPicker renders a custom hex color input", () => {
|
||||
const markup = renderPicker({ iconColorMode: "manual", iconColorCustom: "#12ABEF" });
|
||||
|
||||
assert.match(markup, /Custom color/);
|
||||
assert.match(markup, /#12ABEF/);
|
||||
assert.match(markup, /type="color"/);
|
||||
});
|
||||
|
||||
test("HostIconPicker does not expose image upload", () => {
|
||||
const markup = renderPicker({ distroMode: "manual", iconMode: "custom", iconId: "database" });
|
||||
|
||||
assert.doesNotMatch(markup, /upload/i);
|
||||
assert.doesNotMatch(markup, /choose file/i);
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
import React from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import {
|
||||
DEFAULT_HOST_ICON_COLOR,
|
||||
DEFAULT_HOST_ICON_ID,
|
||||
HOST_ICON_COLORS,
|
||||
HOST_ICON_IDS,
|
||||
isHostIconColorId,
|
||||
isHostIconCustomColor,
|
||||
isHostIconId,
|
||||
resolveHostIconDefaultColorHex,
|
||||
} from "../domain/hostIcon";
|
||||
import type { HostIconColorId, HostIconColorMode, HostIconId, HostIconMode } from "../domain/models";
|
||||
import { cn } from "../lib/utils";
|
||||
import { renderHostIconGlyph } from "./hostIconRenderer";
|
||||
import { Input } from "./ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
|
||||
type DistroOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
bgClass: string;
|
||||
};
|
||||
|
||||
type HostIconPickerProps = {
|
||||
distroMode?: "auto" | "manual";
|
||||
manualDistro?: string;
|
||||
effectiveDistro?: string;
|
||||
distroOptions: DistroOption[];
|
||||
getDistroOptionLabel: (value?: string) => string;
|
||||
iconMode?: HostIconMode;
|
||||
iconId?: HostIconId;
|
||||
iconColorMode?: HostIconColorMode;
|
||||
iconColor?: HostIconColorId;
|
||||
iconColorCustom?: string;
|
||||
manualIconMenuOpen?: boolean;
|
||||
onChange: (next: {
|
||||
distroMode?: "auto" | "manual";
|
||||
manualDistro?: string;
|
||||
iconMode?: HostIconMode;
|
||||
iconId?: HostIconId;
|
||||
iconColorMode?: HostIconColorMode;
|
||||
iconColor?: HostIconColorId;
|
||||
iconColorCustom?: string;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
const renderBrandOption = (option: DistroOption) => (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
|
||||
option.bgClass,
|
||||
)}
|
||||
>
|
||||
{option.icon ? (
|
||||
<img src={option.icon} alt={option.label} className="h-3 w-3 object-contain invert brightness-0" />
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-white/70" />
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate whitespace-nowrap">{option.label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderTypeOption = (iconId: HostIconId, label: string, colorHex: string) => (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px] text-white"
|
||||
style={{ backgroundColor: colorHex }}
|
||||
>
|
||||
{renderHostIconGlyph(iconId, "h-3 w-3")}
|
||||
</div>
|
||||
<span className="truncate whitespace-nowrap">{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderBrandColorPreview = (option: DistroOption, label: string) => (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={cn("h-4 w-4 shrink-0 rounded-full border border-border/70", option.bgClass)} />
|
||||
<span className="truncate whitespace-nowrap">{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderTypeColorPreview = (label: string, colorHex: string) => (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="h-4 w-4 shrink-0 rounded-full border border-border/70" style={{ backgroundColor: colorHex }} />
|
||||
<span className="truncate whitespace-nowrap">{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const colorHexForId = (colorId?: HostIconColorId) =>
|
||||
HOST_ICON_COLORS.find((color) => color.id === colorId)?.hex || HOST_ICON_COLORS[0].hex;
|
||||
|
||||
export const HostIconPicker: React.FC<HostIconPickerProps> = ({
|
||||
distroMode,
|
||||
manualDistro,
|
||||
effectiveDistro,
|
||||
distroOptions,
|
||||
getDistroOptionLabel,
|
||||
iconMode,
|
||||
iconId,
|
||||
iconColorMode,
|
||||
iconColor,
|
||||
iconColorCustom,
|
||||
manualIconMenuOpen,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const manualSource = distroMode === "manual" || iconMode === "custom";
|
||||
const selectedBrandId = manualDistro || effectiveDistro || "linux";
|
||||
const selectedBrand = distroOptions.find((option) => option.value === selectedBrandId);
|
||||
const selectedIconId = isHostIconId(iconId) ? iconId : DEFAULT_HOST_ICON_ID;
|
||||
const selectedIconTab = iconMode === "custom" ? "type" : "brand";
|
||||
const manualColor = iconColorMode === "manual" ||
|
||||
(iconColorMode !== "auto" && (isHostIconColorId(iconColor) || isHostIconCustomColor(iconColorCustom)));
|
||||
const selectedPresetColor = isHostIconColorId(iconColor) ? iconColor : DEFAULT_HOST_ICON_COLOR;
|
||||
const selectedCustomColor = isHostIconCustomColor(iconColorCustom) ? iconColorCustom : colorHexForId(selectedPresetColor);
|
||||
const selectedColorValue = isHostIconCustomColor(iconColorCustom) ? "custom" : selectedPresetColor;
|
||||
const manualPreviewColor = isHostIconCustomColor(iconColorCustom)
|
||||
? selectedCustomColor
|
||||
: colorHexForId(selectedPresetColor);
|
||||
const selectedTypePreviewColor = manualColor
|
||||
? manualPreviewColor
|
||||
: resolveHostIconDefaultColorHex(selectedIconId);
|
||||
const autoColorPreviewLabel = selectedIconTab === "type"
|
||||
? t(`hostDetails.icon.option.${selectedIconId}`)
|
||||
: getDistroOptionLabel(selectedBrandId);
|
||||
const selectedManualIconValue = selectedIconTab === "type" ? `type:${selectedIconId}` : `brand:${selectedBrandId}`;
|
||||
const [customColorDraft, setCustomColorDraft] = React.useState(selectedCustomColor);
|
||||
const [iconMenuTab, setIconMenuTab] = React.useState<"brand" | "type">(selectedIconTab);
|
||||
|
||||
React.useEffect(() => {
|
||||
setCustomColorDraft(selectedCustomColor);
|
||||
}, [selectedCustomColor]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setIconMenuTab(selectedIconTab);
|
||||
}, [selectedIconTab]);
|
||||
|
||||
const setSource = (value: "auto" | "manual") => {
|
||||
if (value === "auto") {
|
||||
onChange({ distroMode: "auto", iconMode: undefined, iconId: undefined });
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
distroMode: "manual",
|
||||
manualDistro: selectedBrandId,
|
||||
iconMode: undefined,
|
||||
iconId: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const setManualIcon = (value: string) => {
|
||||
if (value.startsWith("type:")) {
|
||||
const nextIconId = value.slice("type:".length);
|
||||
if (isHostIconId(nextIconId)) {
|
||||
onChange({ distroMode: "manual", iconMode: "custom", iconId: nextIconId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value.startsWith("brand:")) {
|
||||
onChange({
|
||||
distroMode: "manual",
|
||||
manualDistro: value.slice("brand:".length),
|
||||
iconMode: undefined,
|
||||
iconId: undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const switchIconMenuTab = (nextTab: "brand" | "type") => {
|
||||
setIconMenuTab(nextTab);
|
||||
};
|
||||
|
||||
const setColorMode = (value: "auto" | "manual") => {
|
||||
if (value === "auto") {
|
||||
onChange({ iconColorMode: "auto", iconColor: undefined, iconColorCustom: undefined });
|
||||
return;
|
||||
}
|
||||
onChange({ iconColorMode: "manual", iconColor: selectedPresetColor, iconColorCustom: undefined });
|
||||
};
|
||||
|
||||
const setColorChoice = (value: string) => {
|
||||
if (value === "custom") {
|
||||
onChange({ iconColorMode: "manual", iconColor: undefined, iconColorCustom: selectedCustomColor });
|
||||
return;
|
||||
}
|
||||
if (isHostIconColorId(value)) {
|
||||
onChange({ iconColorMode: "manual", iconColor: value, iconColorCustom: undefined });
|
||||
}
|
||||
};
|
||||
|
||||
const setCustomColor = (value: string) => {
|
||||
setCustomColorDraft(value);
|
||||
if (isHostIconCustomColor(value)) {
|
||||
onChange({
|
||||
iconColorMode: "manual",
|
||||
iconColor: undefined,
|
||||
iconColorCustom: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.icon.source")}</span>
|
||||
<Select value={manualSource ? "manual" : "auto"} onValueChange={(value) => setSource(value as "auto" | "manual")}>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.icon.source")}>
|
||||
<span className="truncate whitespace-nowrap pr-2 text-left">
|
||||
{manualSource ? t("hostDetails.distro.mode.manual") : t("hostDetails.distro.mode.auto")}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("hostDetails.distro.mode.auto")}</SelectItem>
|
||||
<SelectItem value="manual">{t("hostDetails.distro.mode.manual")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{manualSource ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.icon.manualLabel")}</span>
|
||||
<Select
|
||||
value={selectedManualIconValue}
|
||||
onValueChange={setManualIcon}
|
||||
{...(manualIconMenuOpen === undefined ? {} : { open: manualIconMenuOpen })}
|
||||
>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.icon.manualLabel")}>
|
||||
{selectedIconTab === "type"
|
||||
? renderTypeOption(selectedIconId, t(`hostDetails.icon.option.${selectedIconId}`), selectedTypePreviewColor)
|
||||
: selectedBrand
|
||||
? renderBrandOption(selectedBrand)
|
||||
: <SelectValue placeholder={t("hostDetails.distro.unknown")} />}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-[16rem]" hideScrollButtons>
|
||||
<div className="sticky top-0 z-10 bg-popover px-1 pb-1">
|
||||
<div className="grid h-8 grid-cols-2 rounded-md bg-muted/50 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={iconMenuTab === "brand"}
|
||||
className={cn(
|
||||
"rounded-sm text-xs font-medium transition-colors",
|
||||
iconMenuTab === "brand" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground",
|
||||
)}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
switchIconMenuTab("brand");
|
||||
}}
|
||||
>
|
||||
{t("hostDetails.icon.tab.brand")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={iconMenuTab === "type"}
|
||||
className={cn(
|
||||
"rounded-sm text-xs font-medium transition-colors",
|
||||
iconMenuTab === "type" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground",
|
||||
)}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
switchIconMenuTab("type");
|
||||
}}
|
||||
>
|
||||
{t("hostDetails.icon.tab.type")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{iconMenuTab === "brand"
|
||||
? distroOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={`brand:${option.value}`}>
|
||||
{renderBrandOption(option)}
|
||||
</SelectItem>
|
||||
))
|
||||
: HOST_ICON_IDS.map((optionIconId) => (
|
||||
<SelectItem key={optionIconId} value={`type:${optionIconId}`}>
|
||||
{renderTypeOption(
|
||||
optionIconId,
|
||||
t(`hostDetails.icon.option.${optionIconId}`),
|
||||
manualColor ? manualPreviewColor : resolveHostIconDefaultColorHex(optionIconId),
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.detectedLabel")}</span>
|
||||
<div className="flex h-8 items-center rounded-md border border-border/60 bg-background/50 px-3 text-sm">
|
||||
{effectiveDistro ? getDistroOptionLabel(effectiveDistro) : t("hostDetails.distro.unknown")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-3">
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.icon.colorLabel")}</span>
|
||||
<Select value={manualColor ? "manual" : "auto"} onValueChange={(value) => setColorMode(value as "auto" | "manual")}>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.icon.colorLabel")}>
|
||||
<span className="truncate whitespace-nowrap pr-2 text-left">
|
||||
{manualColor ? t("hostDetails.icon.colorManual") : t("hostDetails.icon.colorAuto")}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("hostDetails.icon.colorAuto")}</SelectItem>
|
||||
<SelectItem value="manual">{t("hostDetails.icon.colorManual")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{manualColor ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.icon.colorChoice")}</span>
|
||||
<Select value={selectedColorValue} onValueChange={setColorChoice}>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.icon.colorChoice")}>
|
||||
<div className="flex min-w-0 items-center gap-2 pr-2">
|
||||
<span className="h-4 w-4 shrink-0 rounded-full border border-border/70" style={{ backgroundColor: manualPreviewColor }} />
|
||||
<span className="truncate whitespace-nowrap">
|
||||
{selectedColorValue === "custom"
|
||||
? t("hostDetails.icon.color.custom")
|
||||
: t(`hostDetails.icon.color.${selectedPresetColor}`)}
|
||||
</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HOST_ICON_COLORS.map((color) => (
|
||||
<SelectItem key={color.id} value={color.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 rounded-full border border-border/70" style={{ backgroundColor: color.hex }} />
|
||||
<span>{t(`hostDetails.icon.color.${color.id}`)}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">{t("hostDetails.icon.color.custom")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.icon.autoColorLabel")}</span>
|
||||
<div className="flex h-8 items-center rounded-md border border-border/60 bg-background/50 px-3 text-sm">
|
||||
{selectedIconTab === "type"
|
||||
? renderTypeColorPreview(autoColorPreviewLabel, selectedTypePreviewColor)
|
||||
: selectedBrand
|
||||
? renderBrandColorPreview(selectedBrand, autoColorPreviewLabel)
|
||||
: renderTypeColorPreview(t("hostDetails.distro.unknown"), colorHexForId(DEFAULT_HOST_ICON_COLOR))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{manualColor && selectedColorValue === "custom" && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={isHostIconCustomColor(customColorDraft) ? customColorDraft : selectedCustomColor}
|
||||
onChange={(event) => setCustomColor(event.target.value)}
|
||||
className="h-8 w-12 shrink-0 cursor-pointer p-1"
|
||||
aria-label={t("hostDetails.icon.color.custom")}
|
||||
/>
|
||||
<Input
|
||||
value={customColorDraft}
|
||||
onChange={(event) => setCustomColor(event.target.value)}
|
||||
className="h-8 font-mono text-xs"
|
||||
aria-label={t("hostDetails.icon.color.custom")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+105
-102
@@ -1,4 +1,4 @@
|
||||
import { CheckSquare, ChevronRight, Edit2, FileSymlink, Folder, FolderOpen, Server, Square, Expand, Minimize2 } from 'lucide-react';
|
||||
import { CheckSquare, Edit2, FileSymlink, Server, Square, Expand, Minimize2 } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import {
|
||||
@@ -11,15 +11,14 @@ import { applyGroupDefaults, resolveGroupDefaults } from '../domain/groupConfig'
|
||||
import { resolveTelnetPort, resolveTelnetUsername, sanitizeHost } from '../domain/host';
|
||||
import { sortByVaultOrder } from '../domain/vaultOrder';
|
||||
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
|
||||
import { cn } from '../lib/utils';
|
||||
import { GroupConfig, GroupNode, Host } from '../types';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
import { HostTreeGroupContextMenuContent, HostTreeHostContextMenuContent } from './host/HostTreeContextMenus';
|
||||
import { HostTreeGroupInlineRenameInput } from './host/HostTreeGroupInlineRenameInput';
|
||||
import { ContextMenu, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import { HostNotesIndicator } from './host/HostNotesIndicator';
|
||||
import { Button } from './ui/button';
|
||||
import { VaultTreeGroupRow, VaultTreeItemRow } from './vault/VaultTreeRow';
|
||||
|
||||
const getTreeGroupDropIntent = (
|
||||
element: HTMLElement,
|
||||
@@ -95,6 +94,7 @@ interface TreeNodeProps {
|
||||
getDropTargetClasses?: (target: string) => string;
|
||||
setDragOverDropTarget?: (target: string | null) => void;
|
||||
groupConfigs: GroupConfig[];
|
||||
groupDefaultsByPath: ReadonlyMap<string, Partial<GroupConfig>>;
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
getDropTargetClasses,
|
||||
setDragOverDropTarget,
|
||||
groupConfigs,
|
||||
groupDefaultsByPath,
|
||||
}) => {
|
||||
const inlineEdit = useHostTreeInlineGroupEdit();
|
||||
const vaultTreeActions = useVaultHostTreeActions();
|
||||
@@ -144,7 +145,6 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [inlineEdit?.groupPath, inlineEdit?.shouldScrollIntoView, isInlineEditing]);
|
||||
const hasChildren = node.children && Object.keys(node.children).length > 0;
|
||||
const paddingLeft = `${depth * 20 + 12}px`;
|
||||
const isManaged = managedGroupPaths?.has(node.path) ?? false;
|
||||
const hostsCountInNode = node.totalHostCount ?? node.hosts.length;
|
||||
|
||||
@@ -216,13 +216,18 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div
|
||||
ref={groupRowRef}
|
||||
className={cn(
|
||||
"vault-drop-indicator-row flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
|
||||
getDropTargetClasses?.(node.path),
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
<VaultTreeGroupRow
|
||||
rowRef={groupRowRef}
|
||||
name={node.name}
|
||||
depth={depth}
|
||||
expanded={isExpanded}
|
||||
hasChildren={hasChildren || node.hosts.length > 0}
|
||||
count={hostsCountInNode}
|
||||
editing={isInlineEditing}
|
||||
editingInitialName={inlineEdit?.initialName}
|
||||
onRenameCommit={commitRename}
|
||||
onRenameCancel={cancelRename}
|
||||
className={getDropTargetClasses?.(node.path)}
|
||||
data-section="host-tree-row"
|
||||
data-row-type="group"
|
||||
data-group-path={node.path}
|
||||
@@ -261,52 +266,24 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
moveGroup(groupPath, node.path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="mr-2 flex-shrink-0 w-4 h-4 flex items-center justify-center">
|
||||
{(hasChildren || node.hosts.length > 0) && (
|
||||
<div className={cn("transition-transform duration-200", isExpanded ? "rotate-90" : "")}>
|
||||
<ChevronRight size={14} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mr-3 flex h-8 w-8 shrink-0 items-center justify-center text-primary transition-colors dark:text-primary">
|
||||
{isExpanded ? (
|
||||
<FolderOpen size={21} strokeWidth={2.35} />
|
||||
) : (
|
||||
<Folder size={21} strokeWidth={2.35} />
|
||||
)}
|
||||
</div>
|
||||
{isInlineEditing && commitRename && cancelRename ? (
|
||||
<HostTreeGroupInlineRenameInput
|
||||
initialName={inlineEdit.initialName}
|
||||
onCommit={commitRename}
|
||||
onCancel={cancelRename}
|
||||
className="flex-1 font-semibold"
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate flex-1 font-semibold">{node.name}</span>
|
||||
)}
|
||||
{isManaged && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/15 text-primary shrink-0 mr-1.5">
|
||||
meta={isManaged && (
|
||||
<span className="mr-1.5 inline-flex shrink-0 items-center gap-1 rounded bg-primary/15 px-1.5 py-0.5 text-[10px] font-medium text-primary">
|
||||
<FileSymlink size={10} />
|
||||
Managed
|
||||
</span>
|
||||
)}
|
||||
{(node.hosts.length > 0 || hasChildren) && (
|
||||
<span className="text-xs opacity-70 bg-background/50 px-2 py-0.5 rounded-full border border-border">
|
||||
{hostsCountInNode}
|
||||
</span>
|
||||
actions={(
|
||||
<button
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md opacity-0 transition-colors hover:bg-secondary/80 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditGroup(node.path);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="h-7 w-7 flex items-center justify-center rounded-md hover:bg-secondary/80 transition-colors opacity-0 group-hover:opacity-100 shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditGroup(node.path);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
</ContextMenuTrigger>
|
||||
<HostTreeGroupContextMenuContent
|
||||
@@ -351,6 +328,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
getDropTargetClasses={getDropTargetClasses}
|
||||
setDragOverDropTarget={setDragOverDropTarget}
|
||||
groupConfigs={groupConfigs}
|
||||
groupDefaultsByPath={groupDefaultsByPath}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -371,6 +349,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
groupConfigs={groupConfigs}
|
||||
groupDefaultsByPath={groupDefaultsByPath}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
@@ -393,14 +372,16 @@ interface HostTreeItemProps {
|
||||
selectedHostIds?: Set<string>;
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
groupConfigs: GroupConfig[];
|
||||
groupDefaultsByPath: ReadonlyMap<string, Partial<GroupConfig>>;
|
||||
}
|
||||
|
||||
export const getHostTreeDisplayDetails = (
|
||||
host: Host,
|
||||
groupConfigs: GroupConfig[] = [],
|
||||
groupDefaultsByPath?: ReadonlyMap<string, Partial<GroupConfig>>,
|
||||
) => {
|
||||
const displayHost = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
|
||||
? applyGroupDefaults(host, groupDefaultsByPath?.get(host.group) ?? resolveGroupDefaults(host.group, groupConfigs))
|
||||
: host;
|
||||
const isTelnet = displayHost.protocol === 'telnet';
|
||||
return {
|
||||
@@ -428,13 +409,13 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
selectedHostIds,
|
||||
toggleHostSelection,
|
||||
groupConfigs,
|
||||
groupDefaultsByPath,
|
||||
}) => {
|
||||
const paddingLeft = `${depth * 20 + 12}px`;
|
||||
const safeHost = sanitizeHost(host);
|
||||
const tags = host.tags || [];
|
||||
const displayDetails = useMemo(
|
||||
() => getHostTreeDisplayDetails(host, groupConfigs),
|
||||
[groupConfigs, host],
|
||||
() => getHostTreeDisplayDetails(host, groupConfigs, groupDefaultsByPath),
|
||||
[groupConfigs, groupDefaultsByPath, host],
|
||||
);
|
||||
const displayProtocol = displayDetails.protocol;
|
||||
const displayUsername = displayDetails.username;
|
||||
@@ -443,13 +424,12 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"vault-drop-indicator-row flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg",
|
||||
isSelected ? "bg-primary/10" : "",
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
<ContextMenuTrigger asChild>
|
||||
<VaultTreeItemRow
|
||||
label={host.label}
|
||||
depth={depth}
|
||||
selected={Boolean(isSelected)}
|
||||
className="h-10 rounded-md py-1 pr-2 text-[13px]"
|
||||
data-section="host-tree-row"
|
||||
data-row-type="host"
|
||||
data-host-id={host.id}
|
||||
@@ -462,55 +442,61 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
onConnect(safeHost);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isMultiSelectMode && (
|
||||
<div className="mr-2 flex-shrink-0" onClick={(e) => {
|
||||
leading={isMultiSelectMode ? (
|
||||
<div className="mr-2 flex h-5 w-4 flex-shrink-0 items-center justify-center" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleHostSelection?.(host.id);
|
||||
}}>
|
||||
{isSelected ? (
|
||||
<CheckSquare size={18} className="text-primary" />
|
||||
<CheckSquare size={15} className="text-primary" />
|
||||
) : (
|
||||
<Square size={18} className="text-muted-foreground" />
|
||||
<Square size={15} className="text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
{!isMultiSelectMode && <div className="mr-2 flex-shrink-0 w-4 h-4" />}
|
||||
<div className="mr-3 flex-shrink-0">
|
||||
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="tree" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate flex items-center gap-1.5">
|
||||
<span className="truncate">{host.label}</span>
|
||||
<HostNotesIndicator notes={host.notes} />
|
||||
icon={(
|
||||
<div className="mr-2 flex-shrink-0">
|
||||
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="tree" />
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{displayUsername}@{host.hostname}:{displayPort}
|
||||
)}
|
||||
content={(
|
||||
<div className="min-w-0 flex-1 leading-tight">
|
||||
<div className="flex items-center gap-1.5 truncate font-medium leading-4">
|
||||
<span className="truncate">{host.label}</span>
|
||||
<HostNotesIndicator notes={host.notes} />
|
||||
</div>
|
||||
<div className="truncate text-[11px] leading-4 text-muted-foreground">
|
||||
{displayUsername}@{host.hostname}:{displayPort}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{displayProtocol && displayProtocol !== 'ssh' && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">
|
||||
{displayProtocol.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
{tags.length > 0 && (
|
||||
<span className="text-xs opacity-60">
|
||||
{tags.slice(0, 2).join(', ')}
|
||||
{tags.length > 2 && '...'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="h-7 w-7 flex items-center justify-center rounded-md hover:bg-secondary/80 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditHost(host);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
actions={(
|
||||
<div className="ml-2 flex shrink-0 items-center gap-1.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{displayProtocol && displayProtocol !== 'ssh' && (
|
||||
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] leading-none text-primary">
|
||||
{displayProtocol.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
{tags.length > 0 && (
|
||||
<span className="text-[10px] opacity-60">
|
||||
{tags.slice(0, 2).join(', ')}
|
||||
{tags.length > 2 && '...'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md transition-colors hover:bg-secondary/80"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditHost(host);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
<HostTreeHostContextMenuContent
|
||||
host={host}
|
||||
@@ -595,6 +581,21 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
|
||||
const allGroupPaths = useMemo(() => getAllGroupPaths(groupTree), [groupTree]);
|
||||
|
||||
const groupDefaultsByPath = useMemo(() => {
|
||||
const paths = new Set(allGroupPaths);
|
||||
for (const host of hosts) {
|
||||
if (host.group) {
|
||||
paths.add(host.group);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultsByPath = new Map<string, Partial<GroupConfig>>();
|
||||
for (const path of paths) {
|
||||
defaultsByPath.set(path, resolveGroupDefaults(path, groupConfigs));
|
||||
}
|
||||
return defaultsByPath;
|
||||
}, [allGroupPaths, groupConfigs, hosts]);
|
||||
|
||||
const handleExpandAll = () => {
|
||||
expandAll(allGroupPaths);
|
||||
};
|
||||
@@ -701,6 +702,7 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
getDropTargetClasses={getDropTargetClasses}
|
||||
setDragOverDropTarget={setDragOverDropTarget}
|
||||
groupConfigs={groupConfigs}
|
||||
groupDefaultsByPath={groupDefaultsByPath}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -720,6 +722,7 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
groupConfigs={groupConfigs}
|
||||
groupDefaultsByPath={groupDefaultsByPath}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface KeyboardInteractivePrompt {
|
||||
export interface KeyboardInteractiveRequest {
|
||||
requestId: string;
|
||||
sessionId?: string;
|
||||
scope?: "terminal" | "external";
|
||||
name: string;
|
||||
instructions: string;
|
||||
prompts: KeyboardInteractivePrompt[];
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/storageKeys.ts";
|
||||
import type { Identity, SSHKey } from "../types.ts";
|
||||
import KeychainManager from "./KeychainManager.tsx";
|
||||
import { IdentityCard } from "./keychain/IdentityCard.tsx";
|
||||
import { KeyCard } from "./keychain/KeyCard.tsx";
|
||||
|
||||
const longLabel =
|
||||
"sdakdjkasjakjskajskaijssdakdjkasjakjskajskaijssdakdjkasjakjskajskaijssdakdjkasjakjskajskaijs";
|
||||
|
||||
const renderWithI18n = (node: React.ReactElement) =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(I18nProvider, { locale: "en" }, node),
|
||||
);
|
||||
|
||||
const installStorageStub = (viewMode: string | null = null) => {
|
||||
const values = new Map<string, string>();
|
||||
if (viewMode) {
|
||||
values.set(STORAGE_KEY_VAULT_KEYS_VIEW_MODE, viewMode);
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const installNavigatorStub = () => {
|
||||
const currentNavigator = globalThis.navigator;
|
||||
if (
|
||||
typeof currentNavigator?.platform === "string" &&
|
||||
typeof currentNavigator?.userAgent === "string"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: {
|
||||
platform: "MacIntel",
|
||||
userAgent: "Mac OS",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const identity: Identity = {
|
||||
id: "identity-1",
|
||||
label: longLabel,
|
||||
username: "root",
|
||||
authMethod: "password",
|
||||
password: "pw",
|
||||
created: 1,
|
||||
};
|
||||
|
||||
const keyItem: SSHKey = {
|
||||
id: "key-1",
|
||||
label: longLabel,
|
||||
type: "ED25519",
|
||||
privateKey: "",
|
||||
publicKey: "",
|
||||
source: "imported",
|
||||
category: "key",
|
||||
created: 1,
|
||||
};
|
||||
|
||||
test("IdentityCard list layout constrains long labels", () => {
|
||||
const markup = renderWithI18n(
|
||||
React.createElement(IdentityCard, {
|
||||
identity,
|
||||
viewMode: "list",
|
||||
isSelected: false,
|
||||
onClick: () => {},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.match(markup, /group cursor-pointer min-w-0 w-full max-w-full/);
|
||||
assert.doesNotMatch(markup, /group cursor-pointer min-w-0 w-full max-w-full overflow-hidden/);
|
||||
assert.match(markup, /flex items-center gap-3 h-full min-w-0/);
|
||||
assert.doesNotMatch(markup, /flex items-center gap-3 h-full min-w-0 overflow-hidden/);
|
||||
assert.match(markup, /min-w-0 flex-1 basis-0 overflow-hidden/);
|
||||
assert.match(markup, /block max-w-full truncate text-sm font-semibold/);
|
||||
});
|
||||
|
||||
test("KeyCard list layout constrains long labels", () => {
|
||||
const markup = renderWithI18n(
|
||||
React.createElement(KeyCard, {
|
||||
keyItem,
|
||||
viewMode: "list",
|
||||
isSelected: false,
|
||||
isMac: false,
|
||||
onClick: () => {},
|
||||
onEdit: () => {},
|
||||
onExport: () => {},
|
||||
onCopyPublicKey: () => {},
|
||||
onDelete: () => {},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.match(markup, /group cursor-pointer min-w-0 w-full max-w-full/);
|
||||
assert.doesNotMatch(markup, /group cursor-pointer min-w-0 w-full max-w-full overflow-hidden/);
|
||||
assert.match(markup, /flex items-center gap-3 h-full min-w-0/);
|
||||
assert.doesNotMatch(markup, /flex items-center gap-3 h-full min-w-0 overflow-hidden/);
|
||||
assert.match(markup, /min-w-0 flex-1 basis-0 overflow-hidden/);
|
||||
assert.match(markup, /block max-w-full truncate text-sm font-semibold/);
|
||||
});
|
||||
|
||||
test("KeychainManager list layout constrains long key and identity rows", () => {
|
||||
installNavigatorStub();
|
||||
installStorageStub("list");
|
||||
|
||||
const markup = renderWithI18n(
|
||||
React.createElement(KeychainManager, {
|
||||
keys: [keyItem],
|
||||
identities: [identity],
|
||||
hosts: [],
|
||||
proxyProfiles: [],
|
||||
customGroups: [],
|
||||
groupConfigs: [],
|
||||
managedSources: [],
|
||||
onSave: () => {},
|
||||
onUpdate: () => {},
|
||||
onReorderKeys: () => {},
|
||||
onDelete: () => {},
|
||||
onSaveIdentity: () => {},
|
||||
onReorderIdentities: () => {},
|
||||
onDeleteIdentity: () => {},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.match(markup, /h-full min-w-0 w-full overflow-hidden flex relative/);
|
||||
assert.match(markup, /flex-1 min-w-0 w-full overflow-y-auto/);
|
||||
assert.doesNotMatch(markup, /flex-1 min-w-0 w-full overflow-y-auto overflow-x-hidden/);
|
||||
assert.match(markup, /flex min-w-0 w-full max-w-full flex-col gap-0/);
|
||||
assert.match(markup, /block min-w-0 w-full max-w-full/);
|
||||
assert.match(markup, /Keys/);
|
||||
assert.match(markup, /Identities/);
|
||||
});
|
||||
Loaded 100 of 489 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user