Replaces all direct usage of browser globals and infrastructure service imports in UI components with dedicated application/state backend hooks. Introduces lint rules to prevent direct access to backend bridges and localStorage from components, promoting a cleaner separation of concerns and improved maintainability. Moves user preferences (e.g., port forwarding form mode) to persistent state hooks, updates port forwarding and SFTP logic to rely on backend hooks, and centralizes logging through a logger utility. Cleans up debug code and removes obsolete scripts from HTML. Improves testability, prepares for alternative backend implementations, and enforces architectural boundaries.
282 lines
10 KiB
TypeScript
282 lines
10 KiB
TypeScript
import { useCallback,useEffect,useMemo,useState } from 'react';
|
|
import { SyncConfig, TerminalSettings, DEFAULT_TERMINAL_SETTINGS, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding } 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_CUSTOM_CSS,
|
|
} from '../../infrastructure/config/storageKeys';
|
|
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
|
import { TERMINAL_FONTS, DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
|
|
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
|
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
|
|
|
const DEFAULT_COLOR = '221.2 83.2% 53.3%';
|
|
const DEFAULT_THEME: 'light' | 'dark' = 'light';
|
|
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 applyThemeTokens = (theme: 'light' | 'dark', primaryColor: string) => {
|
|
const root = window.document.documentElement;
|
|
root.classList.remove('light', 'dark');
|
|
root.classList.add(theme);
|
|
root.style.setProperty('--primary', primaryColor);
|
|
root.style.setProperty('--accent', primaryColor);
|
|
root.style.setProperty('--ring', primaryColor);
|
|
const lightness = parseFloat(primaryColor.split(/\s+/)[2]?.replace('%', '') || '');
|
|
const accentForeground = theme === 'dark'
|
|
? '220 40% 96%'
|
|
: (!Number.isNaN(lightness) && lightness < 55 ? '0 0% 98%' : '222 47% 12%');
|
|
root.style.setProperty('--accent-foreground', accentForeground);
|
|
|
|
// Sync with native window title bar (Electron)
|
|
netcattyBridge.get()?.setTheme?.(theme);
|
|
};
|
|
|
|
export const useSettingsState = () => {
|
|
const [theme, setTheme] = useState<'dark' | 'light'>(() => (localStorageAdapter.readString(STORAGE_KEY_THEME) as 'dark' | 'light') || DEFAULT_THEME);
|
|
const [primaryColor, setPrimaryColor] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_COLOR) || DEFAULT_COLOR);
|
|
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 [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 [customCSS, setCustomCSS] = useState<string>(() =>
|
|
localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS) || ''
|
|
);
|
|
|
|
useEffect(() => {
|
|
applyThemeTokens(theme, primaryColor);
|
|
localStorageAdapter.writeString(STORAGE_KEY_THEME, theme);
|
|
localStorageAdapter.writeString(STORAGE_KEY_COLOR, primaryColor);
|
|
}, [theme, primaryColor]);
|
|
|
|
// Listen for storage changes from other windows (cross-window sync)
|
|
useEffect(() => {
|
|
const handleStorageChange = (e: StorageEvent) => {
|
|
if (e.key === STORAGE_KEY_THEME && e.newValue) {
|
|
const newTheme = e.newValue as 'light' | 'dark';
|
|
if (newTheme !== theme) {
|
|
setTheme(newTheme);
|
|
}
|
|
}
|
|
if (e.key === STORAGE_KEY_COLOR && e.newValue) {
|
|
if (e.newValue !== primaryColor) {
|
|
setPrimaryColor(e.newValue);
|
|
}
|
|
}
|
|
if (e.key === STORAGE_KEY_CUSTOM_CSS && e.newValue !== null) {
|
|
if (e.newValue !== customCSS) {
|
|
setCustomCSS(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_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);
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener('storage', handleStorageChange);
|
|
return () => window.removeEventListener('storage', handleStorageChange);
|
|
}, [theme, primaryColor, customCSS, hotkeyScheme, terminalThemeId, terminalFontFamilyId, terminalFontSize]);
|
|
|
|
useEffect(() => {
|
|
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
|
}, [terminalThemeId]);
|
|
|
|
useEffect(() => {
|
|
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
|
|
}, [terminalFontFamilyId]);
|
|
|
|
useEffect(() => {
|
|
localStorageAdapter.writeNumber(STORAGE_KEY_TERM_FONT_SIZE, terminalFontSize);
|
|
}, [terminalFontSize]);
|
|
|
|
useEffect(() => {
|
|
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
|
|
}, [terminalSettings]);
|
|
|
|
useEffect(() => {
|
|
localStorageAdapter.writeString(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
|
|
}, [hotkeyScheme]);
|
|
|
|
useEffect(() => {
|
|
localStorageAdapter.write(STORAGE_KEY_CUSTOM_KEY_BINDINGS, customKeyBindings);
|
|
}, [customKeyBindings]);
|
|
|
|
// Apply and persist custom CSS
|
|
useEffect(() => {
|
|
localStorageAdapter.writeString(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]);
|
|
|
|
// 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(
|
|
() => TERMINAL_FONTS.find(f => f.id === terminalFontFamilyId) || TERMINAL_FONTS[0],
|
|
[terminalFontFamilyId]
|
|
);
|
|
|
|
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
|
|
key: K,
|
|
value: TerminalSettings[K]
|
|
) => {
|
|
setTerminalSettings(prev => ({ ...prev, [key]: value }));
|
|
}, []);
|
|
|
|
return {
|
|
theme,
|
|
setTheme,
|
|
primaryColor,
|
|
setPrimaryColor,
|
|
syncConfig,
|
|
updateSyncConfig,
|
|
terminalThemeId,
|
|
setTerminalThemeId,
|
|
currentTerminalTheme,
|
|
terminalFontFamilyId,
|
|
setTerminalFontFamilyId,
|
|
currentTerminalFont,
|
|
terminalFontSize,
|
|
setTerminalFontSize,
|
|
terminalSettings,
|
|
setTerminalSettings,
|
|
updateTerminalSetting,
|
|
hotkeyScheme,
|
|
setHotkeyScheme,
|
|
keyBindings,
|
|
customKeyBindings,
|
|
updateKeyBinding,
|
|
resetKeyBinding,
|
|
resetAllKeyBindings,
|
|
customCSS,
|
|
setCustomCSS,
|
|
};
|
|
};
|