From f4a664bfe799b6ee7a8a02cd19c9618aa7130433 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sat, 25 Oct 2025 20:21:52 +0100 Subject: [PATCH] Migrate from IndexedDB to file-based storage with cross-instance sync --- electron/main/main.ts | 2 + electron/main/storage.ts | 121 ++++++++++++++++++++++++++++++++++ electron/preload/index.ts | 20 ++++++ src/lib/db.ts | 68 ------------------- src/lib/storage.ts | 106 +++++++++++++++++++++++++++++ src/lib/theme.tsx | 40 ++++++++--- src/stores/message-history.ts | 19 ++++-- src/stores/preferences.ts | 88 ++++++++++++------------- 8 files changed, 334 insertions(+), 130 deletions(-) create mode 100644 electron/main/storage.ts delete mode 100644 src/lib/db.ts create mode 100644 src/lib/storage.ts diff --git a/electron/main/main.ts b/electron/main/main.ts index 272f4101..e1597139 100644 --- a/electron/main/main.ts +++ b/electron/main/main.ts @@ -2,6 +2,7 @@ import { app, BrowserWindow, dialog, ipcMain } from "electron" import { join } from "path" import { createApplicationMenu } from "./menu" import { setupInstanceIPC } from "./ipc" +import { setupStorageIPC } from "./storage" let mainWindow: BrowserWindow | null = null @@ -27,6 +28,7 @@ function createWindow() { createApplicationMenu(mainWindow) setupInstanceIPC(mainWindow) + setupStorageIPC() mainWindow.on("closed", () => { mainWindow = null diff --git a/electron/main/storage.ts b/electron/main/storage.ts new file mode 100644 index 00000000..7c64ff72 --- /dev/null +++ b/electron/main/storage.ts @@ -0,0 +1,121 @@ +import { app, ipcMain } from "electron" +import { join } from "path" +import { readFile, writeFile, mkdir, unlink, stat } from "fs/promises" +import { existsSync } from "fs" + +const CONFIG_DIR = join(app.getPath("home"), ".config", "opencode-client") +const CONFIG_FILE = join(CONFIG_DIR, "config.json") +const INSTANCES_DIR = join(CONFIG_DIR, "instances") + +// File watching for config changes +let configWatchers = new Set() +let configLastModified = 0 +let configCache: string | null = null + +async function ensureDirectories() { + try { + await mkdir(CONFIG_DIR, { recursive: true }) + await mkdir(INSTANCES_DIR, { recursive: true }) + } catch (error) { + console.error("Failed to create directories:", error) + } +} + +async function readConfigWithCache(): Promise { + try { + const stats = await stat(CONFIG_FILE) + const currentModified = stats.mtime.getTime() + + // If file hasn't been modified since last read, return cache + if (configCache && configLastModified >= currentModified) { + return configCache + } + + const content = await readFile(CONFIG_FILE, "utf-8") + configCache = content + configLastModified = currentModified + return content + } catch (error) { + // File doesn't exist or can't be read + configCache = null + configLastModified = 0 + throw error + } +} + +function invalidateConfigCache() { + configCache = null + configLastModified = 0 +} + +export function setupStorageIPC() { + ensureDirectories() + + ipcMain.handle("storage:getConfigPath", () => CONFIG_FILE) + ipcMain.handle("storage:getInstancesDir", () => INSTANCES_DIR) + + ipcMain.handle("storage:readConfigFile", async () => { + try { + return await readConfigWithCache() + } catch (error) { + // Return empty config if file doesn't exist + return JSON.stringify({ preferences: { showThinkingBlocks: false }, recentFolders: [] }, null, 2) + } + }) + + ipcMain.handle("storage:writeConfigFile", async (_, content: string) => { + try { + await writeFile(CONFIG_FILE, content, "utf-8") + invalidateConfigCache() + + // Notify other renderer processes about config change + const windows = require("electron").BrowserWindow.getAllWindows() + windows.forEach((win: any) => { + if (win.webContents && !win.webContents.isDestroyed()) { + win.webContents.send("storage:configChanged") + } + }) + } catch (error) { + console.error("Failed to write config file:", error) + throw error + } + }) + + ipcMain.handle("storage:readInstanceFile", async (_, filename: string) => { + const instanceFile = join(INSTANCES_DIR, `${filename}.json`) + try { + return await readFile(instanceFile, "utf-8") + } catch (error) { + // Return empty instance data if file doesn't exist + return JSON.stringify({ messageHistory: [] }, null, 2) + } + }) + + ipcMain.handle("storage:writeInstanceFile", async (_, filename: string, content: string) => { + const instanceFile = join(INSTANCES_DIR, `${filename}.json`) + try { + await writeFile(instanceFile, content, "utf-8") + } catch (error) { + console.error(`Failed to write instance file for ${filename}:`, error) + throw error + } + }) + + ipcMain.handle("storage:deleteInstanceFile", async (_, filename: string) => { + const instanceFile = join(INSTANCES_DIR, `${filename}.json`) + try { + if (existsSync(instanceFile)) { + await unlink(instanceFile) + } + } catch (error) { + console.error(`Failed to delete instance file for ${filename}:`, error) + throw error + } + }) +} + +// Clean up on app quit +app.on("before-quit", () => { + configCache = null + configLastModified = 0 +}) diff --git a/electron/preload/index.ts b/electron/preload/index.ts index c3defe89..af0ac85a 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -15,6 +15,14 @@ export interface ElectronAPI { ) => void onNewInstance: (callback: () => void) => void scanDirectory: (workspaceFolder: string) => Promise + // Storage operations + getConfigPath: () => string + getInstancesDir: () => string + readConfigFile: () => Promise + writeConfigFile: (content: string) => Promise + readInstanceFile: (instanceId: string) => Promise + writeInstanceFile: (instanceId: string, content: string) => Promise + deleteInstanceFile: (instanceId: string) => Promise } const electronAPI: ElectronAPI = { @@ -37,6 +45,18 @@ const electronAPI: ElectronAPI = { ipcRenderer.on("menu:newInstance", () => callback()) }, scanDirectory: (workspaceFolder: string) => ipcRenderer.invoke("fs:scanDirectory", workspaceFolder), + // Storage operations + getConfigPath: () => ipcRenderer.invoke("storage:getConfigPath"), + getInstancesDir: () => ipcRenderer.invoke("storage:getInstancesDir"), + readConfigFile: () => ipcRenderer.invoke("storage:readConfigFile"), + writeConfigFile: (content: string) => ipcRenderer.invoke("storage:writeConfigFile", content), + readInstanceFile: (filename: string) => ipcRenderer.invoke("storage:readInstanceFile", filename), + writeInstanceFile: (filename: string, content: string) => + ipcRenderer.invoke("storage:writeInstanceFile", filename, content), + deleteInstanceFile: (filename: string) => ipcRenderer.invoke("storage:deleteInstanceFile", filename), + onConfigChanged: (callback: () => void) => { + ipcRenderer.on("storage:configChanged", () => callback()) + }, } contextBridge.exposeInMainWorld("electronAPI", electronAPI) diff --git a/src/lib/db.ts b/src/lib/db.ts deleted file mode 100644 index f7c8890f..00000000 --- a/src/lib/db.ts +++ /dev/null @@ -1,68 +0,0 @@ -const DB_NAME = "opencode-client" -const DB_VERSION = 1 -const HISTORY_STORE = "message-history" - -let db: IDBDatabase | null = null - -async function getDB(): Promise { - if (db) return db - - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION) - - request.onerror = () => reject(request.error) - request.onsuccess = () => { - db = request.result - resolve(db) - } - - request.onupgradeneeded = (event) => { - const database = (event.target as IDBOpenDBRequest).result - - if (!database.objectStoreNames.contains(HISTORY_STORE)) { - database.createObjectStore(HISTORY_STORE) - } - } - }) -} - -export async function saveHistory(instanceId: string, history: string[]): Promise { - const database = await getDB() - return new Promise((resolve, reject) => { - const tx = database.transaction(HISTORY_STORE, "readwrite") - const store = tx.objectStore(HISTORY_STORE) - const request = store.put(history, instanceId) - - request.onerror = () => reject(request.error) - request.onsuccess = () => resolve() - }) -} - -export async function loadHistory(instanceId: string): Promise { - try { - const database = await getDB() - return new Promise((resolve, reject) => { - const tx = database.transaction(HISTORY_STORE, "readonly") - const store = tx.objectStore(HISTORY_STORE) - const request = store.get(instanceId) - - request.onerror = () => reject(request.error) - request.onsuccess = () => resolve(request.result || []) - }) - } catch (error) { - console.warn("Failed to load history from IndexedDB:", error) - return [] - } -} - -export async function deleteHistory(instanceId: string): Promise { - const database = await getDB() - return new Promise((resolve, reject) => { - const tx = database.transaction(HISTORY_STORE, "readwrite") - const store = tx.objectStore(HISTORY_STORE) - const request = store.delete(instanceId) - - request.onerror = () => reject(request.error) - request.onsuccess = () => resolve() - }) -} diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 00000000..d305566c --- /dev/null +++ b/src/lib/storage.ts @@ -0,0 +1,106 @@ +import type { Preferences, RecentFolder } from "../stores/preferences" + +export interface ConfigData { + preferences: Preferences + recentFolders: RecentFolder[] +} + +export interface InstanceData { + messageHistory: string[] +} + +export class FileStorage { + private configPath: string + private instancesDir: string + private configChangeListeners: Set<() => void> = new Set() + + constructor() { + this.configPath = window.electronAPI.getConfigPath() + this.instancesDir = window.electronAPI.getInstancesDir() + + // Listen for config changes from other instances + window.electronAPI.onConfigChanged(() => { + this.configChangeListeners.forEach((listener) => listener()) + }) + } + + // Config operations + async loadConfig(): Promise { + try { + const content = await window.electronAPI.readConfigFile() + return JSON.parse(content) + } catch (error) { + console.warn("Failed to load config, using defaults:", error) + return { + preferences: { + showThinkingBlocks: false, + }, + recentFolders: [], + } + } + } + + async saveConfig(config: ConfigData): Promise { + try { + await window.electronAPI.writeConfigFile(JSON.stringify(config, null, 2)) + } catch (error) { + console.error("Failed to save config:", error) + throw error + } + } + + // Instance operations + async loadInstanceData(instanceId: string): Promise { + try { + const filename = this.instanceIdToFilename(instanceId) + const content = await window.electronAPI.readInstanceFile(filename) + return JSON.parse(content) + } catch (error) { + console.warn(`Failed to load instance data for ${instanceId}, using defaults:`, error) + return { + messageHistory: [], + } + } + } + + async saveInstanceData(instanceId: string, data: InstanceData): Promise { + try { + const filename = this.instanceIdToFilename(instanceId) + await window.electronAPI.writeInstanceFile(filename, JSON.stringify(data, null, 2)) + } catch (error) { + console.error(`Failed to save instance data for ${instanceId}:`, error) + throw error + } + } + + async deleteInstanceData(instanceId: string): Promise { + try { + const filename = this.instanceIdToFilename(instanceId) + await window.electronAPI.deleteInstanceFile(filename) + } catch (error) { + console.error(`Failed to delete instance data for ${instanceId}:`, error) + throw error + } + } + + // Convert folder path to safe filename + private instanceIdToFilename(instanceId: string): string { + // Convert folder path to safe filename + // Replace path separators and other invalid characters + return instanceId + .replace(/[\\/]/g, "_") // Replace path separators + .replace(/[^a-zA-Z0-9_.-]/g, "_") // Replace other invalid chars + .replace(/_{2,}/g, "_") // Replace multiple underscores with single + .replace(/^_|_$/g, "") // Remove leading/trailing underscores + .toLowerCase() + } + + // Config change listeners + onConfigChanged(listener: () => void): () => void { + this.configChangeListeners.add(listener) + return () => this.configChangeListeners.delete(listener) + } +} + +// Singleton instance +export const storage = new FileStorage() diff --git a/src/lib/theme.tsx b/src/lib/theme.tsx index 8b024721..e6294495 100644 --- a/src/lib/theme.tsx +++ b/src/lib/theme.tsx @@ -1,4 +1,5 @@ import { createContext, createSignal, useContext, onMount, type JSX } from "solid-js" +import { storage } from "./storage" interface ThemeContextValue { isDark: () => boolean @@ -10,22 +11,43 @@ const ThemeContext = createContext() export function ThemeProvider(props: { children: JSX.Element }) { const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches - const savedTheme = localStorage.getItem("theme") - const initialDark = savedTheme ? savedTheme === "dark" : prefersDark + const [isDark, setIsDarkSignal] = createSignal(prefersDark) - const [isDark, setIsDarkSignal] = createSignal(initialDark) + async function loadTheme() { + try { + const config = await storage.loadConfig() + const savedTheme = (config as any).theme + const initialDark = savedTheme ? savedTheme === "dark" : prefersDark + setIsDarkSignal(initialDark) + } catch (error) { + console.warn("Failed to load theme from config:", error) + } + } + + async function saveTheme(dark: boolean) { + try { + const config = await storage.loadConfig() + ;(config as any).theme = dark ? "dark" : "light" + await storage.saveConfig(config) + } catch (error) { + console.warn("Failed to save theme to config:", error) + } + } onMount(() => { - if (isDark()) { - document.documentElement.setAttribute("data-theme", "dark") - } else { - document.documentElement.removeAttribute("data-theme") - } + loadTheme() + + // Listen for config changes from other instances + const unsubscribe = storage.onConfigChanged(() => { + loadTheme() + }) + + return unsubscribe }) const setTheme = (dark: boolean) => { setIsDarkSignal(dark) - localStorage.setItem("theme", dark ? "dark" : "light") + saveTheme(dark) if (dark) { document.documentElement.setAttribute("data-theme", "dark") } else { diff --git a/src/stores/message-history.ts b/src/stores/message-history.ts index 36b211f9..147a7880 100644 --- a/src/stores/message-history.ts +++ b/src/stores/message-history.ts @@ -1,4 +1,4 @@ -import { saveHistory, loadHistory, deleteHistory } from "../lib/db" +import { storage, type InstanceData } from "../lib/storage" const MAX_HISTORY = 100 @@ -18,9 +18,11 @@ export async function addToHistory(instanceId: string, text: string): Promise { + try { + await storage.saveInstanceData(instanceId, { messageHistory: history }) + } catch (err) { console.warn("Failed to persist message history:", err) - }) + } } export async function getHistory(instanceId: string): Promise { @@ -31,7 +33,12 @@ export async function getHistory(instanceId: string): Promise { export async function clearHistory(instanceId: string): Promise { instanceHistories.delete(instanceId) historyLoaded.delete(instanceId) - await deleteHistory(instanceId) + + try { + await storage.saveInstanceData(instanceId, { messageHistory: [] }) + } catch (error) { + console.warn("Failed to clear history:", error) + } } async function ensureHistoryLoaded(instanceId: string): Promise { @@ -40,8 +47,8 @@ async function ensureHistoryLoaded(instanceId: string): Promise { } try { - const history = await loadHistory(instanceId) - instanceHistories.set(instanceId, history) + const data = await storage.loadInstanceData(instanceId) + instanceHistories.set(instanceId, data.messageHistory) historyLoaded.add(instanceId) } catch (error) { console.warn("Failed to load history:", error) diff --git a/src/stores/preferences.ts b/src/stores/preferences.ts index d4d8b497..a7826fed 100644 --- a/src/stores/preferences.ts +++ b/src/stores/preferences.ts @@ -1,69 +1,50 @@ -import { createSignal } from "solid-js" +import { createSignal, onMount } from "solid-js" +import { storage, type ConfigData } from "../lib/storage" -const STORAGE_KEY = "opencode-preferences" -const RECENT_FOLDERS_KEY = "opencode-recent-folders" -const MAX_RECENT_FOLDERS = 10 - -interface Preferences { +export interface Preferences { showThinkingBlocks: boolean } -interface RecentFolder { +export interface RecentFolder { path: string lastAccessed: number } +const MAX_RECENT_FOLDERS = 10 + const defaultPreferences: Preferences = { showThinkingBlocks: false, } -function loadPreferences(): Preferences { +const [preferences, setPreferences] = createSignal(defaultPreferences) +const [recentFolders, setRecentFolders] = createSignal([]) + +async function loadConfig(): Promise { try { - const stored = localStorage.getItem(STORAGE_KEY) - if (stored) { - return { ...defaultPreferences, ...JSON.parse(stored) } + const config = await storage.loadConfig() + setPreferences({ ...defaultPreferences, ...config.preferences }) + setRecentFolders(config.recentFolders) + } catch (error) { + console.error("Failed to load config:", error) + } +} + +async function saveConfig(): Promise { + try { + const config: ConfigData = { + preferences: preferences(), + recentFolders: recentFolders(), } + await storage.saveConfig(config) } catch (error) { - console.error("Failed to load preferences:", error) - } - return defaultPreferences -} - -function savePreferences(prefs: Preferences): void { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)) - } catch (error) { - console.error("Failed to save preferences:", error) + console.error("Failed to save config:", error) } } -function loadRecentFolders(): RecentFolder[] { - try { - const stored = localStorage.getItem(RECENT_FOLDERS_KEY) - if (stored) { - return JSON.parse(stored) - } - } catch (error) { - console.error("Failed to load recent folders:", error) - } - return [] -} - -function saveRecentFolders(folders: RecentFolder[]): void { - try { - localStorage.setItem(RECENT_FOLDERS_KEY, JSON.stringify(folders)) - } catch (error) { - console.error("Failed to save recent folders:", error) - } -} - -const [preferences, setPreferences] = createSignal(loadPreferences()) -const [recentFolders, setRecentFolders] = createSignal(loadRecentFolders()) - function updatePreferences(updates: Partial): void { const updated = { ...preferences(), ...updates } setPreferences(updated) - savePreferences(updated) + saveConfig().catch(console.error) } function toggleShowThinkingBlocks(): void { @@ -76,13 +57,26 @@ function addRecentFolder(path: string): void { const trimmed = folders.slice(0, MAX_RECENT_FOLDERS) setRecentFolders(trimmed) - saveRecentFolders(trimmed) + saveConfig().catch(console.error) } function removeRecentFolder(path: string): void { const folders = recentFolders().filter((f) => f.path !== path) setRecentFolders(folders) - saveRecentFolders(folders) + saveConfig().catch(console.error) } +// Load config on mount and listen for changes from other instances +onMount(() => { + loadConfig() + + // Reload config when changed by another instance + const unsubscribe = storage.onConfigChanged(() => { + loadConfig() + }) + + // Cleanup on unmount + return unsubscribe +}) + export { preferences, updatePreferences, toggleShowThinkingBlocks, recentFolders, addRecentFolder, removeRecentFolder }