Files
SenMeshNet/application/state/useSettingsState.ts
zhaolei 4a02370006
Some checks failed
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
feat: add monitoring settings tab, workbench view, and inspection alert improvements
2026-10-30 12:12:04 +08:00

2250 lines
97 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
import { publishAppearanceChromeSnapshot } from './appearanceChromeStore';
import {
publishSettingsChromeSnapshot,
registerSettingsChromeActions,
} from './settingsChromeStore';
import {
publishTerminalSettingsSnapshot,
registerTerminalSettingsActions,
} from './terminalSettingsStore';
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import {
normalizeAppLockTimeoutMinutes,
type AppLockSettings,
type AppLockTimeoutMinutes,
} from '../../domain/appLock';
import {
DEFAULT_HTTP_NETWORK_PROXY,
areHttpNetworkProxySettingsEqual,
normalizeHttpNetworkProxySettings,
type HttpNetworkProxySettings,
} from '../../domain/httpNetworkProxy';
import {
STORAGE_KEY_COLOR,
STORAGE_KEY_SYNC,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_THEME_DARK,
STORAGE_KEY_TERM_THEME_LIGHT,
STORAGE_KEY_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_HOTKEY_SCHEME,
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
STORAGE_KEY_HOTKEY_RECORDING,
STORAGE_KEY_CUSTOM_CSS,
STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_UI_FONT_FAMILY,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
STORAGE_KEY_SFTP_SKIP_UNCHANGED,
STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS,
STORAGE_KEY_SFTP_TRANSFER_POOL_IDLE_TTL_MS,
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_STARTUP_LANDING,
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_JMS_DEEP_LINK_ENABLED,
STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
STORAGE_KEY_HTTP_NETWORK_PROXY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
STORAGE_KEY_PRODUCT_NAME,
STORAGE_KEY_MONITORING_DASHBOARD_TITLE,
STORAGE_KEY_REPORT_PRODUCT_NAME,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import {
areCustomKeyBindingsEqual,
nextCustomKeyBindingsSyncVersion,
parseCustomKeyBindingsStorageRecord,
resetCustomKeyBinding,
serializeCustomKeyBindingsStorageRecord,
shouldApplyIncomingCustomKeyBindingsRecord,
updateCustomKeyBinding as updateCustomKeyBindingRecord,
} from '../../domain/customKeyBindings';
import { resolveGlobalTerminalAppearance, idleThemeUserIntent } from '../../domain/terminalAppearanceRuntime';
import { DEFAULT_FONT_SIZE, TERMINAL_FONT_AUTO } from '../../infrastructure/config/fonts';
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
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,
resolveSftpSkipUnchangedEnabled,
} from './sftp/transferConcurrency';
import { resolveSshTransportIdleTtlMs } from '../../infrastructure/config/sshTransportIdleTtl';
import {
DEFAULT_ACCENT_MODE,
DEFAULT_DARK_UI_THEME,
DEFAULT_EDITOR_WORD_WRAP,
DEFAULT_HOTKEY_SCHEME,
DEFAULT_LIGHT_UI_THEME,
DEFAULT_SESSION_LOGS_ENABLED,
DEFAULT_SESSION_LOGS_FORMAT,
DEFAULT_SESSION_LOGS_TIMESTAMPS_ENABLED,
DEFAULT_SFTP_AUTO_OPEN_SIDEBAR,
DEFAULT_SFTP_FOLLOW_TERMINAL_CWD,
DEFAULT_SFTP_AUTO_SYNC,
DEFAULT_SFTP_DEFAULT_VIEW_MODE,
DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR,
DEFAULT_SFTP_SHOW_HIDDEN_FILES,
DEFAULT_SFTP_USE_COMPRESSED_UPLOAD,
DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
DEFAULT_SHOW_RECENT_HOSTS,
DEFAULT_HOST_CLICK_BEHAVIOR,
DEFAULT_SHOW_SFTP_TAB,
DEFAULT_SHOW_HOST_TREE_SIDEBAR,
DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
DEFAULT_SHOW_TAB_NUMBER_BADGES,
DEFAULT_DISABLE_TERMINAL_FONT_ZOOM,
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
DEFAULT_SSH_DEEP_LINK_ENABLED,
DEFAULT_JMS_DEEP_LINK_ENABLED,
DEFAULT_EXPLORER_CONTEXT_MENU_ENABLED,
DEFAULT_TERMINAL_THEME,
DEFAULT_THEME,
DEFAULT_WINDOW_OPACITY,
clampWindowOpacity,
applyThemeTokens,
areTerminalSettingsEqual,
createCustomKeyBindingsSyncOrigin,
getSystemPreference,
isValidHslToken,
isValidTheme,
isValidUiFontId,
isValidUiThemeId,
migrateIncomingTerminalFontId,
readStoredString,
serializeTerminalSettings,
type HostClickBehavior,
} from './settingsStateDefaults';
import { isHostClickBehavior } from '../../domain/hostClickBehavior';
import {
resolveStartupLandingSetting,
type StartupLanding,
} from '../../domain/startupLanding';
import { resolveRestorePreviousSessionSetting, resolveRestoreTerminalCwdSetting } from './sessionRestoreSettings';
import { sessionRestoreStorage } from './sessionRestoreStorage';
import { useSettingsStorageSync } from './settingsStorageSync';
import { useSettingsIpcSync } from './settingsIpcSync';
import { TERMINAL_THEME_AUTO } from '../../domain/terminalAppearance';
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
import { useSystemSettingsEffects } from './systemSettingsEffects';
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
import { DEFAULT_APP_ICON_VARIANT } from '../../infrastructure/config/appIconVariants';
import { applyCustomCssToDocument } from '../../lib/customCss';
import {
DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_ENABLED,
DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
isTerminalSidePanelAutoOpenTab,
type TerminalSidePanelAutoOpenTab,
} from '../../domain/terminalSidePanelAutoOpen';
import {
parseWindowOpacityRecord,
shouldApplyWindowOpacityRecord,
type WindowOpacityMutationSource,
type WindowOpacityRecord,
} from './windowOpacitySync';
import {
parseCustomAccentRecord,
serializeCustomAccentRecord,
shouldApplyCustomAccentRecord,
shouldBroadcastCustomAccentChange,
type CustomAccentMutationSource,
type CustomAccentRecord,
} from './customAccentSync';
import {
createLocalTerminalFontSizeRecord,
createTerminalFontSizeSyncOrigin,
parseTerminalFontSizeRecord,
resolveAuthoritativeTerminalFontSizeStorage,
resolveIncomingTerminalFontSize,
shouldBroadcastTerminalFontSizeChange,
type TerminalFontSizeMutationSource,
type TerminalFontSizeRecord,
} from './terminalFontSizeSync';
import {
hasPersistedAppearanceChanged,
resolveAppearanceSyncState,
type AppearanceRenderSnapshot,
type AppearanceSyncEvent,
} from './appearanceSync';
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();
const [theme, setTheme] = useState<'dark' | 'light' | 'system'>(() => {
const stored = readStoredString(STORAGE_KEY_THEME);
return stored && isValidTheme(stored) ? stored : DEFAULT_THEME;
});
// Track the OS color scheme preference (updated by matchMedia listener)
const [systemPreference, setSystemPreference] = useState<'light' | 'dark'>(getSystemPreference);
// resolvedTheme is always 'light' or 'dark' - derived synchronously from theme + OS preference
const resolvedTheme: 'light' | 'dark' = theme === 'system' ? systemPreference : theme;
const [lightUiThemeId, setLightUiThemeId] = useState<string>(() => {
const stored = readStoredString(STORAGE_KEY_UI_THEME_LIGHT);
return stored && isValidUiThemeId('light', stored) ? stored : DEFAULT_LIGHT_UI_THEME;
});
const [darkUiThemeId, setDarkUiThemeId] = useState<string>(() => {
const stored = readStoredString(STORAGE_KEY_UI_THEME_DARK);
return stored && isValidUiThemeId('dark', stored) ? stored : DEFAULT_DARK_UI_THEME;
});
const [customAccentRecord, setCustomAccentRecord] = useState<CustomAccentRecord>(() => {
return parseCustomAccentRecord(readStoredString(STORAGE_KEY_COLOR));
});
const customAccent = customAccentRecord.color;
const customAccentMutationSourceRef = useRef<CustomAccentMutationSource>('local');
const setCustomAccent = useCallback((nextValue: SetStateAction<string>) => {
customAccentMutationSourceRef.current = 'local';
setCustomAccentRecord((prev) => {
const candidate = typeof nextValue === 'function'
? (nextValue as (prevState: string) => string)(prev.color)
: nextValue;
const next = parseCustomAccentRecord(candidate);
if (next.color === prev.color) return prev;
return { color: next.color, version: prev.version + 1 };
});
}, []);
const applyIncomingCustomAccent = useCallback((raw: unknown) => {
const incoming = parseCustomAccentRecord(raw);
setCustomAccentRecord((prev) => {
if (!shouldApplyCustomAccentRecord(prev, incoming)) {
return prev;
}
customAccentMutationSourceRef.current = 'incoming';
return incoming;
});
}, []);
const [accentMode, setAccentMode] = useState<'theme' | 'custom'>(() => {
const stored = readStoredString(STORAGE_KEY_ACCENT_MODE);
if (stored === 'theme' || stored === 'custom') return stored;
const raw = readStoredString(STORAGE_KEY_COLOR);
if (!raw) return DEFAULT_ACCENT_MODE;
if (isValidHslToken(raw.trim())) return 'custom';
if (raw.trim().startsWith('{')) {
try {
const obj = JSON.parse(raw.trim()) as { color?: unknown };
return typeof obj.color === 'string' && isValidHslToken(obj.color) ? 'custom' : DEFAULT_ACCENT_MODE;
} catch {
return DEFAULT_ACCENT_MODE;
}
}
return DEFAULT_ACCENT_MODE;
});
const [uiFontFamilyId, setUiFontFamilyId] = useState<string>(() => {
const stored = readStoredString(STORAGE_KEY_UI_FONT_FAMILY);
return stored && isValidUiFontId(stored) ? stored : DEFAULT_UI_FONT_ID;
});
// 产品名称配置(用于标题栏、侧边栏 Logo 等)
const [productName, setProductName] = useState<string>(() => {
return localStorageAdapter.readString(STORAGE_KEY_PRODUCT_NAME) || '云启智能运维平台';
});
// 远程浏览器:启动时从 /api/dashboard-title 拉取产品名系列配置,写入 localStorage
useEffect(() => {
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
if (!IS_REMOTE) return;
const token = (window as any).__REMOTE_TOKEN__ || "";
fetch(`/api/dashboard-title?token=${encodeURIComponent(token)}`)
.then(r => r.json())
.then(d => {
let changed = false;
if (d?.productName) {
localStorageAdapter.writeString(STORAGE_KEY_PRODUCT_NAME, d.productName);
setProductName(d.productName);
changed = true;
}
if (d?.dashboardTitle) {
localStorageAdapter.writeString(STORAGE_KEY_MONITORING_DASHBOARD_TITLE, d.dashboardTitle);
changed = true;
}
if (d?.reportProductName !== undefined) {
localStorageAdapter.writeString(STORAGE_KEY_REPORT_PRODUCT_NAME, d.reportProductName);
changed = true;
}
if (changed) {
try { window.dispatchEvent(new CustomEvent('productNameChanged')); } catch {}
try { window.dispatchEvent(new CustomEvent('monitoringTitleChanged')); } catch {}
}
})
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Electron 本地:启动时从主进程读取产品名系列配置 → 恢复到 Local Storage
// 解决绿色便携版首次启动 Local Storage 空时显示旧默认值的问题
useEffect(() => {
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
if (IS_REMOTE) return; // 远程浏览器有上面的 effect 处理
const nc = (window as any).netcatty;
if (!nc?.inspection) return;
const localProductName = localStorageAdapter.readString(STORAGE_KEY_PRODUCT_NAME);
const needsRestore = !localProductName; // Local Storage 空才需要恢复
if (needsRestore) {
nc.inspection.getProductName?.().then((v: string) => {
if (v && v.length > 0) {
localStorageAdapter.writeString(STORAGE_KEY_PRODUCT_NAME, v);
setProductName(v);
console.info('[useSettingsState] restored productName from main:', v);
}
}).catch(() => {});
}
// dashboardTitle 和 reportProductName 在 SettingsApplicationTab 里单独恢复
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [syncConfig, setSyncConfig] = useState<SyncConfig | null>(() => localStorageAdapter.read<SyncConfig>(STORAGE_KEY_SYNC));
const [terminalThemeId, setTerminalThemeId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME) || DEFAULT_TERMINAL_THEME);
const [followAppTerminalTheme, setFollowAppTerminalThemeState] = useState<boolean>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_TERM_FOLLOW_APP_THEME);
if (stored !== null) return stored === 'true';
// First time seeing this key. For genuinely fresh installs (no existing
// terminal theme in storage) default ON so the terminal matches the app
// theme out of the box. For upgrades from an older version (existing
// terminal theme present) default OFF to avoid silently overriding the
// user's manual choice.
const isUpgrade = !!localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
return !isUpgrade;
});
const [terminalThemeDarkId, setTerminalThemeDarkId] = useState<string>(
() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_DARK) || TERMINAL_THEME_AUTO,
);
const [terminalThemeLightId, setTerminalThemeLightId] = useState<string>(
() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_LIGHT) || TERMINAL_THEME_AUTO,
);
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
return migrateIncomingTerminalFontId(stored) ?? TERMINAL_FONT_AUTO;
});
const [terminalFontSizeRecord, setTerminalFontSizeRecord] = useState<TerminalFontSizeRecord>(() => {
const stored = readStoredString(STORAGE_KEY_TERM_FONT_SIZE);
if (stored === null) {
return { fontSize: DEFAULT_FONT_SIZE, version: 0, origin: 'legacy' };
}
return parseTerminalFontSizeRecord(stored);
});
const terminalFontSize = terminalFontSizeRecord.fontSize;
const terminalFontSizeRecordRef = useRef(terminalFontSizeRecord);
const terminalFontSizeMutationSourceRef = useRef<TerminalFontSizeMutationSource>('local');
const terminalFontSizeLocalOriginRef = useRef(createTerminalFontSizeSyncOrigin());
const setTerminalFontSize = useCallback((nextValue: SetStateAction<number>) => {
terminalFontSizeMutationSourceRef.current = 'local';
const persistedRaw = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_SIZE);
const current = terminalFontSizeRecordRef.current;
const candidate = typeof nextValue === 'function'
? (nextValue as (prevState: number) => number)(current.fontSize)
: nextValue;
const next = createLocalTerminalFontSizeRecord(
current,
persistedRaw,
candidate,
terminalFontSizeLocalOriginRef.current,
);
if (next === current) return;
terminalFontSizeRecordRef.current = next;
setTerminalFontSizeRecord(next);
}, []);
const applyIncomingTerminalFontSize = useCallback((raw: unknown) => {
const resolution = resolveIncomingTerminalFontSize(
terminalFontSizeRecordRef.current,
raw,
localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_SIZE),
);
if (resolution.repairSerializedRecord !== null) {
localStorageAdapter.writeString(
STORAGE_KEY_TERM_FONT_SIZE,
resolution.repairSerializedRecord,
);
}
if (!resolution.shouldUpdate) return;
terminalFontSizeMutationSourceRef.current = 'incoming';
terminalFontSizeRecordRef.current = resolution.record;
setTerminalFontSizeRecord(resolution.record);
}, []);
const [uiLanguage, setUiLanguage] = useState<UILanguage>(() => {
const stored = readStoredString(STORAGE_KEY_UI_LANGUAGE);
return resolveSupportedLocale(stored || DEFAULT_UI_LOCALE);
});
const [appLockSettings, setAppLockSettingsState] = useState<AppLockSettings>({
enabled: false,
timeoutMinutes: 15,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
passwordVerifier: null,
});
const [terminalSettings, setTerminalSettingsState] = useState<TerminalSettings>(() => {
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
return normalizeTerminalSettings(stored);
});
const [hotkeyScheme, setHotkeyScheme] = useState<HotkeyScheme>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_HOTKEY_SCHEME);
// Validate stored value is a valid HotkeyScheme
if (stored === 'disabled' || stored === 'mac' || stored === 'pc') {
return stored;
}
return DEFAULT_HOTKEY_SCHEME;
});
const [customKeyBindings, setCustomKeyBindingsState] = useState<CustomKeyBindings>(() =>
initialCustomKeyBindingsRecord?.bindings || {}
);
const [isHotkeyRecording, setIsHotkeyRecordingState] = useState(false);
const [customCSS, setCustomCSS] = useState<string>(() =>
localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS) || ''
);
const [sftpDoubleClickBehavior, setSftpDoubleClickBehavior] = useState<'open' | 'transfer'>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
return (stored === 'open' || stored === 'transfer') ? stored : DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR;
});
const [sftpAutoSync, setSftpAutoSync] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_SYNC);
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_SYNC;
});
const [sftpShowHiddenFiles, setSftpShowHiddenFiles] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
return stored === 'true' ? true : DEFAULT_SFTP_SHOW_HIDDEN_FILES;
});
const [sftpUseCompressedUpload, setSftpUseCompressedUpload] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
// 兼容旧的设置值
if (stored === 'true' || stored === 'enabled' || stored === 'ask') return true;
if (stored === 'false' || stored === 'disabled') return false;
return DEFAULT_SFTP_USE_COMPRESSED_UPLOAD;
});
const [sftpAutoOpenSidebar, setSftpAutoOpenSidebar] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_OPEN_SIDEBAR;
});
const [sftpFollowTerminalCwd, setSftpFollowTerminalCwd] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD);
return stored === 'true' ? true : DEFAULT_SFTP_FOLLOW_TERMINAL_CWD;
});
const [sftpDefaultViewMode, setSftpDefaultViewMode] = useState<'list' | 'tree'>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
return (stored === 'list' || stored === 'tree') ? stored : DEFAULT_SFTP_DEFAULT_VIEW_MODE;
});
const [showRecentHosts, setShowRecentHostsState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
return stored ?? DEFAULT_SHOW_RECENT_HOSTS;
});
const [hostClickBehavior, setHostClickBehaviorState] = useState<HostClickBehavior>(() => {
const stored = readStoredString(STORAGE_KEY_HOST_CLICK_BEHAVIOR);
return isHostClickBehavior(stored) ? stored : DEFAULT_HOST_CLICK_BEHAVIOR;
});
const [showOnlyUngroupedHostsInRoot, setShowOnlyUngroupedHostsInRootState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
return stored ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT;
});
const [showSftpTab, setShowSftpTabState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
return stored ?? DEFAULT_SHOW_SFTP_TAB;
});
const [showHostTreeSidebar, setShowHostTreeSidebarState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
return stored ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR;
});
const [terminalSidePanelAutoOpen, setTerminalSidePanelAutoOpenState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN);
return stored ?? DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_ENABLED;
});
const [terminalSidePanelAutoOpenTab, setTerminalSidePanelAutoOpenTabState] = useState<TerminalSidePanelAutoOpenTab>(() => {
const stored = readStoredString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB);
return isTerminalSidePanelAutoOpenTab(stored) ? stored : DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB;
});
const [shellOnlyTabNumberShortcuts, setShellOnlyTabNumberShortcutsState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
return stored ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS;
});
const [showTabNumberBadges, setShowTabNumberBadgesState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES);
return stored ?? DEFAULT_SHOW_TAB_NUMBER_BADGES;
});
const [disableTerminalFontZoom, setDisableTerminalFontZoomState] = useState<boolean>(() => {
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 [startupLanding, setStartupLandingState] = useState<StartupLanding>(() => {
return resolveStartupLandingSetting(readStoredString(STORAGE_KEY_STARTUP_LANDING));
});
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
return resolveSftpTransferConcurrency(() =>
localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
);
});
// Folder transfer concurrency is renderer-only (runSftpTransferWorkers).
// Do not push it into main-process host admission - that made multi-select
// top-level files queue against each other and against folder children.
const [sftpSkipUnchanged, setSftpSkipUnchanged] = useState<boolean>(() =>
resolveSftpSkipUnchangedEnabled(() => localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED)),
);
const [sshTransportIdleTtlMs, setSshTransportIdleTtlMsState] = useState<number>(() => {
// Prefer the new SSH transport key; if absent (upgrade), migrate the legacy
// transfer-pool keep-alive value so users do not silently fall back to 5m.
return resolveSshTransportIdleTtlMs(() => {
const next = localStorageAdapter.readNumber(STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS);
if (next != null) return next;
const legacy = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_POOL_IDLE_TTL_MS);
if (legacy != null) {
try {
localStorageAdapter.writeString(STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS, String(legacy));
} catch {
// ignore migration write failures
}
}
return legacy;
});
});
// Editor Settings
const [editorWordWrap, setEditorWordWrapState] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_EDITOR_WORD_WRAP);
return stored === 'true' ? true : DEFAULT_EDITOR_WORD_WRAP;
});
// Session Logs Settings
const [sessionLogsEnabled, setSessionLogsEnabled] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SESSION_LOGS_ENABLED);
return stored === 'true' ? true : DEFAULT_SESSION_LOGS_ENABLED;
});
const [sessionLogsDir, setSessionLogsDir] = useState<string>(() => {
return readStoredString(STORAGE_KEY_SESSION_LOGS_DIR) || '';
});
const [sessionLogsFormat, setSessionLogsFormat] = useState<SessionLogFormat>(() => {
const stored = readStoredString(STORAGE_KEY_SESSION_LOGS_FORMAT);
if (stored === 'txt' || stored === 'raw' || stored === 'html') return stored;
return DEFAULT_SESSION_LOGS_FORMAT;
});
const [sessionLogsTimestampsEnabled, setSessionLogsTimestampsEnabled] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED);
return stored === 'true' ? true : DEFAULT_SESSION_LOGS_TIMESTAMPS_ENABLED;
});
const [sshDebugLogsEnabled, setSshDebugLogsEnabled] = useState<boolean>(() => {
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;
});
const [jmsDeepLinkEnabled, setJmsDeepLinkEnabledState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED);
return stored ?? DEFAULT_JMS_DEEP_LINK_ENABLED;
});
const [explorerContextMenuEnabled, setExplorerContextMenuEnabledState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED);
return stored ?? DEFAULT_EXPLORER_CONTEXT_MENU_ENABLED;
});
const [explorerContextMenuSupported, setExplorerContextMenuSupported] = useState<boolean>(() => {
if (typeof navigator === 'undefined') return false;
return /Win/i.test(navigator.platform) || /Windows/i.test(navigator.userAgent);
});
// Global Toggle Window Settings (Quake Mode)
const [toggleWindowHotkey, setToggleWindowHotkey] = useState<string>(() => {
const stored = readStoredString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY);
if (stored !== null) return stored;
// Default: Ctrl+` (Control+backtick) - similar to VS Code terminal toggle
const isMac = typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform);
return isMac ? '⌃ + `' : 'Ctrl + `';
});
const [closeToTray, setCloseToTray] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_CLOSE_TO_TRAY);
// Default to true (enabled)
if (stored === null) return true;
return stored === 'true';
});
// Optimistic cache for first paint; useSystemSettingsEffects hydrates this
// from app.getLoginItemSettings() (the real source of truth) on mount.
const [autoLaunchEnabled, setAutoLaunchEnabled] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_AUTO_LAUNCH_ENABLED);
return stored === 'true';
});
const [autoLaunchSupported, setAutoLaunchSupported] = useState<boolean>(true);
const [httpNetworkProxy, setHttpNetworkProxyState] = useState<HttpNetworkProxySettings>(() => {
const stored = localStorageAdapter.read<unknown>(STORAGE_KEY_HTTP_NETWORK_PROXY);
return normalizeHttpNetworkProxySettings(stored ?? DEFAULT_HTTP_NETWORK_PROXY);
});
const setHttpNetworkProxy = useCallback((nextValue: SetStateAction<HttpNetworkProxySettings>) => {
setHttpNetworkProxyState((prev) => {
const candidate = typeof nextValue === 'function'
? (nextValue as (prevState: HttpNetworkProxySettings) => HttpNetworkProxySettings)(prev)
: nextValue;
// Preserve the previous object when values are unchanged so cross-window
// settings:changed IPC does not rebroadcast forever via useSystemSettingsEffects.
const next = normalizeHttpNetworkProxySettings(candidate);
return areHttpNetworkProxySettingsEqual(prev, next) ? prev : next;
});
}, []);
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_AUTO_UPDATE_ENABLED);
if (stored === null) return true; // Default to enabled
return stored === 'true';
});
const [hotkeyRegistrationError, setHotkeyRegistrationError] = useState<string | null>(null);
const [globalHotkeyEnabled, setGlobalHotkeyEnabled] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED);
if (stored === null) return true; // Default to enabled
return stored === 'true';
});
const [windowOpacityRecord, setWindowOpacityRecord] = useState<WindowOpacityRecord>(() => {
const stored = readStoredString(STORAGE_KEY_WINDOW_OPACITY);
if (stored === null) return { opacity: DEFAULT_WINDOW_OPACITY, version: 0 };
return parseWindowOpacityRecord(stored);
});
const windowOpacity = windowOpacityRecord.opacity;
const windowOpacityMutationSourceRef = useRef<WindowOpacityMutationSource>('local');
const setWindowOpacity = useCallback((nextValue: SetStateAction<number>) => {
windowOpacityMutationSourceRef.current = 'local';
setWindowOpacityRecord((prev) => {
const candidate = typeof nextValue === 'function'
? (nextValue as (prevState: number) => number)(prev.opacity)
: nextValue;
const nextOpacity = clampWindowOpacity(candidate);
if (nextOpacity === prev.opacity) return prev;
return { opacity: nextOpacity, version: prev.version + 1 };
});
}, []);
const applyIncomingWindowOpacity = useCallback((raw: unknown) => {
const incoming = parseWindowOpacityRecord(raw);
// Version gate so stale IPC/storage echoes cannot clobber a newer local
// drag revision (see #2018).
setWindowOpacityRecord((prev) => {
if (!shouldApplyWindowOpacityRecord(prev, incoming)) {
return prev;
}
windowOpacityMutationSourceRef.current = 'incoming';
return incoming;
});
}, []);
const [appIconVariant, setAppIconVariantState] = useState<AppIconVariant>(() => {
const stored = readStoredString(STORAGE_KEY_APP_ICON_VARIANT);
return resolveAppIconVariant(stored ?? DEFAULT_APP_ICON_VARIANT);
});
const setAppIconVariant = useCallback((nextValue: SetStateAction<AppIconVariant>) => {
setAppIconVariantState((prev) => {
const candidate = typeof nextValue === 'function'
? (nextValue as (prevState: AppIconVariant) => AppIconVariant)(prev)
: nextValue;
return resolveAppIconVariant(candidate);
});
}, []);
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
const localTerminalSettingsVersionRef = useRef(0);
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
const customKeyBindingsVersionRef = useRef(initialCustomKeyBindingsRecord?.version || 0);
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);
const jmsDeepLinkMutationSourceRef = useRef<'local' | 'incoming'>('local');
const jmsDeepLinkEnabledRef = useRef(jmsDeepLinkEnabled);
const jmsDeepLinkSetRequestIdRef = useRef(0);
const explorerContextMenuMutationSourceRef = useRef<'local' | 'incoming'>('local');
const explorerContextMenuEnabledRef = useRef(explorerContextMenuEnabled);
const explorerContextMenuSetRequestIdRef = 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.
const persistMountedRef = useRef(false);
const appearanceTransitionModeRef = useRef<ThemeTransitionMode>('view');
const previousAppearanceRenderRef = useRef<AppearanceRenderSnapshot | null>(null);
// Latest appearance kept in a ref so sequential keyed IPC updates (one multi-field
// change -> multiple notifies) can compose without waiting for a React re-render.
const appearanceStateRef = useRef({
theme,
lightUiThemeId,
darkUiThemeId,
accentMode,
customAccent,
customAccentVersion: customAccentRecord.version,
});
appearanceStateRef.current = {
theme,
lightUiThemeId,
darkUiThemeId,
accentMode,
customAccent,
customAccentVersion: customAccentRecord.version,
};
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
setTerminalSettingsState((prev) => {
const candidate = typeof nextValue === 'function'
? (nextValue as (prevState: TerminalSettings) => TerminalSettings)(prev)
: nextValue;
const next = normalizeTerminalSettings(candidate);
if (areTerminalSettingsEqual(prev, next)) {
return prev;
}
localTerminalSettingsVersionRef.current += 1;
return next;
});
}, []);
const mergeIncomingTerminalSettings = useCallback((incoming: Partial<TerminalSettings>) => {
setTerminalSettingsState((prev) => {
const merged: Partial<TerminalSettings> = { ...prev, ...incoming };
if (
!Object.prototype.hasOwnProperty.call(incoming, 'middleClickBehavior') &&
Object.prototype.hasOwnProperty.call(incoming, 'middleClickPaste')
) {
delete merged.middleClickBehavior;
}
const next = normalizeTerminalSettings(merged);
if (areTerminalSettingsEqual(prev, next)) {
return prev;
}
// Mark the exact incoming snapshot so only this state is skipped for IPC rebroadcast.
incomingTerminalSettingsSignatureRef.current = serializeTerminalSettings(next);
return next;
});
}, []);
const setCustomKeyBindings = useCallback((nextValue: SetStateAction<CustomKeyBindings>) => {
setCustomKeyBindingsState((prev) => {
const candidate = typeof nextValue === 'function'
? (nextValue as (prevState: CustomKeyBindings) => CustomKeyBindings)(prev)
: nextValue;
if (areCustomKeyBindingsEqual(prev, candidate)) {
return prev;
}
customKeyBindingsVersionRef.current = nextCustomKeyBindingsSyncVersion(
customKeyBindingsVersionRef.current,
);
customKeyBindingsOriginRef.current = customKeyBindingsLocalOriginRef.current;
customKeyBindingsMutationSourceRef.current = 'local';
return candidate;
});
}, []);
const applyIncomingCustomKeyBindings = useCallback((incoming: {
bindings: CustomKeyBindings;
version: number;
origin: string;
}) => {
setCustomKeyBindingsState((prev) => {
if (!shouldApplyIncomingCustomKeyBindingsRecord(
{
version: customKeyBindingsVersionRef.current,
origin: customKeyBindingsOriginRef.current,
},
{
version: incoming.version,
origin: incoming.origin,
},
)) {
return prev;
}
customKeyBindingsVersionRef.current = incoming.version;
customKeyBindingsOriginRef.current = incoming.origin;
customKeyBindingsMutationSourceRef.current = 'incoming';
if (areCustomKeyBindingsEqual(prev, incoming.bindings)) {
return prev;
}
return incoming.bindings;
});
}, []);
const applyIncomingSshDeepLinkEnabled = useCallback((enabled: boolean) => {
sshDeepLinkSetRequestIdRef.current += 1;
setSshDeepLinkEnabledState((prev) => {
if (prev === enabled) return prev;
sshDeepLinkMutationSourceRef.current = 'incoming';
return enabled;
});
}, []);
const applyIncomingJmsDeepLinkEnabled = useCallback((enabled: boolean) => {
jmsDeepLinkSetRequestIdRef.current += 1;
setJmsDeepLinkEnabledState((prev) => {
if (prev === enabled) return prev;
jmsDeepLinkMutationSourceRef.current = 'incoming';
return enabled;
});
}, []);
const applyIncomingExplorerContextMenuEnabled = useCallback((enabled: boolean) => {
explorerContextMenuSetRequestIdRef.current += 1;
setExplorerContextMenuEnabledState((prev) => {
if (prev === enabled) return prev;
explorerContextMenuMutationSourceRef.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) => {
const clamped = Math.max(1, Math.min(16, Math.round(value)));
setSftpTransferConcurrencyState(clamped);
localStorageAdapter.writeString(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY, String(clamped));
// Intentionally not calling setGlobalTransferConcurrency - this setting
// only caps files inside a single folder transfer job.
notifySettingsChanged(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY, clamped);
}, [notifySettingsChanged]);
const setSshTransportIdleTtlMs = useCallback((value: number) => {
const next = resolveSshTransportIdleTtlMs(() => value);
setSshTransportIdleTtlMsState(next);
localStorageAdapter.writeString(STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS, String(next));
notifySettingsChanged(STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS, next);
}, [notifySettingsChanged]);
// Push idle TTL to the main-process transport registry on load and whenever
// it changes (including cross-window IPC/storage updates to local state).
const lastPushedSshTransportIdleTtlRef = useRef<number | null>(null);
useEffect(() => {
if (lastPushedSshTransportIdleTtlRef.current === sshTransportIdleTtlMs) return;
lastPushedSshTransportIdleTtlRef.current = sshTransportIdleTtlMs;
notifySettingsChanged(STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS, sshTransportIdleTtlMs);
}, [sshTransportIdleTtlMs, notifySettingsChanged]);
const [workspaceFocusStyle, setWorkspaceFocusStyleState] = useState<'dim' | 'border'>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
return stored === 'border' ? 'border' : 'dim';
});
const setWorkspaceFocusStyle = useCallback((style: 'dim' | 'border') => {
setWorkspaceFocusStyleState(style);
localStorageAdapter.writeString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, style);
notifySettingsChanged(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, style);
}, [notifySettingsChanged]);
const setAppLockTimeoutMinutes = useCallback((timeoutMinutes: AppLockTimeoutMinutes) => {
void netcattyBridge.get()?.setAppLockTimeoutMinutes?.(normalizeAppLockTimeoutMinutes(timeoutMinutes))
?.then((next) => {
if (next) setAppLockSettingsState(next);
})
.catch(() => {});
}, []);
const requestAppLockEnable = useCallback(async () => {
const next = await netcattyBridge.get()?.requestAppLockEnable?.();
if (next && !('ok' in next)) {
setAppLockSettingsState(next);
}
return next ?? appLockSettings;
}, [appLockSettings]);
const requestAppLockDisable = useCallback(async (currentPassword: string) => {
const next = await netcattyBridge.get()?.requestAppLockDisable?.(currentPassword);
if (next && !('ok' in next)) {
setAppLockSettingsState(next);
}
return next ?? appLockSettings;
}, [appLockSettings]);
const requestAppLockPasswordChange = useCallback(async (input: {
currentPassword?: string;
nextPassword: string;
}) => {
const next = await netcattyBridge.get()?.requestAppLockPasswordChange?.(input);
if (next && !('ok' in next)) {
setAppLockSettingsState(next);
}
return next ?? appLockSettings;
}, [appLockSettings]);
const setAppLockSystemUnlockEnabled = useCallback(async (input: {
enabled: boolean;
currentPassword?: string;
autoPromptEnabled?: boolean;
}) => {
const next = await netcattyBridge.get()?.setAppLockSystemUnlockEnabled?.(input);
if (next && !('ok' in next)) {
setAppLockSettingsState(next);
}
return next ?? appLockSettings;
}, [appLockSettings]);
const syncAppearanceFromStorage = useCallback((incoming?: AppearanceSyncEvent) => {
const current = appearanceStateRef.current;
const nextAppearance = resolveAppearanceSyncState(
current,
{
theme: readStoredString(STORAGE_KEY_THEME),
lightUiThemeId: readStoredString(STORAGE_KEY_UI_THEME_LIGHT),
darkUiThemeId: readStoredString(STORAGE_KEY_UI_THEME_DARK),
accentMode: readStoredString(STORAGE_KEY_ACCENT_MODE),
customAccent: readStoredString(STORAGE_KEY_COLOR),
},
incoming,
);
const {
theme: nextTheme,
lightUiThemeId: nextLightId,
darkUiThemeId: nextDarkId,
accentMode: nextAccentMode,
customAccent: nextAccent,
customAccentVersion: nextAccentVersion,
} = nextAppearance;
// Fix 2: Skip expensive DOM operations if nothing actually changed
if (
nextTheme === current.theme &&
nextLightId === current.lightUiThemeId &&
nextDarkId === current.darkUiThemeId &&
nextAccentMode === current.accentMode &&
nextAccent === current.customAccent &&
nextAccentVersion === current.customAccentVersion
) {
return;
}
// Publish synchronously so a later keyed IPC in the same turn composes on top.
appearanceStateRef.current = nextAppearance;
if (
nextAccent !== current.customAccent
|| nextAccentVersion !== current.customAccentVersion
) {
// Mark incoming so the persist effect does not rebroadcast to the peer that
// originated this update (color-picker drag ping-pong; see #2743).
customAccentMutationSourceRef.current = 'incoming';
setCustomAccentRecord({ color: nextAccent, version: nextAccentVersion });
}
setTheme(nextTheme);
setLightUiThemeId(nextLightId);
setDarkUiThemeId(nextDarkId);
setAccentMode(nextAccentMode);
const effective = nextTheme === 'system' ? getSystemPreference() : nextTheme;
const tokens = getUiThemeById(effective, effective === 'dark' ? nextDarkId : nextLightId).tokens;
runThemeTransition(() => {
applyThemeTokens(nextTheme, effective, tokens, nextAccentMode, nextAccent);
});
}, []);
const syncCustomCssFromStorage = useCallback(() => {
const storedCss = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS) || '';
setCustomCSS((prev) => (prev === storedCss ? prev : storedCss));
applyCustomCssToDocument(storedCss);
}, []);
const rehydrateAllFromStorage = useCallback(() => {
// Theme & appearance (already have helper)
syncAppearanceFromStorage();
syncCustomCssFromStorage();
// UI Font
const storedFont = readStoredString(STORAGE_KEY_UI_FONT_FAMILY);
if (storedFont) setUiFontFamilyId(storedFont);
// Language
const storedLang = readStoredString(STORAGE_KEY_UI_LANGUAGE);
if (storedLang) setUiLanguage(storedLang as UILanguage);
void netcattyBridge.get()?.getAppLockSettings?.().then((next) => {
if (next) setAppLockSettingsState(next);
}).catch(() => {});
// Terminal
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
// Cloud sync writes follow-app via applySyncableSettings; without this the
// open window keeps the pre-sync flag while terminalThemeId updates, which
// flickers between the local default theme and the synced one (#2757).
const storedFollowAppTermTheme = readStoredString(STORAGE_KEY_TERM_FOLLOW_APP_THEME);
if (storedFollowAppTermTheme === 'true' || storedFollowAppTermTheme === 'false') {
setFollowAppTerminalThemeState(storedFollowAppTermTheme === 'true');
}
const storedTermThemeDark = readStoredString(STORAGE_KEY_TERM_THEME_DARK);
if (storedTermThemeDark) setTerminalThemeDarkId(storedTermThemeDark);
const storedTermThemeLight = readStoredString(STORAGE_KEY_TERM_THEME_LIGHT);
if (storedTermThemeLight) setTerminalThemeLightId(storedTermThemeLight);
const storedTermFont = readStoredString(STORAGE_KEY_TERM_FONT_FAMILY);
const migratedTermFont = migrateIncomingTerminalFontId(storedTermFont);
if (migratedTermFont) setTerminalFontFamilyId(migratedTermFont);
const storedTermSize = readStoredString(STORAGE_KEY_TERM_FONT_SIZE);
if (storedTermSize != null) applyIncomingTerminalFontSize(storedTermSize);
const storedTermSettings = readStoredString(STORAGE_KEY_TERM_SETTINGS);
if (storedTermSettings) {
try {
const parsed = JSON.parse(storedTermSettings);
setTerminalSettings(parsed);
} catch { /* ignore */ }
}
// Keyboard
const storedKb = parseCustomKeyBindingsStorageRecord(
localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS),
);
if (storedKb) {
applyIncomingCustomKeyBindings(storedKb);
}
// Editor
const storedWrap = readStoredString(STORAGE_KEY_EDITOR_WORD_WRAP);
if (storedWrap === 'true' || storedWrap === 'false') setEditorWordWrapState(storedWrap === 'true');
// SSH diagnostics
const storedSshDebugLogsEnabled = readStoredString(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED);
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);
const storedJmsDeepLinkEnabled = localStorageAdapter.readBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED);
applyIncomingJmsDeepLinkEnabled(storedJmsDeepLinkEnabled ?? DEFAULT_JMS_DEEP_LINK_ENABLED);
const storedExplorerContextMenuEnabled = localStorageAdapter.readBoolean(STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED);
applyIncomingExplorerContextMenuEnabled(storedExplorerContextMenuEnabled ?? DEFAULT_EXPLORER_CONTEXT_MENU_ENABLED);
// SFTP
const storedDblClick = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
if (storedDblClick === 'open' || storedDblClick === 'transfer') setSftpDoubleClickBehavior(storedDblClick);
const storedAutoSync = readStoredString(STORAGE_KEY_SFTP_AUTO_SYNC);
if (storedAutoSync === 'true' || storedAutoSync === 'false') setSftpAutoSync(storedAutoSync === 'true');
const storedHidden = readStoredString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
if (storedHidden === 'true' || storedHidden === 'false') setSftpShowHiddenFiles(storedHidden === 'true');
const storedCompress = readStoredString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
if (storedCompress === 'true' || storedCompress === 'false') setSftpUseCompressedUpload(storedCompress === 'true');
const storedSkipUnchanged = localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED);
if (storedSkipUnchanged != null) setSftpSkipUnchanged(storedSkipUnchanged);
const storedAutoOpenSidebar = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
const storedFollowTerminalCwd = readStoredString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD);
if (storedFollowTerminalCwd === 'true' || storedFollowTerminalCwd === 'false') {
setSftpFollowTerminalCwd(storedFollowTerminalCwd === 'true');
}
const storedDefaultViewMode = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
if (storedDefaultViewMode === 'list' || storedDefaultViewMode === 'tree') setSftpDefaultViewMode(storedDefaultViewMode);
const storedShowRecentHosts = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
setShowRecentHostsState(storedShowRecentHosts ?? DEFAULT_SHOW_RECENT_HOSTS);
const storedHostClickBehavior = readStoredString(STORAGE_KEY_HOST_CLICK_BEHAVIOR);
setHostClickBehaviorState(
isHostClickBehavior(storedHostClickBehavior) ? storedHostClickBehavior : DEFAULT_HOST_CLICK_BEHAVIOR,
);
const storedShowOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
setShowOnlyUngroupedHostsInRootState(storedShowOnlyUngroupedHostsInRoot ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
const storedShowSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
const storedShowHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
setShowHostTreeSidebarState(storedShowHostTreeSidebar ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR);
const storedTerminalSidePanelAutoOpen = localStorageAdapter.readBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN);
setTerminalSidePanelAutoOpenState(storedTerminalSidePanelAutoOpen ?? DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_ENABLED);
const storedTerminalSidePanelAutoOpenTab = readStoredString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB);
setTerminalSidePanelAutoOpenTabState(
isTerminalSidePanelAutoOpenTab(storedTerminalSidePanelAutoOpenTab)
? storedTerminalSidePanelAutoOpenTab
: DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
);
const storedShellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
setShellOnlyTabNumberShortcutsState(storedShellOnlyTabNumberShortcuts ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
const storedShowTabNumberBadges = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES);
setShowTabNumberBadgesState(storedShowTabNumberBadges ?? DEFAULT_SHOW_TAB_NUMBER_BADGES);
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));
setStartupLandingState(resolveStartupLandingSetting(readStoredString(STORAGE_KEY_STARTUP_LANDING)));
// Workspace focus style
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
if (storedFocusStyle === 'dim' || storedFocusStyle === 'border') setWorkspaceFocusStyleState(storedFocusStyle);
// App-level HTTP(S) network proxy
setHttpNetworkProxyState(
normalizeHttpNetworkProxySettings(
localStorageAdapter.read(STORAGE_KEY_HTTP_NETWORK_PROXY) ?? DEFAULT_HTTP_NETWORK_PROXY,
),
);
// Custom terminal themes
customThemeStore.loadFromStorage();
}, [applyIncomingCustomKeyBindings, applyIncomingExplorerContextMenuEnabled, applyIncomingJmsDeepLinkEnabled, applyIncomingSshDeepLinkEnabled, applyIncomingTerminalFontSize, syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
useLayoutEffect(() => {
const appearanceRender: AppearanceRenderSnapshot = {
theme,
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
accentMode,
customAccent,
customAccentVersion: customAccentRecord.version,
};
// Capture previous snapshot before overwrite so we can notify only the
// appearance fields that actually changed on this render.
const previousAppearance = previousAppearanceRenderRef.current;
const persistedAppearanceChanged = previousAppearance === null
|| hasPersistedAppearanceChanged(previousAppearance, appearanceRender);
previousAppearanceRenderRef.current = appearanceRender;
// Terminal leaves subscribe here so accent drag does not rebuild TerminalLayer.
publishAppearanceChromeSnapshot({ accentMode, customAccent });
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
const apply = () => applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
const transitionMode = appearanceTransitionModeRef.current;
appearanceTransitionModeRef.current = 'instant';
if (persistMountedRef.current) {
runThemeTransition(apply, { mode: transitionMode });
} else {
apply();
}
if (!persistedAppearanceChanged && persistMountedRef.current) return;
localStorageAdapter.writeString(STORAGE_KEY_THEME, theme);
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, accentMode);
// Never let a stale effect overwrite a newer revision already on disk.
const storedAccent = parseCustomAccentRecord(
localStorageAdapter.readString(STORAGE_KEY_COLOR),
);
if (
shouldApplyCustomAccentRecord(storedAccent, customAccentRecord)
|| storedAccent.version === customAccentRecord.version
) {
localStorageAdapter.writeString(
STORAGE_KEY_COLOR,
serializeCustomAccentRecord(customAccentRecord),
);
}
// Fix 1: Skip IPC broadcast on initial mount (values already match localStorage)
if (!persistMountedRef.current) return;
// Emit a keyed IPC notification for each changed appearance field so the
// receiver can apply the payload value even if shared storage is still stale.
// resolveAppearanceSyncState only trusts the announced key's value; other
// fields fall back to storage, so every changed field must be announced.
if (!previousAppearance || previousAppearance.theme !== theme) {
notifySettingsChanged(STORAGE_KEY_THEME, theme);
}
if (!previousAppearance || previousAppearance.lightUiThemeId !== lightUiThemeId) {
notifySettingsChanged(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
}
if (!previousAppearance || previousAppearance.darkUiThemeId !== darkUiThemeId) {
notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
}
if (!previousAppearance || previousAppearance.accentMode !== accentMode) {
notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode);
}
if (
!previousAppearance
|| previousAppearance.customAccent !== customAccent
|| previousAppearance.customAccentVersion !== customAccentRecord.version
) {
const decision = shouldBroadcastCustomAccentChange(
customAccentMutationSourceRef.current,
true,
);
customAccentMutationSourceRef.current = decision.nextSource;
if (decision.shouldBroadcast) {
notifySettingsChanged(STORAGE_KEY_COLOR, customAccentRecord);
}
}
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentRecord, notifySettingsChanged]);
// Listen for OS color scheme changes to keep systemPreference in sync
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return;
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
setSystemPreference(e.matches ? 'dark' : 'light');
};
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);
useLayoutEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
document.documentElement.lang = uiLanguage;
netcattyBridge.get()?.setLanguage?.(uiLanguage);
// Fix 1: Skip IPC broadcast on initial mount
if (persistMountedRef.current) {
notifySettingsChanged(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
}
}, [uiLanguage, notifySettingsChanged]);
// Apply and persist UI font family
// 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', withWindowsEmojiFallback(font.family));
localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
// Fix 1: Skip IPC broadcast on initial mount
if (persistMountedRef.current) {
notifySettingsChanged(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
}
}, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]);
// Persist product name
useLayoutEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_PRODUCT_NAME, productName);
// 同步到主进程 inspection-data.json,远程浏览器 API 可读
try {
const nc = (window as any).netcatty;
nc?.inspection?.saveProductName?.(productName).catch(() => {});
} catch {}
if (persistMountedRef.current) {
notifySettingsChanged(STORAGE_KEY_PRODUCT_NAME, productName);
// 更新窗口标题栏
const bridge = netcattyBridge.get();
if (bridge?.setWindowTitle) {
bridge.setWindowTitle(productName).then((result: any) => {
console.log('[productName] setWindowTitle result:', result);
}).catch((err: any) => {
console.log('[productName] setWindowTitle error:', err);
});
}
}
// 发送自定义事件通知侧边栏更新
window.dispatchEvent(new CustomEvent('productNameChanged', { detail: productName }));
}, [productName, notifySettingsChanged]);
useSettingsIpcSync({
enabled: enableSettingsSync,
syncAppearanceFromStorage,
syncCustomCssFromStorage,
setUiLanguage,
setUiFontFamilyId,
setProductName,
setTerminalThemeId,
setTerminalThemeDarkId,
setTerminalThemeLightId,
setFollowAppTerminalThemeState,
setTerminalFontFamilyId,
setTerminalFontSize: applyIncomingTerminalFontSize,
mergeIncomingTerminalSettings,
setEditorWordWrapState,
setSessionLogsEnabled,
setSessionLogsDir,
setSessionLogsFormat,
setSessionLogsTimestampsEnabled,
setSshDebugLogsEnabled,
setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled,
setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled,
setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
setHotkeyScheme,
applyIncomingCustomKeyBindings,
setIsHotkeyRecordingState,
setGlobalHotkeyEnabled,
setWindowOpacity: applyIncomingWindowOpacity,
setAppIconVariant,
setAutoUpdateEnabled,
setAutoLaunchEnabled,
setHttpNetworkProxy,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
setStartupLandingState,
setSftpTransferConcurrencyState,
setSshTransportIdleTtlMsState,
});
useEffect(() => {
if (!enableSettingsSync) return;
const bridge = netcattyBridge.get();
if (!bridge?.onLanguageChanged) return;
const unsubscribe = bridge.onLanguageChanged((language) => {
if (typeof language !== 'string' || !language.length) return;
const next = resolveSupportedLocale(language);
setUiLanguage((prev) => (prev === next ? prev : next));
});
return () => {
try {
unsubscribe?.();
} catch {
// ignore
}
};
}, [enableSettingsSync]);
useEffect(() => {
const bridge = netcattyBridge.get();
let sawPushedSettings = false;
const unsubscribe = bridge?.onAppLockSettingsChanged?.((next) => {
sawPushedSettings = true;
setAppLockSettingsState(next);
}) ?? (() => {});
void bridge?.getAppLockSettings?.().then((next) => {
if (!next || sawPushedSettings) return;
setAppLockSettingsState(next);
}).catch(() => {});
return () => {
unsubscribe();
};
}, []);
useSettingsStorageSync({
enabled: enableSettingsSync,
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
customAccentVersion: customAccentRecord.version,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
applyIncomingCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
});
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_THEME, terminalThemeId);
}, [terminalThemeId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(followAppTerminalTheme));
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(followAppTerminalTheme));
}, [followAppTerminalTheme, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_DARK, terminalThemeDarkId);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_THEME_DARK, terminalThemeDarkId);
}, [terminalThemeDarkId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_LIGHT, terminalThemeLightId);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_THEME_LIGHT, terminalThemeLightId);
}, [terminalThemeLightId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
}, [terminalFontFamilyId, notifySettingsChanged]);
useEffect(() => {
const resolution = resolveAuthoritativeTerminalFontSizeStorage(
terminalFontSizeRecordRef,
localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_SIZE),
);
// If another window already published a newer winner, adopt it instead of
// displaying and broadcasting a local value that peers will reject.
if (resolution.shouldAdopt) {
terminalFontSizeMutationSourceRef.current = 'incoming';
terminalFontSizeRecordRef.current = resolution.record;
setTerminalFontSizeRecord(resolution.record);
return;
}
if (resolution.shouldPersist) {
localStorageAdapter.writeString(
STORAGE_KEY_TERM_FONT_SIZE,
resolution.serializedRecord,
);
}
const decision = shouldBroadcastTerminalFontSizeChange(
terminalFontSizeMutationSourceRef.current,
persistMountedRef.current,
);
terminalFontSizeMutationSourceRef.current = decision.nextSource;
if (!decision.shouldBroadcast) return;
notifySettingsChanged(STORAGE_KEY_TERM_FONT_SIZE, resolution.record);
}, [terminalFontSizeRecord, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
if (!persistMountedRef.current) return;
const currentSignature = serializeTerminalSettings(terminalSettings);
const hasPendingUnbroadcastLocalChanges =
localTerminalSettingsVersionRef.current !== broadcastedLocalTerminalSettingsVersionRef.current;
if (incomingTerminalSettingsSignatureRef.current === currentSignature && !hasPendingUnbroadcastLocalChanges) {
incomingTerminalSettingsSignatureRef.current = null;
return;
}
incomingTerminalSettingsSignatureRef.current = null;
notifySettingsChanged(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
broadcastedLocalTerminalSettingsVersionRef.current = localTerminalSettingsVersionRef.current;
}, [terminalSettings, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
}, [hotkeyScheme, notifySettingsChanged]);
useEffect(() => {
const payload = serializeCustomKeyBindingsStorageRecord({
version: customKeyBindingsVersionRef.current,
origin: customKeyBindingsOriginRef.current,
bindings: customKeyBindings,
});
if (localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS) !== payload) {
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_KEY_BINDINGS, payload);
}
if (!persistMountedRef.current) return;
if (customKeyBindingsMutationSourceRef.current === 'incoming') return;
notifySettingsChanged(STORAGE_KEY_CUSTOM_KEY_BINDINGS, {
version: customKeyBindingsVersionRef.current,
origin: customKeyBindingsOriginRef.current,
bindings: customKeyBindings,
});
}, [customKeyBindings, notifySettingsChanged]);
const setIsHotkeyRecording = useCallback((isRecording: boolean) => {
setIsHotkeyRecordingState(isRecording);
notifySettingsChanged(STORAGE_KEY_HOTKEY_RECORDING, isRecording);
}, [notifySettingsChanged]);
const setShowRecentHosts = useCallback((enabled: boolean) => {
setShowRecentHostsState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SHOW_RECENT_HOSTS, enabled);
}, [notifySettingsChanged]);
const setHostClickBehavior = useCallback((behavior: HostClickBehavior) => {
setHostClickBehaviorState(behavior);
localStorageAdapter.writeString(STORAGE_KEY_HOST_CLICK_BEHAVIOR, behavior);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_HOST_CLICK_BEHAVIOR, behavior);
}, [notifySettingsChanged]);
const setShowOnlyUngroupedHostsInRoot = useCallback((enabled: boolean) => {
setShowOnlyUngroupedHostsInRootState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT, enabled);
}, [notifySettingsChanged]);
const setShowSftpTab = useCallback((enabled: boolean) => {
setShowSftpTabState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
}, [notifySettingsChanged]);
const setShowHostTreeSidebar = useCallback((enabled: boolean) => {
setShowHostTreeSidebarState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, enabled);
}, [notifySettingsChanged]);
const setTerminalSidePanelAutoOpen = useCallback((enabled: boolean) => {
setTerminalSidePanelAutoOpenState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, enabled);
}, [notifySettingsChanged]);
const setTerminalSidePanelAutoOpenTab = useCallback((tab: TerminalSidePanelAutoOpenTab) => {
const next = isTerminalSidePanelAutoOpenTab(tab) ? tab : DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB;
setTerminalSidePanelAutoOpenTabState(next);
localStorageAdapter.writeString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, next);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, next);
}, [notifySettingsChanged]);
const setShellOnlyTabNumberShortcuts = useCallback((enabled: boolean) => {
setShellOnlyTabNumberShortcutsState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
}, [notifySettingsChanged]);
const setShowTabNumberBadges = useCallback((enabled: boolean) => {
setShowTabNumberBadgesState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES, enabled);
}, [notifySettingsChanged]);
const setDisableTerminalFontZoom = useCallback((enabled: boolean) => {
setDisableTerminalFontZoomState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, enabled);
if (!persistMountedRef.current) return;
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]);
const setStartupLanding = useCallback((landing: StartupLanding) => {
setStartupLandingState(landing);
localStorageAdapter.writeString(STORAGE_KEY_STARTUP_LANDING, landing);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_STARTUP_LANDING, landing);
}, [notifySettingsChanged]);
// Apply and persist custom CSS
useEffect(() => {
applyCustomCssToDocument(customCSS);
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
// Skip IPC on initial mount
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_CUSTOM_CSS, customCSS);
}, [customCSS, notifySettingsChanged]);
// Persist SFTP double-click behavior
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
}, [sftpDoubleClickBehavior, notifySettingsChanged]);
// Persist SFTP auto-sync setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync);
}, [sftpAutoSync, notifySettingsChanged]);
// Persist SFTP show hidden files setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles);
}, [sftpShowHiddenFiles, notifySettingsChanged]);
// Persist SFTP compressed upload setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload);
}, [sftpUseCompressedUpload, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED, sftpSkipUnchanged);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_SKIP_UNCHANGED, sftpSkipUnchanged);
}, [sftpSkipUnchanged, notifySettingsChanged]);
// Persist SFTP auto-open sidebar setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar);
}, [sftpAutoOpenSidebar, notifySettingsChanged]);
// Persist SFTP follow terminal cwd setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD, sftpFollowTerminalCwd ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD, sftpFollowTerminalCwd);
}, [sftpFollowTerminalCwd, notifySettingsChanged]);
// Persist SFTP default view mode
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, sftpDefaultViewMode);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, sftpDefaultViewMode);
}, [sftpDefaultViewMode, notifySettingsChanged]);
// Persist Session Logs settings
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled);
}, [sessionLogsEnabled, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
}, [sessionLogsDir, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
}, [sessionLogsFormat, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED, sessionLogsTimestampsEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED, sessionLogsTimestampsEnabled);
}, [sessionLogsTimestampsEnabled, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED, sshDebugLogsEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
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]);
useEffect(() => {
jmsDeepLinkEnabledRef.current = jmsDeepLinkEnabled;
}, [jmsDeepLinkEnabled]);
useEffect(() => {
let cancelled = false;
const requestIdAtStart = jmsDeepLinkSetRequestIdRef.current;
const bridge = netcattyBridge.get();
if (!bridge?.getJmsDeepLinkEnabled) return;
void bridge.getJmsDeepLinkEnabled().then((enabled) => {
if (cancelled || typeof enabled !== 'boolean') return;
if (jmsDeepLinkSetRequestIdRef.current !== requestIdAtStart) return;
jmsDeepLinkMutationSourceRef.current = 'incoming';
setJmsDeepLinkEnabledState((prev) => (prev === enabled ? prev : enabled));
localStorageAdapter.writeBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, enabled);
}).catch(() => {
// The renderer can still use its cached setting when the bridge is unavailable.
});
return () => {
cancelled = true;
};
}, []);
const setJmsDeepLinkEnabled = useCallback((enabled: boolean) => {
const previous = jmsDeepLinkEnabledRef.current;
const requestId = jmsDeepLinkSetRequestIdRef.current + 1;
jmsDeepLinkSetRequestIdRef.current = requestId;
jmsDeepLinkMutationSourceRef.current = 'local';
setJmsDeepLinkEnabledState(enabled);
const bridge = netcattyBridge.get();
if (!bridge?.setJmsDeepLinkEnabled) return;
void bridge.setJmsDeepLinkEnabled(enabled).then((result) => {
if (jmsDeepLinkSetRequestIdRef.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;
jmsDeepLinkMutationSourceRef.current = 'incoming';
setJmsDeepLinkEnabledState(finalEnabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, finalEnabled);
}).catch(() => {
if (jmsDeepLinkSetRequestIdRef.current !== requestId) return;
jmsDeepLinkMutationSourceRef.current = 'incoming';
setJmsDeepLinkEnabledState(previous);
localStorageAdapter.writeBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, previous);
});
}, []);
useEffect(() => {
localStorageAdapter.writeBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, jmsDeepLinkEnabled);
if (jmsDeepLinkMutationSourceRef.current === 'incoming') {
jmsDeepLinkMutationSourceRef.current = 'local';
return;
}
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, jmsDeepLinkEnabled);
}, [jmsDeepLinkEnabled, notifySettingsChanged]);
useEffect(() => {
explorerContextMenuEnabledRef.current = explorerContextMenuEnabled;
}, [explorerContextMenuEnabled]);
useEffect(() => {
let cancelled = false;
const requestIdAtStart = explorerContextMenuSetRequestIdRef.current;
const bridge = netcattyBridge.get();
if (!bridge?.getExplorerContextMenuEnabled) return;
void bridge.getExplorerContextMenuEnabled().then((result) => {
if (cancelled) return;
if (explorerContextMenuSetRequestIdRef.current !== requestIdAtStart) return;
const enabled = typeof result === 'object' && result
? result.enabled
: result;
const supported = typeof result === 'object' && result
? result.supported === true
: false;
setExplorerContextMenuSupported(supported);
if (typeof enabled !== 'boolean') return;
explorerContextMenuMutationSourceRef.current = 'incoming';
setExplorerContextMenuEnabledState((prev) => (prev === enabled ? prev : enabled));
localStorageAdapter.writeBoolean(STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED, enabled);
}).catch(() => {
// Keep the cached renderer value when the bridge is unavailable.
});
return () => {
cancelled = true;
};
}, []);
const setExplorerContextMenuEnabled = useCallback((enabled: boolean) => {
const previous = explorerContextMenuEnabledRef.current;
const requestId = explorerContextMenuSetRequestIdRef.current + 1;
explorerContextMenuSetRequestIdRef.current = requestId;
explorerContextMenuMutationSourceRef.current = 'local';
setExplorerContextMenuEnabledState(enabled);
const bridge = netcattyBridge.get();
if (!bridge?.setExplorerContextMenuEnabled) return;
void bridge.setExplorerContextMenuEnabled(enabled).then((result) => {
if (explorerContextMenuSetRequestIdRef.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;
explorerContextMenuMutationSourceRef.current = 'incoming';
setExplorerContextMenuEnabledState(finalEnabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED, finalEnabled);
}).catch(() => {
if (explorerContextMenuSetRequestIdRef.current !== requestId) return;
explorerContextMenuMutationSourceRef.current = 'incoming';
setExplorerContextMenuEnabledState(previous);
localStorageAdapter.writeBoolean(STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED, previous);
});
}, []);
useEffect(() => {
localStorageAdapter.writeBoolean(STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED, explorerContextMenuEnabled);
if (explorerContextMenuMutationSourceRef.current === 'incoming') {
explorerContextMenuMutationSourceRef.current = 'local';
return;
}
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED, explorerContextMenuEnabled);
}, [explorerContextMenuEnabled, notifySettingsChanged]);
useSystemSettingsEffects({
enabled: enableSystemEffects,
toggleWindowHotkey,
globalHotkeyEnabled,
closeToTray,
autoLaunchEnabled,
windowOpacityRecord,
windowOpacityMutationSourceRef,
appIconVariant,
autoUpdateEnabled,
httpNetworkProxy,
persistMountedRef,
setHotkeyRegistrationError,
setAutoUpdateEnabled,
setAppIconVariant,
setAutoLaunchEnabled,
setAutoLaunchSupported,
notifySettingsChanged,
});
// Mark persist effects mounted AFTER all persist useEffects so React runs
// this last (declaration order). Cleanup clears the latch for remount.
useEffect(() => {
persistMountedRef.current = true;
return () => {
persistMountedRef.current = false;
};
}, []);
// Get merged key bindings (defaults + custom overrides)
const keyBindings = useMemo((): KeyBinding[] => {
return DEFAULT_KEY_BINDINGS.map(binding => {
const custom = customKeyBindings[binding.id];
if (!custom) return binding;
return {
...binding,
mac: custom.mac ?? binding.mac,
pc: custom.pc ?? binding.pc,
};
});
}, [customKeyBindings]);
// Update a single key binding
const updateKeyBinding = useCallback((bindingId: string, scheme: 'mac' | 'pc', newKey: string) => {
setCustomKeyBindings(prev => updateCustomKeyBindingRecord(prev, bindingId, scheme, newKey));
}, [setCustomKeyBindings]);
// Reset a key binding to default
const resetKeyBinding = useCallback((bindingId: string, scheme?: 'mac' | 'pc') => {
setCustomKeyBindings(prev => resetCustomKeyBinding(prev, bindingId, scheme));
}, [setCustomKeyBindings]);
// Reset all key bindings to defaults
const resetAllKeyBindings = useCallback(() => {
setCustomKeyBindings({});
}, [setCustomKeyBindings]);
const updateSyncConfig = useCallback((config: SyncConfig | null) => {
setSyncConfig(config);
localStorageAdapter.write(STORAGE_KEY_SYNC, config);
}, []);
// Subscribe to custom theme changes so editing in-place triggers re-render
const customThemes = useCustomThemes();
const settledTerminalTheme = useMemo(() => resolveGlobalTerminalAppearance({
userIntent: idleThemeUserIntent(),
settings: {
terminalThemeId,
terminalThemeDarkId,
terminalThemeLightId,
followAppTerminalTheme,
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
accentMode,
customAccent,
},
customThemes,
}).theme, [terminalThemeId, terminalThemeDarkId, terminalThemeLightId, customThemes,
followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId,
accentMode, customAccent]);
const currentTerminalTheme = settledTerminalTheme;
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
key: K,
value: TerminalSettings[K]
) => {
setTerminalSettings(prev => ({ ...prev, [key]: value }));
}, [setTerminalSettings]);
const applyAppTheme = useCallback(() => {
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
const setEditorWordWrap = useCallback((enabled: boolean) => {
setEditorWordWrapState(enabled);
localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(enabled));
notifySettingsChanged(STORAGE_KEY_EDITOR_WORD_WRAP, enabled);
}, [notifySettingsChanged]);
// Main-window chrome subscribes to this slice instead of receiving the whole
// settings object through the App domain bags. The store dedupes field by
// field, so unrelated settings churn never notifies chrome consumers.
useLayoutEffect(() => {
publishSettingsChromeSnapshot({
theme,
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
uiLanguage,
windowOpacity,
showSftpTab,
showHostTreeSidebar,
showRecentHosts,
hostClickBehavior,
showOnlyUngroupedHostsInRoot,
dynamicTabTitleMode: terminalSettings.dynamicTabTitleMode,
disableTerminalFontZoom,
hotkeyScheme,
shellOnlyTabNumberShortcuts,
showTabNumberBadges,
restoreTerminalCwd,
terminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
});
}, [
darkUiThemeId,
disableTerminalFontZoom,
hostClickBehavior,
hotkeyScheme,
lightUiThemeId,
resolvedTheme,
restoreTerminalCwd,
shellOnlyTabNumberShortcuts,
showHostTreeSidebar,
showOnlyUngroupedHostsInRoot,
showRecentHosts,
showSftpTab,
showTabNumberBadges,
terminalSettings.dynamicTabTitleMode,
terminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
theme,
uiLanguage,
windowOpacity,
]);
useLayoutEffect(() => {
registerSettingsChromeActions({ setTheme, setWindowOpacity });
return () => {
registerSettingsChromeActions(null);
};
}, [setTheme, setWindowOpacity]);
// TerminalHost / terminal domain bags subscribe here instead of receiving
// settings through the App mega-subscriber.
useLayoutEffect(() => {
publishTerminalSettingsSnapshot({
terminalThemeId,
terminalThemeDarkId,
terminalThemeLightId,
followAppTerminalTheme,
terminalFontFamilyId,
terminalFontSize,
terminalSettings,
hotkeyScheme,
keyBindings,
isHotkeyRecording,
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sftpAutoOpenSidebar,
sftpFollowTerminalCwd,
sftpDefaultViewMode,
editorWordWrap,
sessionLogsEnabled,
sessionLogsDir,
sessionLogsFormat,
sessionLogsTimestampsEnabled,
sshDebugLogsEnabled,
});
}, [
editorWordWrap,
followAppTerminalTheme,
hotkeyScheme,
isHotkeyRecording,
keyBindings,
sessionLogsDir,
sessionLogsEnabled,
sessionLogsFormat,
sessionLogsTimestampsEnabled,
sftpAutoOpenSidebar,
sftpAutoSync,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
sftpFollowTerminalCwd,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sshDebugLogsEnabled,
terminalFontFamilyId,
terminalFontSize,
terminalSettings,
terminalThemeDarkId,
terminalThemeId,
terminalThemeLightId,
]);
useLayoutEffect(() => {
registerTerminalSettingsActions({
setTerminalThemeId,
setTerminalThemeDarkId,
setTerminalThemeLightId,
setFollowAppTerminalTheme: setFollowAppTerminalThemeState,
setTerminalFontFamilyId,
setTerminalFontSize,
updateTerminalSetting,
setSftpFollowTerminalCwd,
setEditorWordWrap,
applyAppTheme,
});
return () => {
registerTerminalSettingsActions(null);
};
}, [
applyAppTheme,
setEditorWordWrap,
setSftpFollowTerminalCwd,
setTerminalFontFamilyId,
setTerminalFontSize,
setTerminalThemeDarkId,
setTerminalThemeId,
setTerminalThemeLightId,
updateTerminalSetting,
]);
// 远程浏览器改了设置 → 主进程 broadcast → preload 更新 localStorage 并 dispatch 此事件
// 这里监听并全量刷新 React state,解决 useState initializer 只跑一次的问题
useEffect(() => {
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
if (IS_REMOTE) return; // 远程浏览器不通过 preload,由 VaultViewLayout 处理
const handler = (_e: Event) => {
rehydrateAllFromStorage();
};
window.addEventListener("netcatty-settings-batch", handler);
return () => window.removeEventListener("netcatty-settings-batch", handler);
}, [rehydrateAllFromStorage]);
return {
theme,
setTheme,
resolvedTheme,
lightUiThemeId,
setLightUiThemeId,
darkUiThemeId,
setDarkUiThemeId,
accentMode,
setAccentMode,
customAccent,
setCustomAccent,
uiFontFamilyId,
setUiFontFamilyId,
productName,
setProductName,
syncConfig,
updateSyncConfig,
uiLanguage,
setUiLanguage,
appLockSettings,
setAppLockTimeoutMinutes,
requestAppLockEnable,
requestAppLockDisable,
requestAppLockPasswordChange,
setAppLockSystemUnlockEnabled,
terminalThemeId,
setTerminalThemeId,
followAppTerminalTheme,
setFollowAppTerminalTheme: setFollowAppTerminalThemeState,
terminalThemeDarkId,
setTerminalThemeDarkId,
terminalThemeLightId,
setTerminalThemeLightId,
currentTerminalTheme,
terminalFontFamilyId,
setTerminalFontFamilyId,
terminalFontSize,
setTerminalFontSize,
terminalSettings,
setTerminalSettings,
updateTerminalSetting,
hotkeyScheme,
setHotkeyScheme,
keyBindings,
customKeyBindings,
updateKeyBinding,
resetKeyBinding,
resetAllKeyBindings,
isHotkeyRecording,
setIsHotkeyRecording,
customCSS,
setCustomCSS,
sftpDoubleClickBehavior,
setSftpDoubleClickBehavior,
sftpAutoSync,
setSftpAutoSync,
sftpShowHiddenFiles,
setSftpShowHiddenFiles,
sftpUseCompressedUpload,
setSftpUseCompressedUpload,
sftpSkipUnchanged,
setSftpSkipUnchanged,
sftpAutoOpenSidebar,
setSftpAutoOpenSidebar,
sftpFollowTerminalCwd,
setSftpFollowTerminalCwd,
sftpDefaultViewMode,
setSftpDefaultViewMode,
showRecentHosts,
setShowRecentHosts,
hostClickBehavior,
setHostClickBehavior,
showOnlyUngroupedHostsInRoot,
setShowOnlyUngroupedHostsInRoot,
showSftpTab,
setShowSftpTab,
showHostTreeSidebar,
setShowHostTreeSidebar,
terminalSidePanelAutoOpen,
setTerminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
setTerminalSidePanelAutoOpenTab,
shellOnlyTabNumberShortcuts,
setShellOnlyTabNumberShortcuts,
showTabNumberBadges,
setShowTabNumberBadges,
disableTerminalFontZoom,
setDisableTerminalFontZoom,
restorePreviousSession,
setRestorePreviousSession,
restoreTerminalCwd,
setRestoreTerminalCwd,
startupLanding,
setStartupLanding,
sftpTransferConcurrency,
setSftpTransferConcurrency,
sshTransportIdleTtlMs,
setSshTransportIdleTtlMs,
// Editor Settings
editorWordWrap,
setEditorWordWrap,
// Session Logs
sessionLogsEnabled,
setSessionLogsEnabled,
sessionLogsDir,
setSessionLogsDir,
sessionLogsFormat,
setSessionLogsFormat,
sessionLogsTimestampsEnabled,
setSessionLogsTimestampsEnabled,
sshDebugLogsEnabled,
setSshDebugLogsEnabled,
sshDeepLinkEnabled,
setSshDeepLinkEnabled,
jmsDeepLinkEnabled,
setJmsDeepLinkEnabled,
explorerContextMenuEnabled,
setExplorerContextMenuEnabled,
explorerContextMenuSupported,
// Global Toggle Window (Quake Mode)
toggleWindowHotkey,
setToggleWindowHotkey,
closeToTray,
setCloseToTray,
autoLaunchEnabled,
setAutoLaunchEnabled,
autoLaunchSupported,
httpNetworkProxy,
setHttpNetworkProxy,
autoUpdateEnabled,
setAutoUpdateEnabled,
hotkeyRegistrationError,
globalHotkeyEnabled,
setGlobalHotkeyEnabled,
windowOpacity,
setWindowOpacity,
appIconVariant,
setAppIconVariant,
rehydrateAllFromStorage,
applyAppTheme,
workspaceFocusStyle,
setWorkspaceFocusStyle,
// Opaque version that changes when any synced setting changes, used by useAutoSync.
// eslint-disable-next-line react-hooks/exhaustive-deps
settingsVersion: useMemo(() => Math.random(), [
theme, lightUiThemeId, darkUiThemeId, accentMode,
uiFontFamilyId, uiLanguage, customCSS,
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
]),
};
};