187 lines
6.0 KiB
TypeScript
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 };
|
|
};
|