diff --git a/electron/main.ts b/electron/main.ts index ab3ce89..ea89f6e 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -7,7 +7,7 @@ import os from "os"; import { fileURLToPath } from "url"; import { PtyManager, OutputBatcher } from "./pty-manager"; import { ProjectScanner } from "./project-scanner"; -import { StatePersistence, TERMCANVAS_DIR } from "./state-persistence"; +import { StatePersistence, PreferencesPersistence, TERMCANVAS_DIR } from "./state-persistence"; import { GitFileWatcher } from "./git-watcher"; import { SessionWatcher, type SessionType } from "./session-watcher"; import { ApiServer } from "./api-server"; @@ -70,6 +70,7 @@ const outputBatcher = new OutputBatcher((ptyId, data) => { }); const projectScanner = new ProjectScanner(); const statePersistence = new StatePersistence(); +const prefsPersistence = new PreferencesPersistence(); const gitWatcher = new GitFileWatcher(); const sessionWatcher = new SessionWatcher(); const apiServer = new ApiServer({ @@ -456,6 +457,15 @@ function setupIpc() { statePersistence.save(state); }); + // Preferences IPC + ipcMain.handle("preferences:load", () => { + return prefsPersistence.load(); + }); + + ipcMain.handle("preferences:save", (_event, prefs: unknown) => { + prefsPersistence.save(prefs); + }); + // Workspace file IPC ipcMain.handle("workspace:save", async (_event, data: string) => { const result = await dialog.showSaveDialog(mainWindow!, { diff --git a/electron/preload.ts b/electron/preload.ts index bfef3b1..ee0ae70 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -96,6 +96,10 @@ contextBridge.exposeInMainWorld("termcanvas", { load: () => ipcRenderer.invoke("state:load"), save: (state: unknown) => ipcRenderer.invoke("state:save", state), }, + preferences: { + load: () => ipcRenderer.invoke("preferences:load"), + save: (prefs: unknown) => ipcRenderer.invoke("preferences:save", prefs), + }, workspace: { save: (data: string) => ipcRenderer.invoke("workspace:save", data) as Promise, diff --git a/electron/state-persistence.ts b/electron/state-persistence.ts index 33e2876..8f1a577 100644 --- a/electron/state-persistence.ts +++ b/electron/state-persistence.ts @@ -8,28 +8,52 @@ export const TERMCANVAS_DIR = path.join( isDev ? ".termcanvas-dev" : ".termcanvas", ); const STATE_FILE = path.join(TERMCANVAS_DIR, "state.json"); +const PREFERENCES_FILE = path.join(TERMCANVAS_DIR, "preferences.json"); + +function ensureDir(): void { + if (!fs.existsSync(TERMCANVAS_DIR)) { + fs.mkdirSync(TERMCANVAS_DIR, { recursive: true }); + } +} + +function loadJsonFile(filePath: string): unknown | null { + try { + if (!fs.existsSync(filePath)) return null; + const data = fs.readFileSync(filePath, "utf-8"); + return JSON.parse(data); + } catch (err) { + console.error(`[Persistence] failed to load ${filePath}:`, err); + return null; + } +} + +function saveJsonFile(filePath: string, data: unknown): void { + ensureDir(); + const tmp = filePath + ".tmp"; + fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8"); + fs.renameSync(tmp, filePath); +} export class StatePersistence { constructor() { - if (!fs.existsSync(TERMCANVAS_DIR)) { - fs.mkdirSync(TERMCANVAS_DIR, { recursive: true }); - } + ensureDir(); + } + + load(): unknown | null { + return loadJsonFile(STATE_FILE); } + save(state: unknown): void { + saveJsonFile(STATE_FILE, state); + } +} + +export class PreferencesPersistence { load(): unknown | null { - try { - if (!fs.existsSync(STATE_FILE)) return null; - const data = fs.readFileSync(STATE_FILE, "utf-8"); - return JSON.parse(data); - } catch (err) { - console.error("[StatePersistence] failed to load state:", err); - return null; - } + return loadJsonFile(PREFERENCES_FILE); } - save(state: unknown) { - const tmp = STATE_FILE + ".tmp"; - fs.writeFileSync(tmp, JSON.stringify(state, null, 2), "utf-8"); - fs.renameSync(tmp, STATE_FILE); + save(prefs: unknown): void { + saveJsonFile(PREFERENCES_FILE, prefs); } } diff --git a/src/App.tsx b/src/App.tsx index 2e0b153..f334536 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,7 @@ import { Sidebar } from "./components/Sidebar"; import { NotificationToast } from "./components/NotificationToast"; import { initUpdaterListeners } from "./stores/updaterStore"; import { ComposerBar } from "./components/ComposerBar"; -import { usePreferencesStore } from "./stores/preferencesStore"; +import { usePreferencesStore, hydratePreferences } from "./stores/preferencesStore"; import { DrawingPanel } from "./toolbar/DrawingPanel"; import { ShortcutHints } from "./components/ShortcutHints"; import { CompletionGlow } from "./components/CompletionGlow"; @@ -327,6 +327,7 @@ function CloseDialog({ export function App() { useWorktreeWatcher(); useStatePersistence(); + useEffect(() => { hydratePreferences(); }, []); useAutoSave(); useWorkspaceOpen(); useKeyboardShortcuts(); diff --git a/src/stores/preferencesStore.ts b/src/stores/preferencesStore.ts index 41312ba..3748ca4 100644 --- a/src/stores/preferencesStore.ts +++ b/src/stores/preferencesStore.ts @@ -11,7 +11,17 @@ export interface CliCommandConfig { args: string[]; } -interface PreferencesStore { +interface PreferencesData { + animationBlur: number; + terminalFontSize: number; + terminalFontFamily: string; + composerEnabled: boolean; + drawingEnabled: boolean; + minimumContrastRatio: number; + cliCommands: Partial>; +} + +interface PreferencesStore extends PreferencesData { /** Blur intensity in px (0 = off, max 3) */ animationBlur: number; /** Terminal (xterm) font size in px (6–24) */ @@ -35,68 +45,67 @@ interface PreferencesStore { setCli: (type: TerminalType, config: CliCommandConfig | null) => void; } -const STORAGE_KEY = "termcanvas-preferences"; +const DEFAULTS: PreferencesData = { + animationBlur: DEFAULT_BLUR, + terminalFontSize: DEFAULT_FONT_SIZE, + terminalFontFamily: "geist-mono", + composerEnabled: false, + drawingEnabled: false, + minimumContrastRatio: DEFAULT_MIN_CONTRAST, + cliCommands: {}, +}; -function loadPreferences(): { animationBlur: number; terminalFontSize: number; terminalFontFamily: string; composerEnabled: boolean; drawingEnabled: boolean; minimumContrastRatio: number; cliCommands: Partial> } { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw) { - const parsed = JSON.parse(raw); - let blur = DEFAULT_BLUR; - const v = parsed.animationBlur; - if (v === true) blur = LEGACY_ENABLED_BLUR; - else if (v === false) blur = 0; - else if (typeof v === "number" && v >= 0 && v <= 3) blur = v; - - let fontSize = DEFAULT_FONT_SIZE; - const f = parsed.terminalFontSize; - if (typeof f === "number" && f >= 6 && f <= 24) fontSize = f; - - let fontFamily = "geist-mono"; - const ff = parsed.terminalFontFamily; - if (typeof ff === "string" && ff.length > 0) fontFamily = ff; - - let composerEnabled = false; - if (parsed.composerEnabled === true) composerEnabled = true; - - let drawingEnabled = false; - if (parsed.drawingEnabled === true) drawingEnabled = true; - - let minimumContrastRatio = DEFAULT_MIN_CONTRAST; - const mcr = parsed.minimumContrastRatio; - if (typeof mcr === "number" && mcr >= 1 && mcr <= 7) minimumContrastRatio = mcr; - - const cliCommands: Partial> = {}; - if (parsed.cliCommands && typeof parsed.cliCommands === "object") { - for (const [key, val] of Object.entries(parsed.cliCommands)) { - if (val && typeof val === "object" && typeof (val as CliCommandConfig).command === "string") { - cliCommands[key as TerminalType] = val as CliCommandConfig; - } - } - } +function parsePreferences(parsed: Record): PreferencesData { + let blur = DEFAULT_BLUR; + const v = parsed.animationBlur; + if (v === true) blur = LEGACY_ENABLED_BLUR; + else if (v === false) blur = 0; + else if (typeof v === "number" && v >= 0 && v <= 3) blur = v; + + let fontSize = DEFAULT_FONT_SIZE; + const f = parsed.terminalFontSize; + if (typeof f === "number" && f >= 6 && f <= 24) fontSize = f; + + let fontFamily = "geist-mono"; + const ff = parsed.terminalFontFamily; + if (typeof ff === "string" && ff.length > 0) fontFamily = ff; - return { animationBlur: blur, terminalFontSize: fontSize, terminalFontFamily: fontFamily, composerEnabled, drawingEnabled, minimumContrastRatio, cliCommands }; + let composerEnabled = false; + if (parsed.composerEnabled === true) composerEnabled = true; + + let drawingEnabled = false; + if (parsed.drawingEnabled === true) drawingEnabled = true; + + let minimumContrastRatio = DEFAULT_MIN_CONTRAST; + const mcr = parsed.minimumContrastRatio; + if (typeof mcr === "number" && mcr >= 1 && mcr <= 7) minimumContrastRatio = mcr; + + const cliCommands: Partial> = {}; + if (parsed.cliCommands && typeof parsed.cliCommands === "object") { + for (const [key, val] of Object.entries(parsed.cliCommands as Record)) { + if (val && typeof val === "object" && typeof (val as CliCommandConfig).command === "string") { + cliCommands[key as TerminalType] = val as CliCommandConfig; + } } - } catch { - // ignore } - return { animationBlur: DEFAULT_BLUR, terminalFontSize: DEFAULT_FONT_SIZE, terminalFontFamily: "geist-mono", composerEnabled: false, drawingEnabled: false, minimumContrastRatio: DEFAULT_MIN_CONTRAST, cliCommands: {} }; + + return { animationBlur: blur, terminalFontSize: fontSize, terminalFontFamily: fontFamily, composerEnabled, drawingEnabled, minimumContrastRatio, cliCommands }; } -function savePreferences(state: { animationBlur: number; terminalFontSize: number; terminalFontFamily: string; composerEnabled: boolean; drawingEnabled: boolean; minimumContrastRatio: number; cliCommands: Partial> }) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); +function getPrefsData(state: PreferencesStore): PreferencesData { + const { animationBlur, terminalFontSize, terminalFontFamily, composerEnabled, drawingEnabled, minimumContrastRatio, cliCommands } = state; + return { animationBlur, terminalFontSize, terminalFontFamily, composerEnabled, drawingEnabled, minimumContrastRatio, cliCommands }; } -const initialPrefs = loadPreferences(); +function savePreferences(state: PreferencesStore): void { + const data = getPrefsData(state); + if (window.termcanvas?.preferences) { + window.termcanvas.preferences.save(data); + } +} export const usePreferencesStore = create((set, get) => ({ - animationBlur: initialPrefs.animationBlur, - terminalFontSize: initialPrefs.terminalFontSize, - terminalFontFamily: initialPrefs.terminalFontFamily, - composerEnabled: initialPrefs.composerEnabled, - drawingEnabled: initialPrefs.drawingEnabled, - minimumContrastRatio: initialPrefs.minimumContrastRatio, - cliCommands: initialPrefs.cliCommands, + ...DEFAULTS, setAnimationBlur: (value) => { const clamped = Math.round(Math.max(0, Math.min(3, value)) * 10) / 10; set({ animationBlur: clamped }); @@ -135,3 +144,17 @@ export const usePreferencesStore = create((set, get) => ({ savePreferences({ ...get(), cliCommands: current }); }, })); + +/** Load preferences from disk via IPC and hydrate the store. Call once on app startup. */ +export async function hydratePreferences(): Promise { + if (!window.termcanvas?.preferences) return; + try { + const saved = await window.termcanvas.preferences.load(); + if (saved && typeof saved === "object") { + const prefs = parsePreferences(saved as Record); + usePreferencesStore.setState(prefs); + } + } catch (err) { + console.error("[PreferencesStore] failed to hydrate from disk:", err); + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 925b3c2..71b3fd8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -265,6 +265,10 @@ export interface TermCanvasAPI { load: () => Promise; save: (state: unknown) => Promise; }; + preferences: { + load: () => Promise; + save: (prefs: unknown) => Promise; + }; workspace: { save: (data: string) => Promise; open: () => Promise;