Files
Netcatty/application/state/customThemeStore.ts
2026-04-10 21:43:15 +08:00

187 lines
6.0 KiB
TypeScript

import { useSyncExternalStore, useCallback } from 'react';
import { TerminalTheme } from '../../domain/models';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import { STORAGE_KEY_CUSTOM_THEMES } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
// Access the Electron bridge for cross-window IPC
type NetcattyBridge = {
notifySettingsChanged?(payload: { key: string; value: unknown }): void;
onSettingsChanged?(cb: (payload: { key: string; value: unknown }) => void): () => void;
};
const getBridge = (): NetcattyBridge | undefined =>
(window as unknown as { netcatty?: NetcattyBridge }).netcatty;
/**
* Custom Theme Store - manages user-created terminal themes
* Uses useSyncExternalStore pattern (same as fontStore)
* Persists to localStorage + cross-window IPC sync
*/
type Listener = () => void;
class CustomThemeStore {
private themes: TerminalTheme[] = [];
private listeners = new Set<Listener>();
/** Cached merged array for stable useSyncExternalStore snapshots */
private cachedAllThemes: TerminalTheme[] | null = null;
constructor() {
this.loadFromStorage();
this.setupCrossWindowSync();
}
/** Reload themes from localStorage. Called internally and after sync apply. */
loadFromStorage = () => {
try {
const parsed = localStorageAdapter.read<TerminalTheme[]>(STORAGE_KEY_CUSTOM_THEMES);
if (Array.isArray(parsed)) {
this.themes = parsed.map((t: TerminalTheme) => ({ ...t, isCustom: true }));
}
} catch {
// ignore corrupt data
}
this.notify();
};
private saveToStorage = () => {
try {
localStorageAdapter.write(STORAGE_KEY_CUSTOM_THEMES, this.themes);
} catch {
// storage full or unavailable
}
};
private notify = () => {
this.cachedAllThemes = null; // invalidate cache on any mutation
this.listeners.forEach(listener => listener());
};
/** Broadcast change to other Electron windows via IPC */
private broadcastChange = () => {
try {
getBridge()?.notifySettingsChanged?.({
key: STORAGE_KEY_CUSTOM_THEMES,
value: this.themes,
});
} catch {
// not in Electron or bridge unavailable
}
};
/** Listen for changes from other windows and reload */
private setupCrossWindowSync = () => {
try {
getBridge()?.onSettingsChanged?.((payload) => {
if (payload.key === STORAGE_KEY_CUSTOM_THEMES) {
// Another window changed custom themes — reload from localStorage
this.loadFromStorage();
}
});
} catch {
// not in Electron or bridge unavailable
}
};
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
// ---- Getters (stable references for useSyncExternalStore) ----
getCustomThemes = (): TerminalTheme[] => this.themes;
/** Returns all themes: built-in + custom (cached for snapshot stability) */
getAllThemes = (): TerminalTheme[] => {
if (!this.cachedAllThemes) {
this.cachedAllThemes = [...TERMINAL_THEMES, ...this.themes];
}
return this.cachedAllThemes;
};
/** Find a theme by ID across both built-in and custom */
getThemeById = (id: string): TerminalTheme | undefined => {
return TERMINAL_THEMES.find(t => t.id === id) || this.themes.find(t => t.id === id);
};
// ---- Mutations ----
addTheme = (theme: TerminalTheme) => {
this.themes = [...this.themes, { ...theme, isCustom: true }];
this.saveToStorage();
this.notify();
this.broadcastChange();
};
updateTheme = (id: string, updates: Partial<TerminalTheme>) => {
this.themes = this.themes.map(t =>
t.id === id ? { ...t, ...updates, isCustom: true } : t
);
this.saveToStorage();
this.notify();
this.broadcastChange();
};
deleteTheme = (id: string) => {
this.themes = this.themes.filter(t => t.id !== id);
this.saveToStorage();
this.notify();
this.broadcastChange();
};
replaceThemes = (themes: TerminalTheme[]) => {
this.themes = themes.map((theme) => ({ ...theme, colors: { ...theme.colors }, isCustom: true }));
this.saveToStorage();
this.notify();
this.broadcastChange();
};
}
// Singleton
export const customThemeStore = new CustomThemeStore();
// ============== Hooks ==============
/** Get all themes (built-in + custom) */
export const useAllThemes = (): TerminalTheme[] => {
return useSyncExternalStore(
customThemeStore.subscribe,
customThemeStore.getAllThemes
);
};
/** Get custom themes only */
export const useCustomThemes = (): TerminalTheme[] => {
return useSyncExternalStore(
customThemeStore.subscribe,
customThemeStore.getCustomThemes
);
};
/** Get theme by ID (built-in or custom) with fallback */
export const useThemeById = (id: string): TerminalTheme => {
const allThemes = useAllThemes();
return allThemes.find(t => t.id === id) || TERMINAL_THEMES[0];
};
/** Theme mutation actions */
export const useCustomThemeActions = () => {
const addTheme = useCallback((theme: TerminalTheme) => {
customThemeStore.addTheme(theme);
}, []);
const updateTheme = useCallback((id: string, updates: Partial<TerminalTheme>) => {
customThemeStore.updateTheme(id, updates);
}, []);
const deleteTheme = useCallback((id: string) => {
customThemeStore.deleteTheme(id);
}, []);
const replaceThemes = useCallback((themes: TerminalTheme[]) => {
customThemeStore.replaceThemes(themes);
}, []);
return { addTheme, updateTheme, deleteTheme, replaceThemes };
};