mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
308 lines
9.6 KiB
TypeScript
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
|
|
}
|
|
}
|