Files
Netcatty/application/state/useSettingsState.ts
T
bincxz e87ce831b4 refactor(compressedUpload): simplify to auto-detect with toggle setting
- Replace 3-option setting (ask/enabled/disabled) with boolean toggle
- Remove CompressedUploadDialog component - no more user prompts
- Auto-detect tar availability and silently fallback to regular upload
- Apply compressed upload setting to drag-and-drop uploads
- Fix upload progress updates for compressed uploads via callback
- Add i18n for upload phase labels (compressing/uploading/extracting)
- Fix empty folder handling - fallback to regular mkdir
- Preserve error message on failed upload tasks for UI display
- Fix fallback logic to only re-upload failed folders, not successful ones
- Handle cancellation during all phases (compression/transfer/extraction)
- Fix filename display to use explicit phase markers instead of substring match
- Fix Toggle onChange prop for settings to work correctly
- Use DropEntry.relativePath for correct drag-drop folder paths
- Dynamic extraction timeout based on archive size (60s base + 30s per 10MB)

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 14:35:47 +08:00

707 lines
29 KiB
TypeScript

import { useCallback,useEffect,useLayoutEffect,useMemo,useState } from 'react';
import { SyncConfig, TerminalSettings, DEFAULT_TERMINAL_SETTINGS, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat } from '../../domain/models';
import {
STORAGE_KEY_COLOR,
STORAGE_KEY_SYNC,
STORAGE_KEY_TERM_THEME,
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_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_FORMAT,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
import { UI_FONTS, DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
import { useAvailableFonts } from './fontStore';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
const DEFAULT_THEME: 'light' | 'dark' = 'light';
const DEFAULT_LIGHT_UI_THEME = 'snow';
const DEFAULT_DARK_UI_THEME = 'midnight';
const DEFAULT_ACCENT_MODE: 'theme' | 'custom' = 'theme';
const DEFAULT_CUSTOM_ACCENT = '221.2 83.2% 53.3%';
const DEFAULT_TERMINAL_THEME = 'netcatty-dark';
const DEFAULT_FONT_FAMILY = 'menlo';
// Auto-detect default hotkey scheme based on platform
const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
? 'mac'
: 'pc';
const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
const DEFAULT_SFTP_AUTO_SYNC = false;
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
// Session Logs defaults
const DEFAULT_SESSION_LOGS_ENABLED = false;
const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
const readStoredString = (key: string): string | null => {
const raw = localStorageAdapter.readString(key);
if (!raw) return null;
const trimmed = raw.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed);
return typeof parsed === 'string' ? parsed : trimmed;
} catch {
return trimmed;
}
};
const isValidTheme = (value: unknown): value is 'light' | 'dark' => value === 'light' || value === 'dark';
const isValidHslToken = (value: string): boolean => {
// Expect: "<h> <s>% <l>%", e.g. "221.2 83.2% 53.3%"
return /^\s*\d+(\.\d+)?\s+\d+(\.\d+)?%\s+\d+(\.\d+)?%\s*$/.test(value);
};
const isValidUiThemeId = (theme: 'light' | 'dark', value: string): boolean => {
const list = theme === 'dark' ? DARK_UI_THEMES : LIGHT_UI_THEMES;
return list.some((preset) => preset.id === value);
};
const isValidUiFontId = (value: string): boolean => {
// Local fonts are always considered valid
if (value.startsWith('local-')) return true;
// Check bundled fonts first, then check dynamically loaded fonts
return UI_FONTS.some((font) => font.id === value) ||
uiFontStore.getAvailableFonts().some((font) => font.id === value);
};
const applyThemeTokens = (
theme: 'light' | 'dark',
tokens: UiThemeTokens,
accentMode: 'theme' | 'custom',
accentOverride: string,
) => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(theme);
root.style.setProperty('--background', tokens.background);
root.style.setProperty('--foreground', tokens.foreground);
root.style.setProperty('--card', tokens.card);
root.style.setProperty('--card-foreground', tokens.cardForeground);
root.style.setProperty('--popover', tokens.popover);
root.style.setProperty('--popover-foreground', tokens.popoverForeground);
const accentToken = accentMode === 'custom' ? accentOverride : tokens.accent;
const accentLightness = parseFloat(accentToken.split(/\s+/)[2]?.replace('%', '') || '');
const computedAccentForeground = theme === 'dark'
? '220 40% 96%'
: (!Number.isNaN(accentLightness) && accentLightness < 55 ? '0 0% 98%' : '222 47% 12%');
root.style.setProperty('--primary', accentToken);
root.style.setProperty('--primary-foreground', accentMode === 'custom' ? computedAccentForeground : tokens.primaryForeground);
root.style.setProperty('--secondary', tokens.secondary);
root.style.setProperty('--secondary-foreground', tokens.secondaryForeground);
root.style.setProperty('--muted', tokens.muted);
root.style.setProperty('--muted-foreground', tokens.mutedForeground);
root.style.setProperty('--accent', accentToken);
root.style.setProperty('--accent-foreground', accentMode === 'custom' ? computedAccentForeground : tokens.accentForeground);
root.style.setProperty('--destructive', tokens.destructive);
root.style.setProperty('--destructive-foreground', tokens.destructiveForeground);
root.style.setProperty('--border', tokens.border);
root.style.setProperty('--input', tokens.input);
root.style.setProperty('--ring', accentToken);
// Sync with native window title bar (Electron)
netcattyBridge.get()?.setTheme?.(theme);
netcattyBridge.get()?.setBackgroundColor?.(tokens.background);
};
export const useSettingsState = () => {
const availableFonts = useAvailableFonts();
const uiFontsLoaded = useUIFontsLoaded();
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
const stored = readStoredString(STORAGE_KEY_THEME);
return stored && isValidTheme(stored) ? stored : DEFAULT_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 [customAccent, setCustomAccent] = useState<string>(() => {
const stored = readStoredString(STORAGE_KEY_COLOR);
return stored && isValidHslToken(stored) ? stored.trim() : DEFAULT_CUSTOM_ACCENT;
});
const [accentMode, setAccentMode] = useState<'theme' | 'custom'>(() => {
const stored = readStoredString(STORAGE_KEY_ACCENT_MODE);
if (stored === 'theme' || stored === 'custom') return stored;
const legacyColor = readStoredString(STORAGE_KEY_COLOR);
return legacyColor && isValidHslToken(legacyColor) ? 'custom' : 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;
});
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 [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY) || DEFAULT_FONT_FAMILY);
const [terminalFontSize, setTerminalFontSize] = useState<number>(() => localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE) || DEFAULT_FONT_SIZE);
const [uiLanguage, setUiLanguage] = useState<UILanguage>(() => {
const stored = readStoredString(STORAGE_KEY_UI_LANGUAGE);
return resolveSupportedLocale(stored || DEFAULT_UI_LOCALE);
});
const [terminalSettings, setTerminalSettings] = useState<TerminalSettings>(() => {
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
return stored ? { ...DEFAULT_TERMINAL_SETTINGS, ...stored } : DEFAULT_TERMINAL_SETTINGS;
});
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, setCustomKeyBindings] = useState<CustomKeyBindings>(() =>
localStorageAdapter.read<CustomKeyBindings>(STORAGE_KEY_CUSTOM_KEY_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;
});
// 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;
});
// Helper to notify other windows about settings changes via IPC
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
try {
netcattyBridge.get()?.notifySettingsChanged?.({ key, value });
} catch {
// ignore - bridge may not be available
}
}, []);
const syncAppearanceFromStorage = useCallback(() => {
const storedTheme = readStoredString(STORAGE_KEY_THEME);
const nextTheme = storedTheme && isValidTheme(storedTheme) ? storedTheme : theme;
const storedLightId = readStoredString(STORAGE_KEY_UI_THEME_LIGHT);
const nextLightId = storedLightId && isValidUiThemeId('light', storedLightId) ? storedLightId : lightUiThemeId;
const storedDarkId = readStoredString(STORAGE_KEY_UI_THEME_DARK);
const nextDarkId = storedDarkId && isValidUiThemeId('dark', storedDarkId) ? storedDarkId : darkUiThemeId;
const storedAccentMode = readStoredString(STORAGE_KEY_ACCENT_MODE);
const nextAccentMode = storedAccentMode === 'theme' || storedAccentMode === 'custom' ? storedAccentMode : accentMode;
const storedAccent = readStoredString(STORAGE_KEY_COLOR);
const nextAccent = storedAccent && isValidHslToken(storedAccent) ? storedAccent.trim() : customAccent;
setTheme(nextTheme);
setLightUiThemeId(nextLightId);
setDarkUiThemeId(nextDarkId);
setAccentMode(nextAccentMode);
setCustomAccent(nextAccent);
const tokens = getUiThemeById(nextTheme, nextTheme === 'dark' ? nextDarkId : nextLightId).tokens;
applyThemeTokens(nextTheme, tokens, nextAccentMode, nextAccent);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
const syncCustomCssFromStorage = useCallback(() => {
const storedCss = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS) || '';
setCustomCSS((prev) => (prev === storedCss ? prev : storedCss));
}, []);
useLayoutEffect(() => {
const tokens = getUiThemeById(theme, theme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
applyThemeTokens(theme, tokens, accentMode, customAccent);
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);
localStorageAdapter.writeString(STORAGE_KEY_COLOR, customAccent);
// Notify other windows
notifySettingsChanged(STORAGE_KEY_THEME, theme);
notifySettingsChanged(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode);
notifySettingsChanged(STORAGE_KEY_COLOR, customAccent);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, notifySettingsChanged]);
useLayoutEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
document.documentElement.lang = uiLanguage;
netcattyBridge.get()?.setLanguage?.(uiLanguage);
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', font.family);
localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
notifySettingsChanged(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
}, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]);
// Listen for settings changes from other windows via IPC
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onSettingsChanged) return;
const unsubscribe = bridge.onSettingsChanged((payload) => {
const { key, value } = payload;
if (
key === STORAGE_KEY_THEME ||
key === STORAGE_KEY_UI_THEME_LIGHT ||
key === STORAGE_KEY_UI_THEME_DARK ||
key === STORAGE_KEY_ACCENT_MODE ||
key === STORAGE_KEY_COLOR
) {
syncAppearanceFromStorage();
return;
}
if (key === STORAGE_KEY_UI_LANGUAGE && typeof value === 'string') {
const next = resolveSupportedLocale(value);
setUiLanguage((prev) => (prev === next ? prev : next));
document.documentElement.lang = next;
}
if (key === STORAGE_KEY_CUSTOM_CSS && typeof value === 'string') {
syncCustomCssFromStorage();
}
if (key === STORAGE_KEY_UI_FONT_FAMILY && typeof value === 'string') {
if (isValidUiFontId(value)) {
setUiFontFamilyId(value);
}
}
if (key === STORAGE_KEY_TERM_THEME && typeof value === 'string') {
setTerminalThemeId(value);
}
if (key === STORAGE_KEY_TERM_FONT_FAMILY && typeof value === 'string') {
setTerminalFontFamilyId(value);
}
if (key === STORAGE_KEY_TERM_FONT_SIZE && typeof value === 'number') {
setTerminalFontSize(value);
}
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
setHotkeyScheme(value);
}
if (key === STORAGE_KEY_CUSTOM_KEY_BINDINGS) {
if (typeof value === 'string') {
try {
setCustomKeyBindings(JSON.parse(value) as CustomKeyBindings);
} catch {
// ignore parse errors
}
} else if (value && typeof value === 'object') {
setCustomKeyBindings(value as CustomKeyBindings);
}
}
if (key === STORAGE_KEY_HOTKEY_RECORDING && typeof value === 'boolean') {
setIsHotkeyRecordingState(value);
}
});
return () => {
try {
unsubscribe?.();
} catch {
// ignore
}
};
}, [syncAppearanceFromStorage, syncCustomCssFromStorage]);
useEffect(() => {
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
}
};
}, []);
// Listen for storage changes from other windows (cross-window sync)
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === STORAGE_KEY_THEME && e.newValue) {
if (isValidTheme(e.newValue) && e.newValue !== theme) {
setTheme(e.newValue);
}
}
if (e.key === STORAGE_KEY_UI_THEME_LIGHT && e.newValue) {
if (isValidUiThemeId('light', e.newValue) && e.newValue !== lightUiThemeId) {
setLightUiThemeId(e.newValue);
}
}
if (e.key === STORAGE_KEY_UI_THEME_DARK && e.newValue) {
if (isValidUiThemeId('dark', e.newValue) && e.newValue !== darkUiThemeId) {
setDarkUiThemeId(e.newValue);
}
}
if (e.key === STORAGE_KEY_ACCENT_MODE && e.newValue) {
if ((e.newValue === 'theme' || e.newValue === 'custom') && e.newValue !== accentMode) {
setAccentMode(e.newValue);
}
}
if (e.key === STORAGE_KEY_COLOR && e.newValue) {
if (isValidHslToken(e.newValue) && e.newValue !== customAccent) {
setCustomAccent(e.newValue.trim());
}
}
if (e.key === STORAGE_KEY_CUSTOM_CSS && e.newValue !== null) {
if (e.newValue !== customCSS) {
setCustomCSS(e.newValue);
}
}
if (e.key === STORAGE_KEY_UI_FONT_FAMILY && e.newValue) {
if (isValidUiFontId(e.newValue) && e.newValue !== uiFontFamilyId) {
setUiFontFamilyId(e.newValue);
}
}
if (e.key === STORAGE_KEY_HOTKEY_SCHEME && e.newValue) {
const newScheme = e.newValue as HotkeyScheme;
if (newScheme !== hotkeyScheme) {
setHotkeyScheme(newScheme);
}
}
if (e.key === STORAGE_KEY_UI_LANGUAGE && e.newValue) {
const next = resolveSupportedLocale(e.newValue);
if (next !== uiLanguage) {
setUiLanguage(next as UILanguage);
}
}
if (e.key === STORAGE_KEY_CUSTOM_KEY_BINDINGS && e.newValue) {
try {
const newBindings = JSON.parse(e.newValue) as CustomKeyBindings;
setCustomKeyBindings(newBindings);
} catch {
// ignore parse errors
}
}
// Sync terminal settings from other windows
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
try {
const newSettings = JSON.parse(e.newValue) as TerminalSettings;
setTerminalSettings(_prev => ({ ...DEFAULT_TERMINAL_SETTINGS, ...newSettings }));
} catch {
// ignore parse errors
}
}
// Sync terminal theme from other windows
if (e.key === STORAGE_KEY_TERM_THEME && e.newValue) {
if (e.newValue !== terminalThemeId) {
setTerminalThemeId(e.newValue);
}
}
// Sync terminal font family from other windows
if (e.key === STORAGE_KEY_TERM_FONT_FAMILY && e.newValue) {
if (e.newValue !== terminalFontFamilyId) {
setTerminalFontFamilyId(e.newValue);
}
}
// Sync terminal font size from other windows
if (e.key === STORAGE_KEY_TERM_FONT_SIZE && e.newValue) {
const newSize = parseInt(e.newValue, 10);
if (!isNaN(newSize) && newSize !== terminalFontSize) {
setTerminalFontSize(newSize);
}
}
// Sync SFTP double-click behavior from other windows
if (e.key === STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR && e.newValue) {
if ((e.newValue === 'open' || e.newValue === 'transfer') && e.newValue !== sftpDoubleClickBehavior) {
setSftpDoubleClickBehavior(e.newValue);
}
}
// Sync SFTP auto-sync setting from other windows
if (e.key === STORAGE_KEY_SFTP_AUTO_SYNC && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== sftpAutoSync) {
setSftpAutoSync(newValue);
}
}
// Sync SFTP show hidden files setting from other windows
if (e.key === STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== sftpShowHiddenFiles) {
setSftpShowHiddenFiles(newValue);
}
}
// Sync SFTP compressed upload setting from other windows
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
if (newValue !== sftpUseCompressedUpload) {
setSftpUseCompressedUpload(newValue);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
notifySettingsChanged(STORAGE_KEY_TERM_THEME, terminalThemeId);
}, [terminalThemeId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
notifySettingsChanged(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
}, [terminalFontFamilyId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeNumber(STORAGE_KEY_TERM_FONT_SIZE, terminalFontSize);
notifySettingsChanged(STORAGE_KEY_TERM_FONT_SIZE, terminalFontSize);
}, [terminalFontSize, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
}, [terminalSettings]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
notifySettingsChanged(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
}, [hotkeyScheme, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.write(STORAGE_KEY_CUSTOM_KEY_BINDINGS, customKeyBindings);
notifySettingsChanged(STORAGE_KEY_CUSTOM_KEY_BINDINGS, customKeyBindings);
}, [customKeyBindings, notifySettingsChanged]);
const setIsHotkeyRecording = useCallback((isRecording: boolean) => {
setIsHotkeyRecordingState(isRecording);
notifySettingsChanged(STORAGE_KEY_HOTKEY_RECORDING, isRecording);
}, [notifySettingsChanged]);
// Apply and persist custom CSS
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
notifySettingsChanged(STORAGE_KEY_CUSTOM_CSS, customCSS);
// Apply custom CSS to document
let styleEl = document.getElementById('netcatty-custom-css') as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'netcatty-custom-css';
document.head.appendChild(styleEl);
}
styleEl.textContent = customCSS;
}, [customCSS, notifySettingsChanged]);
// Persist SFTP double-click behavior
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
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');
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');
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');
notifySettingsChanged(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload);
}, [sftpUseCompressedUpload, notifySettingsChanged]);
// Persist Session Logs settings
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled ? 'true' : 'false');
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled);
}, [sessionLogsEnabled, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
}, [sessionLogsDir, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
}, [sessionLogsFormat, notifySettingsChanged]);
// 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 => ({
...prev,
[bindingId]: {
...prev[bindingId],
[scheme]: newKey,
},
}));
}, []);
// Reset a key binding to default
const resetKeyBinding = useCallback((bindingId: string, scheme?: 'mac' | 'pc') => {
setCustomKeyBindings(prev => {
const next = { ...prev };
if (scheme) {
if (next[bindingId]) {
delete next[bindingId][scheme];
if (Object.keys(next[bindingId]).length === 0) {
delete next[bindingId];
}
}
} else {
delete next[bindingId];
}
return next;
});
}, []);
// Reset all key bindings to defaults
const resetAllKeyBindings = useCallback(() => {
setCustomKeyBindings({});
}, []);
const updateSyncConfig = useCallback((config: SyncConfig | null) => {
setSyncConfig(config);
localStorageAdapter.write(STORAGE_KEY_SYNC, config);
}, []);
const currentTerminalTheme = useMemo(
() => TERMINAL_THEMES.find(t => t.id === terminalThemeId) || TERMINAL_THEMES[0],
[terminalThemeId]
);
const currentTerminalFont = useMemo(
() => availableFonts.find(f => f.id === terminalFontFamilyId) || availableFonts[0],
[terminalFontFamilyId, availableFonts]
);
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
key: K,
value: TerminalSettings[K]
) => {
setTerminalSettings(prev => ({ ...prev, [key]: value }));
}, []);
return {
theme,
setTheme,
lightUiThemeId,
setLightUiThemeId,
darkUiThemeId,
setDarkUiThemeId,
accentMode,
setAccentMode,
customAccent,
setCustomAccent,
uiFontFamilyId,
setUiFontFamilyId,
syncConfig,
updateSyncConfig,
uiLanguage,
setUiLanguage,
terminalThemeId,
setTerminalThemeId,
currentTerminalTheme,
terminalFontFamilyId,
setTerminalFontFamilyId,
currentTerminalFont,
terminalFontSize,
setTerminalFontSize,
terminalSettings,
setTerminalSettings,
updateTerminalSetting,
hotkeyScheme,
setHotkeyScheme,
keyBindings,
customKeyBindings,
updateKeyBinding,
resetKeyBinding,
resetAllKeyBindings,
isHotkeyRecording,
setIsHotkeyRecording,
customCSS,
setCustomCSS,
sftpDoubleClickBehavior,
setSftpDoubleClickBehavior,
sftpAutoSync,
setSftpAutoSync,
sftpShowHiddenFiles,
setSftpShowHiddenFiles,
sftpUseCompressedUpload,
setSftpUseCompressedUpload,
availableFonts,
// Session Logs
sessionLogsEnabled,
setSessionLogsEnabled,
sessionLogsDir,
setSessionLogsDir,
sessionLogsFormat,
setSessionLogsFormat,
};
};