Files
orca/src/main/persistence.ts
T
2026-04-06 23:24:02 -07:00

308 lines
9.6 KiB
TypeScript

import { app } from 'electron'
import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from 'fs'
import { join, dirname } from 'path'
import { homedir } from 'os'
import type { PersistedState, Repo, WorktreeMeta, GlobalSettings } from '../shared/types'
import { isFolderRepo } from '../shared/repo-kind'
import { getGitUsername } from './git/repo'
import {
getDefaultPersistedState,
getDefaultNotificationSettings,
getDefaultUIState,
getDefaultRepoHookSettings,
getDefaultWorkspaceSession
} from '../shared/constants'
// Why: the data-file path must not be a module-level constant. Module-level
// code runs at import time — before configureDevUserDataPath() redirects the
// userData path in index.ts — so a constant would capture the default (non-dev)
// path, causing dev and production instances to share the same file and silently
// overwrite each other.
//
// It also must not be resolved lazily on every call, because app.setName('Orca')
// runs before the Store constructor and would change the resolved path from
// lowercase 'orca' to uppercase 'Orca'. On case-sensitive filesystems (Linux)
// this would look in the wrong directory and lose existing user data.
//
// Solution: index.ts calls initDataPath() right after configureDevUserDataPath()
// but before app.setName(), capturing the correct path at the right moment.
let _dataFile: string | null = null
export function initDataPath(): void {
_dataFile = join(app.getPath('userData'), 'orca-data.json')
}
function getDataFile(): string {
if (!_dataFile) {
// Safety fallback — should not be hit in normal startup.
_dataFile = join(app.getPath('userData'), 'orca-data.json')
}
return _dataFile
}
function normalizeSortBy(sortBy: unknown): 'name' | 'recent' | 'repo' {
if (sortBy === 'recent' || sortBy === 'repo' || sortBy === 'name') {
return sortBy
}
if (sortBy === 'smart') {
return 'recent'
}
return getDefaultUIState().sortBy
}
export class Store {
private state: PersistedState
private writeTimer: ReturnType<typeof setTimeout> | null = null
private gitUsernameCache = new Map<string, string>()
constructor() {
this.state = this.load()
}
private load(): PersistedState {
try {
const dataFile = getDataFile()
if (existsSync(dataFile)) {
const raw = readFileSync(dataFile, 'utf-8')
const parsed = JSON.parse(raw) as PersistedState
// Merge with defaults in case new fields were added
const defaults = getDefaultPersistedState(homedir())
return {
...defaults,
...parsed,
settings: {
...defaults.settings,
...parsed.settings,
notifications: {
...getDefaultNotificationSettings(),
...parsed.settings?.notifications
}
},
ui: {
...defaults.ui,
...parsed.ui,
sortBy: normalizeSortBy(parsed.ui?.sortBy)
},
workspaceSession: { ...defaults.workspaceSession, ...parsed.workspaceSession }
}
}
} catch (err) {
console.error('[persistence] Failed to load state, using defaults:', err)
}
return getDefaultPersistedState(homedir())
}
private scheduleSave(): void {
if (this.writeTimer) {
clearTimeout(this.writeTimer)
}
this.writeTimer = setTimeout(() => {
this.writeTimer = null
try {
this.writeToDisk()
} catch (err) {
console.error('[persistence] Failed to write state:', err)
}
}, 300)
}
private writeToDisk(): void {
const dataFile = getDataFile()
const dir = dirname(dataFile)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
// Why: synchronous flushes can race the debounced writer during shutdown or
// beforeunload persistence. A shared `.tmp` path lets one rename steal the
// temp file from the other, which surfaces as ENOENT even though the final
// state may already be on disk. Use a unique temp file per write so atomic
// replaces remain race-safe across platforms.
const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8')
renameSync(tmpFile, dataFile)
}
// ── Repos ──────────────────────────────────────────────────────────
getRepos(): Repo[] {
return this.state.repos.map((repo) => this.hydrateRepo(repo))
}
getRepo(id: string): Repo | undefined {
const repo = this.state.repos.find((r) => r.id === id)
if (!repo) {
return undefined
}
return this.hydrateRepo(repo)
}
addRepo(repo: Repo): void {
this.state.repos.push(repo)
this.scheduleSave()
}
removeRepo(id: string): void {
this.state.repos = this.state.repos.filter((r) => r.id !== id)
// Clean up worktree meta for this repo
const prefix = `${id}::`
for (const key of Object.keys(this.state.worktreeMeta)) {
if (key.startsWith(prefix)) {
delete this.state.worktreeMeta[key]
}
}
this.scheduleSave()
}
updateRepo(
id: string,
updates: Partial<
Pick<Repo, 'displayName' | 'badgeColor' | 'hookSettings' | 'worktreeBaseRef' | 'kind'>
>
): Repo | null {
const repo = this.state.repos.find((r) => r.id === id)
if (!repo) {
return null
}
Object.assign(repo, updates)
this.scheduleSave()
return this.hydrateRepo(repo)
}
private hydrateRepo(repo: Repo): Repo {
const gitUsername = isFolderRepo(repo)
? ''
: (this.gitUsernameCache.get(repo.path) ??
(() => {
const username = getGitUsername(repo.path)
this.gitUsernameCache.set(repo.path, username)
return username
})())
return {
...repo,
kind: isFolderRepo(repo) ? 'folder' : 'git',
gitUsername,
hookSettings: {
...getDefaultRepoHookSettings(),
...repo.hookSettings,
scripts: {
...getDefaultRepoHookSettings().scripts,
...repo.hookSettings?.scripts
}
}
}
}
// ── Worktree Meta ──────────────────────────────────────────────────
getWorktreeMeta(worktreeId: string): WorktreeMeta | undefined {
return this.state.worktreeMeta[worktreeId]
}
getAllWorktreeMeta(): Record<string, WorktreeMeta> {
return this.state.worktreeMeta
}
setWorktreeMeta(worktreeId: string, meta: Partial<WorktreeMeta>): WorktreeMeta {
const existing = this.state.worktreeMeta[worktreeId] || getDefaultWorktreeMeta()
const updated = { ...existing, ...meta }
this.state.worktreeMeta[worktreeId] = updated
this.scheduleSave()
return updated
}
removeWorktreeMeta(worktreeId: string): void {
delete this.state.worktreeMeta[worktreeId]
this.scheduleSave()
}
// ── Settings ───────────────────────────────────────────────────────
getSettings(): GlobalSettings {
return this.state.settings
}
updateSettings(updates: Partial<GlobalSettings>): GlobalSettings {
this.state.settings = {
...this.state.settings,
...updates,
notifications: {
...this.state.settings.notifications,
...updates.notifications
}
}
this.scheduleSave()
return this.state.settings
}
// ── UI State ───────────────────────────────────────────────────────
getUI(): PersistedState['ui'] {
return {
...getDefaultUIState(),
...this.state.ui,
sortBy: normalizeSortBy(this.state.ui?.sortBy)
}
}
updateUI(updates: Partial<PersistedState['ui']>): void {
this.state.ui = {
...this.state.ui,
...updates,
sortBy: updates.sortBy
? normalizeSortBy(updates.sortBy)
: normalizeSortBy(this.state.ui?.sortBy)
}
this.scheduleSave()
}
// ── GitHub Cache ──────────────────────────────────────────────────
getGitHubCache(): PersistedState['githubCache'] {
return this.state.githubCache
}
setGitHubCache(cache: PersistedState['githubCache']): void {
this.state.githubCache = cache
this.scheduleSave()
}
// ── Workspace Session ─────────────────────────────────────────────
getWorkspaceSession(): PersistedState['workspaceSession'] {
return this.state.workspaceSession ?? getDefaultWorkspaceSession()
}
setWorkspaceSession(session: PersistedState['workspaceSession']): void {
this.state.workspaceSession = session
this.scheduleSave()
}
// ── Flush (for shutdown) ───────────────────────────────────────────
flush(): void {
if (this.writeTimer) {
clearTimeout(this.writeTimer)
this.writeTimer = null
}
try {
this.writeToDisk()
} catch (err) {
console.error('[persistence] Failed to flush state:', err)
}
}
}
function getDefaultWorktreeMeta(): WorktreeMeta {
return {
displayName: '',
comment: '',
linkedIssue: null,
linkedPR: null,
isArchived: false,
isUnread: false,
sortOrder: Date.now(),
lastActivityAt: 0
}
}