Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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!, {
Expand Down
4 changes: 4 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>,
Expand Down
54 changes: 39 additions & 15 deletions electron/state-persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
3 changes: 2 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -327,6 +327,7 @@ function CloseDialog({
export function App() {
useWorktreeWatcher();
useStatePersistence();
useEffect(() => { hydratePreferences(); }, []);
useAutoSave();
useWorkspaceOpen();
useKeyboardShortcuts();
Expand Down
129 changes: 76 additions & 53 deletions src/stores/preferencesStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<TerminalType, CliCommandConfig>>;
}

interface PreferencesStore extends PreferencesData {
/** Blur intensity in px (0 = off, max 3) */
animationBlur: number;
/** Terminal (xterm) font size in px (6–24) */
Expand All @@ -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<Record<TerminalType, CliCommandConfig>> } {
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<Record<TerminalType, CliCommandConfig>> = {};
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<string, unknown>): 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<Record<TerminalType, CliCommandConfig>> = {};
if (parsed.cliCommands && typeof parsed.cliCommands === "object") {
for (const [key, val] of Object.entries(parsed.cliCommands as Record<string, unknown>)) {
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<Record<TerminalType, CliCommandConfig>> }) {
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<PreferencesStore>((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 });
Expand Down Expand Up @@ -135,3 +144,17 @@ export const usePreferencesStore = create<PreferencesStore>((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<void> {
if (!window.termcanvas?.preferences) return;
try {
const saved = await window.termcanvas.preferences.load();
if (saved && typeof saved === "object") {
const prefs = parsePreferences(saved as Record<string, unknown>);
usePreferencesStore.setState(prefs);
}
} catch (err) {
console.error("[PreferencesStore] failed to hydrate from disk:", err);
}
}
4 changes: 4 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,10 @@ export interface TermCanvasAPI {
load: () => Promise<CanvasState | null>;
save: (state: unknown) => Promise<void>;
};
preferences: {
load: () => Promise<unknown | null>;
save: (prefs: unknown) => Promise<void>;
};
workspace: {
save: (data: string) => Promise<string | null>;
open: () => Promise<string | null>;
Expand Down