Migrate from IndexedDB to file-based storage with cross-instance sync

This commit is contained in:
Shantur Rathore
2025-10-25 20:21:52 +01:00
parent c4a8a54bd7
commit f4a664bfe7
8 changed files with 334 additions and 130 deletions

View File

@@ -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<IDBDatabase> {
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<void> {
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<string[]> {
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<void> {
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()
})
}

106
src/lib/storage.ts Normal file
View File

@@ -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<ConfigData> {
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<void> {
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<InstanceData> {
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<void> {
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<void> {
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()

View File

@@ -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<ThemeContextValue>()
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 {

View File

@@ -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<vo
instanceHistories.set(instanceId, history)
saveHistory(instanceId, history).catch((err) => {
try {
await storage.saveInstanceData(instanceId, { messageHistory: history })
} catch (err) {
console.warn("Failed to persist message history:", err)
})
}
}
export async function getHistory(instanceId: string): Promise<string[]> {
@@ -31,7 +33,12 @@ export async function getHistory(instanceId: string): Promise<string[]> {
export async function clearHistory(instanceId: string): Promise<void> {
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<void> {
@@ -40,8 +47,8 @@ async function ensureHistoryLoaded(instanceId: string): Promise<void> {
}
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)

View File

@@ -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<Preferences>(defaultPreferences)
const [recentFolders, setRecentFolders] = createSignal<RecentFolder[]>([])
async function loadConfig(): Promise<void> {
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<void> {
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<Preferences>(loadPreferences())
const [recentFolders, setRecentFolders] = createSignal<RecentFolder[]>(loadRecentFolders())
function updatePreferences(updates: Partial<Preferences>): 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 }