🎉 initial commit

This commit is contained in:
2026-06-08 17:15:43 +10:00
commit bfece01c67
33 changed files with 9920 additions and 0 deletions

123
src/main/aiService.ts Normal file
View File

@@ -0,0 +1,123 @@
import Anthropic from '@anthropic-ai/sdk'
import { getApiKey } from './globalConfig'
import type { Market } from './fileSystem'
export type AnalysisMode = 'compression' | 'ending' | 'tone' | 'market_fit' | 'chat'
export interface AIPayload {
mode: AnalysisMode
storyContent: string
storyId: string
wordCountTarget?: number
targetMarket?: Market
collectionContext?: string
useCollectionContext: boolean
useMarketBrief: boolean
conversationHistory: { role: 'user' | 'assistant'; content: string }[]
userMessage: string
}
let _client: Anthropic | null = null
export function resetClient(): void {
_client = null
}
function getClient(): Anthropic {
if (!_client) {
const apiKey = getApiKey()
if (!apiKey || apiKey === 'your-api-key-here') {
throw new Error('API key not set. Open Borges → Preferences to configure it.')
}
_client = new Anthropic({ apiKey })
}
return _client
}
function buildSystemPrompt(payload: AIPayload): string {
const wordConstraint = payload.wordCountTarget
? `The target word count is ${payload.wordCountTarget} words.`
: ''
const collectionSection =
payload.useCollectionContext && payload.collectionContext
? `\n\n=== COLLECTION CONTEXT ===\n${payload.collectionContext}\n=== END COLLECTION CONTEXT ===`
: ''
const marketSection =
payload.useMarketBrief && payload.targetMarket
? `\n\n=== MARKET BRIEF: ${payload.targetMarket.name} ===\nWord count range: ${payload.targetMarket.wordCountMin ?? 0}${payload.targetMarket.wordCountMax}\nSimultaneous subs allowed: ${payload.targetMarket.simultaneousSubs}\nGenres: ${payload.targetMarket.genres.join(', ')}\n${payload.targetMarket.notes ? `Notes: ${payload.targetMarket.notes}` : ''}\n=== END MARKET BRIEF ===`
: ''
const storyBlock = `\n\n=== STORY ===\n${payload.storyContent}\n=== END STORY ===`
const modeInstructions: Record<AnalysisMode, string> = {
chat: 'You are a literary editor helping with flash fiction. Answer questions about the story — craft, compression, character, market fit, or anything else the writer asks. Be specific and cite exact passages.',
compression: `You are a flash fiction editor specializing in compression. Identify every sentence, clause, or phrase carrying redundant or low-yield content. Flag passive constructions, hedging adverbs, and over-explained beats.
For each issue, use this EXACT format:
ISSUE: Compression
PASSAGE: "[exact verbatim text from the story]"
PROBLEM: [one sentence explaining what is bloated]
SUGGESTION: "[rewritten tighter version]"
Be rigorous — flash fiction tolerates no fat. List every instance, then give a brief summary.`,
ending: `You are a flash fiction editor evaluating the story's ending. Look at the final paragraph (or final sentence for very short pieces).
Evaluate:
- Is the weight right? Does it carry the emotional/thematic load of everything before it?
- Is it earned? Does the story build toward this moment?
- Does it close, open, or pivot — and is that the right choice for this story?
Write a prose critique of 150300 words. No annotation format — this is a direct assessment. Be honest about what isn't working and concrete about what would.`,
tone: `You are a flash fiction editor mapping tonal register. First, identify the dominant tone of the piece (e.g., dread, irony, tenderness, menace, melancholy). Then find every sentence that drifts out of that tone.
For each outlier, use this EXACT format:
ISSUE: Tone drift
PASSAGE: "[exact verbatim text from the story]"
PROBLEM: [one sentence: how does this sentence break the tonal register?]
SUGGESTION: "[rewritten version that holds the dominant tone]"
List all outliers, then name the dominant tone in a single closing sentence.`,
market_fit: `You are a literary submissions editor. Evaluate how well this flash fiction piece fits the target market described in the Market Brief above.
Provide:
1. A brief qualitative assessment (23 sentences): what aspects of the piece align with this market's aesthetic, and what works against it?
2. Two or three specific, actionable suggestions for bringing the piece closer to what this market publishes.
Be candid. If the fit is poor, say so plainly.`
}
return `You are an expert flash fiction editor. ${wordConstraint}${collectionSection}${marketSection}${storyBlock}\n\n${modeInstructions[payload.mode]}`
}
export async function streamMessage(
payload: AIPayload,
onChunk: (chunk: string) => void
): Promise<void> {
const client = getClient()
const messages: Anthropic.MessageParam[] = [
...payload.conversationHistory.slice(-10),
{ role: 'user', content: payload.userMessage }
]
const stream = client.messages.stream({
model: 'claude-sonnet-4-6',
max_tokens: 4096,
system: buildSystemPrompt(payload),
messages
})
for await (const chunk of stream) {
if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
onChunk(chunk.delta.text)
}
}
await stream.finalMessage()
}

321
src/main/fileSystem.ts Normal file
View File

@@ -0,0 +1,321 @@
import { readdir, readFile, writeFile, mkdir, unlink, rename as fsRename, rm } from 'fs/promises'
import { join, basename } from 'path'
import { getCollectionRoot } from './globalConfig'
// ─── Types ────────────────────────────────────────────────────────────────────
export interface StoryMeta {
title?: string
wordCountTarget?: number
tags?: string[]
notes?: string
}
export interface Market {
id: string
name: string
url?: string
wordCountMin?: number
wordCountMax: number
simultaneousSubs: boolean
responseTimeWeeks?: number
genres: string[]
notes?: string
active: boolean
}
export interface Submission {
id: string
storyId: string
marketId: string
submittedAt: string
status: 'pending' | 'withdrawn' | 'rejected' | 'accepted' | 'pending-revision'
statusUpdatedAt: string
notes?: string
simultaneous: boolean
}
export interface CollectionConfig {
stories: Record<string, StoryMeta>
collectionContext?: string
}
export interface RevisionMeta {
id: string
timestamp: number
wordCount: number
}
// ─── Path helpers ─────────────────────────────────────────────────────────────
const borgesDir = (): string => join(getCollectionRoot(), '.borges')
const configFile = (): string => join(borgesDir(), 'config.json')
const orderFile = (): string => join(borgesDir(), 'order.json')
const sessionFile = (): string => join(borgesDir(), 'session.json')
const marketsFile = (): string => join(borgesDir(), 'markets.json')
const submissionsFile = (): string => join(borgesDir(), 'submissions.json')
const revisionsDir = (): string => join(borgesDir(), 'revisions')
const MAX_REVISIONS = 50
function assertInCollection(filePath: string): void {
const root = getCollectionRoot()
const resolved = filePath.startsWith('/') ? filePath : join(root, filePath)
if (!resolved.startsWith(root)) throw new Error('Access denied: path outside collection')
}
// ─── Collection config ────────────────────────────────────────────────────────
async function readConfig(): Promise<CollectionConfig> {
try {
return JSON.parse(await readFile(configFile(), 'utf-8'))
} catch {
return { stories: {} }
}
}
async function writeConfig(cfg: CollectionConfig): Promise<void> {
await mkdir(borgesDir(), { recursive: true })
await writeFile(configFile(), JSON.stringify(cfg, null, 2), 'utf-8')
}
export async function getStoryMeta(storyId: string): Promise<StoryMeta> {
const cfg = await readConfig()
return cfg.stories[storyId] ?? {}
}
export async function setStoryMeta(storyId: string, meta: StoryMeta): Promise<void> {
const cfg = await readConfig()
cfg.stories[storyId] = { ...cfg.stories[storyId], ...meta }
await writeConfig(cfg)
}
export async function getCollectionConfig(): Promise<CollectionConfig> {
return readConfig()
}
export async function setCollectionContext(context: string): Promise<void> {
const cfg = await readConfig()
cfg.collectionContext = context
await writeConfig(cfg)
}
// ─── Story listing ────────────────────────────────────────────────────────────
export interface StoryFile {
id: string
path: string
wordCount: number
meta: StoryMeta
}
function countWords(text: string): number {
return text.trim() === '' ? 0 : text.trim().split(/\s+/).length
}
async function readOrderList(): Promise<string[]> {
try {
return JSON.parse(await readFile(orderFile(), 'utf-8'))
} catch {
return []
}
}
export async function saveOrderList(order: string[]): Promise<void> {
await mkdir(borgesDir(), { recursive: true })
await writeFile(orderFile(), JSON.stringify(order, null, 2), 'utf-8')
}
export async function listStories(): Promise<StoryFile[]> {
await mkdir(getCollectionRoot(), { recursive: true })
const [entries, order, cfg] = await Promise.all([
readdir(getCollectionRoot(), { withFileTypes: true }),
readOrderList(),
readConfig()
])
const mdFiles = entries.filter(
(e) => e.isFile() && e.name.endsWith('.md') && !e.name.startsWith('.')
)
const stories: StoryFile[] = await Promise.all(
mdFiles.map(async (e) => {
const id = e.name.replace(/\.md$/, '')
const path = join(getCollectionRoot(), e.name)
const content = await readFile(path, 'utf-8').catch(() => '')
return { id, path, wordCount: countWords(content), meta: cfg.stories[id] ?? {} }
})
)
const map = new Map(stories.map((s) => [s.id, s]))
const ordered = order.filter((id) => map.has(id)).map((id) => map.get(id)!)
const rest = stories.filter((s) => !order.includes(s.id)).sort((a, b) => a.id.localeCompare(b.id))
return [...ordered, ...rest]
}
// ─── Story CRUD ───────────────────────────────────────────────────────────────
export async function readStory(filePath: string): Promise<string> {
assertInCollection(filePath)
return readFile(filePath, 'utf-8')
}
export async function writeStory(filePath: string, content: string): Promise<void> {
assertInCollection(filePath)
await writeFile(filePath, content, 'utf-8')
}
export async function createStory(name: string): Promise<{ id: string; path: string }> {
const id = name
const path = join(getCollectionRoot(), `${name}.md`)
assertInCollection(path)
await writeFile(path, '', 'utf-8')
return { id, path }
}
export async function renameStory(oldPath: string, newName: string): Promise<string> {
assertInCollection(oldPath)
const newPath = join(getCollectionRoot(), `${newName}.md`)
assertInCollection(newPath)
await fsRename(oldPath, newPath)
// Update meta key
const cfg = await readConfig()
const oldId = basename(oldPath, '.md')
if (cfg.stories[oldId]) {
cfg.stories[newName] = cfg.stories[oldId]
delete cfg.stories[oldId]
await writeConfig(cfg)
}
return newPath
}
export async function deleteStory(filePath: string): Promise<void> {
assertInCollection(filePath)
await rm(filePath, { force: true })
const id = basename(filePath, '.md')
const cfg = await readConfig()
delete cfg.stories[id]
await writeConfig(cfg)
}
// ─── Session ──────────────────────────────────────────────────────────────────
export async function readSession(): Promise<Record<string, unknown>> {
try {
return JSON.parse(await readFile(sessionFile(), 'utf-8'))
} catch {
return {}
}
}
export async function writeSession(data: Record<string, unknown>): Promise<void> {
await mkdir(borgesDir(), { recursive: true })
await writeFile(sessionFile(), JSON.stringify(data), 'utf-8')
}
// ─── Markets ──────────────────────────────────────────────────────────────────
export async function listMarkets(): Promise<Market[]> {
try {
return JSON.parse(await readFile(marketsFile(), 'utf-8'))
} catch {
return []
}
}
export async function saveMarkets(markets: Market[]): Promise<void> {
await mkdir(borgesDir(), { recursive: true })
await writeFile(marketsFile(), JSON.stringify(markets, null, 2), 'utf-8')
}
export async function upsertMarket(market: Market): Promise<void> {
const markets = await listMarkets()
const idx = markets.findIndex((m) => m.id === market.id)
if (idx === -1) markets.push(market)
else markets[idx] = market
await saveMarkets(markets)
}
export async function deleteMarket(id: string): Promise<void> {
const markets = await listMarkets()
await saveMarkets(markets.filter((m) => m.id !== id))
}
// ─── Submissions ──────────────────────────────────────────────────────────────
export async function listSubmissions(): Promise<Submission[]> {
try {
return JSON.parse(await readFile(submissionsFile(), 'utf-8'))
} catch {
return []
}
}
export async function saveSubmissions(subs: Submission[]): Promise<void> {
await mkdir(borgesDir(), { recursive: true })
await writeFile(submissionsFile(), JSON.stringify(subs, null, 2), 'utf-8')
}
export async function addSubmission(sub: Submission): Promise<void> {
const subs = await listSubmissions()
subs.push(sub)
await saveSubmissions(subs)
}
export async function updateSubmission(id: string, updates: Partial<Submission>): Promise<void> {
const subs = await listSubmissions()
const idx = subs.findIndex((s) => s.id === id)
if (idx === -1) throw new Error('Submission not found')
subs[idx] = { ...subs[idx], ...updates }
await saveSubmissions(subs)
}
// ─── Revisions ────────────────────────────────────────────────────────────────
function storySlug(filePath: string): string {
return basename(filePath, '.md').replace(/[^a-zA-Z0-9_-]/g, '_')
}
function shortId(): string {
return Math.random().toString(36).slice(2, 7)
}
export async function saveRevision(filePath: string, content: string): Promise<void> {
assertInCollection(filePath)
const slug = storySlug(filePath)
const dir = join(revisionsDir(), slug)
await mkdir(dir, { recursive: true })
const timestamp = Date.now()
const id = `${timestamp}_${shortId()}`
await writeFile(join(dir, `${id}.json`), JSON.stringify({ id, timestamp, wordCount: countWords(content), content }), 'utf-8')
const entries = (await readdir(dir)).filter((e) => e.endsWith('.json')).sort()
if (entries.length > MAX_REVISIONS) {
await Promise.all(entries.slice(0, entries.length - MAX_REVISIONS).map((f) => unlink(join(dir, f))))
}
}
export async function listRevisions(filePath: string): Promise<RevisionMeta[]> {
assertInCollection(filePath)
const slug = storySlug(filePath)
const dir = join(revisionsDir(), slug)
try {
const entries = (await readdir(dir)).filter((e) => e.endsWith('.json')).sort().reverse()
return Promise.all(
entries.map(async (f) => {
const raw = JSON.parse(await readFile(join(dir, f), 'utf-8'))
return { id: raw.id, timestamp: raw.timestamp, wordCount: raw.wordCount }
})
)
} catch {
return []
}
}
export async function loadRevision(filePath: string, revisionId: string): Promise<string> {
assertInCollection(filePath)
if (!/^[\w-]+$/.test(revisionId)) throw new Error('Invalid revision ID')
const slug = storySlug(filePath)
const revPath = join(revisionsDir(), slug, `${revisionId}.json`)
const raw = JSON.parse(await readFile(revPath, 'utf-8'))
return raw.content
}

38
src/main/globalConfig.ts Normal file
View File

@@ -0,0 +1,38 @@
import { join } from 'path'
import { homedir } from 'os'
import { readFileSync, writeFileSync, mkdirSync } from 'fs'
export interface GlobalConfig {
apiKey?: string
collectionPath?: string
fontSize?: number
theme?: 'dark' | 'light'
defaultWordCountTarget?: number
}
const CONFIG_DIR = join(homedir(), '.borges')
const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
let _config: GlobalConfig = {}
try {
_config = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'))
} catch {
// first run
}
export const getCollectionRoot = (): string =>
_config.collectionPath ?? join(homedir(), 'Documents', 'borges-collection')
export const getApiKey = (): string | undefined =>
_config.apiKey ?? process.env.ANTHROPIC_API_KEY
export function readGlobalConfig(): GlobalConfig {
return { ..._config }
}
export function writeGlobalConfig(updates: Partial<GlobalConfig>): void {
_config = { ..._config, ...updates }
mkdirSync(CONFIG_DIR, { recursive: true })
writeFileSync(CONFIG_FILE, JSON.stringify(_config, null, 2), 'utf-8')
}

149
src/main/index.ts Normal file
View File

@@ -0,0 +1,149 @@
import { app, BrowserWindow, shell, nativeImage, Menu, dialog } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import { registerIpcHandlers } from './ipcHandlers'
import { getCollectionRoot } from './globalConfig'
app.setName('Borges')
function send(win: BrowserWindow, action: string): void {
if (!win.isDestroyed()) win.webContents.send('menu:action', action)
}
function buildAppMenu(win: BrowserWindow): void {
const isMac = process.platform === 'darwin'
const template: Electron.MenuItemConstructorOptions[] = [
...(isMac ? ([{
label: 'Borges',
submenu: [
{
label: 'About Borges',
click: () => dialog.showMessageBox(win, { type: 'info', title: 'Borges', message: 'Borges', detail: `Version ${app.getVersion()}\n\nFlash fiction writing, analysis, and submission tracking.` })
},
{ type: 'separator' },
{ label: 'Preferences…', accelerator: 'CmdOrCtrl+,', click: () => send(win, 'openSettings') },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' }
]
}] as Electron.MenuItemConstructorOptions[]) : []),
{
label: 'File',
submenu: [
{ label: 'New Story', accelerator: 'CmdOrCtrl+N', click: () => send(win, 'newStory') },
{ type: 'separator' },
{ label: 'Save', accelerator: 'CmdOrCtrl+S', click: () => send(win, 'save') },
{ type: 'separator' },
{ label: 'Open Collection Folder in Finder', click: () => shell.openPath(getCollectionRoot()) },
{ type: 'separator' },
...(!isMac ? ([
{ label: 'Preferences…', accelerator: 'CmdOrCtrl+,', click: () => send(win, 'openSettings') },
{ type: 'separator' }
] as Electron.MenuItemConstructorOptions[]) : []),
isMac ? { role: 'close' } : { role: 'quit' }
]
},
{
label: 'Edit',
submenu: [
{ role: 'undo' }, { role: 'redo' }, { type: 'separator' },
{ role: 'cut' }, { role: 'copy' }, { role: 'paste' },
{ type: 'separator' }, { role: 'selectAll' }
]
},
{
label: 'View',
submenu: [
{ label: 'Focus Mode', accelerator: 'CmdOrCtrl+Shift+G', click: () => send(win, 'toggleFocusMode') },
{ type: 'separator' },
{ label: 'Toggle Story List', accelerator: 'CmdOrCtrl+Shift+1', click: () => send(win, 'toggleSidebar') },
{ label: 'Toggle Submission Panel', accelerator: 'CmdOrCtrl+Shift+2', click: () => send(win, 'toggleSubmissionPanel') },
{ label: 'Toggle AI Chat', accelerator: 'CmdOrCtrl+Shift+3', click: () => send(win, 'toggleChat') },
{ label: 'Toggle Revision History', accelerator: 'CmdOrCtrl+Shift+R', click: () => send(win, 'toggleRevisions') },
{ type: 'separator' },
{ label: 'Toggle Dark Mode', click: () => send(win, 'toggleTheme') },
{ type: 'separator' },
{ label: 'Increase Font Size', accelerator: 'CmdOrCtrl+=', click: () => send(win, 'fontIncrease') },
{ label: 'Decrease Font Size', accelerator: 'CmdOrCtrl+-', click: () => send(win, 'fontDecrease') },
{ label: 'Reset Font Size', accelerator: 'CmdOrCtrl+0', click: () => send(win, 'fontReset') }
]
},
{
label: 'Window',
submenu: [
{ role: 'minimize' }, { role: 'zoom' },
...(isMac ? ([{ type: 'separator' }, { role: 'front' }] as Electron.MenuItemConstructorOptions[]) : [])
]
}
]
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
}
function createWindow(): BrowserWindow {
const icon = nativeImage.createFromPath(join(__dirname, '../../resources/icon.png'))
const mainWindow = new BrowserWindow({
width: 1440,
height: 900,
minWidth: 900,
minHeight: 600,
title: 'Borges',
titleBarStyle: 'hiddenInset',
icon,
show: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
}
})
mainWindow.on('ready-to-show', () => mainWindow.show())
mainWindow.on('enter-full-screen', () => mainWindow.setWindowButtonVisibility(false))
mainWindow.on('leave-full-screen', () => mainWindow.setWindowButtonVisibility(true))
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
return mainWindow
}
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.borges.editor')
if (process.platform === 'darwin') {
app.dock.setIcon(nativeImage.createFromPath(join(__dirname, '../../resources/icon.png')))
}
app.on('browser-window-created', (_, window) => optimizer.watchWindowShortcuts(window))
registerIpcHandlers()
const mainWindow = createWindow()
buildAppMenu(mainWindow)
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})

89
src/main/ipcHandlers.ts Normal file
View File

@@ -0,0 +1,89 @@
import { ipcMain, dialog, BrowserWindow } from 'electron'
import {
listStories, readStory, writeStory, createStory, renameStory, deleteStory,
getStoryMeta, setStoryMeta, getCollectionConfig, setCollectionContext,
saveOrderList, readSession, writeSession,
listMarkets, upsertMarket, deleteMarket,
listSubmissions, addSubmission, updateSubmission,
saveRevision, listRevisions, loadRevision
} from './fileSystem'
import type { Market, Submission, StoryMeta } from './fileSystem'
import { streamMessage, resetClient } from './aiService'
import type { AIPayload } from './aiService'
import { readGlobalConfig, writeGlobalConfig } from './globalConfig'
import type { GlobalConfig } from './globalConfig'
export function registerIpcHandlers(): void {
// ── Stories ──────────────────────────────────────────────────────────────────
ipcMain.handle('stories:list', async () => listStories())
ipcMain.handle('stories:read', async (_e, path: string) => readStory(path))
ipcMain.handle('stories:write', async (_e, path: string, content: string) => writeStory(path, content))
ipcMain.handle('stories:create', async (_e, name: string) => createStory(name))
ipcMain.handle('stories:rename', async (_e, oldPath: string, newName: string) => renameStory(oldPath, newName))
ipcMain.handle('stories:delete', async (_e, path: string) => deleteStory(path))
ipcMain.handle('stories:getMeta', async (_e, storyId: string) => getStoryMeta(storyId))
ipcMain.handle('stories:setMeta', async (_e, storyId: string, meta: StoryMeta) => setStoryMeta(storyId, meta))
ipcMain.handle('stories:saveOrder', async (_e, order: string[]) => saveOrderList(order))
// ── Collection config ─────────────────────────────────────────────────────────
ipcMain.handle('collection:getConfig', async () => getCollectionConfig())
ipcMain.handle('collection:setContext', async (_e, context: string) => setCollectionContext(context))
// ── Session ──────────────────────────────────────────────────────────────────
ipcMain.handle('session:read', async () => readSession())
ipcMain.handle('session:write', async (_e, data: Record<string, unknown>) => writeSession(data))
// ── Markets ──────────────────────────────────────────────────────────────────
ipcMain.handle('markets:list', async () => listMarkets())
ipcMain.handle('markets:upsert', async (_e, market: Market) => upsertMarket(market))
ipcMain.handle('markets:delete', async (_e, id: string) => deleteMarket(id))
// ── Submissions ──────────────────────────────────────────────────────────────
ipcMain.handle('submissions:list', async () => listSubmissions())
ipcMain.handle('submissions:add', async (_e, sub: Submission) => addSubmission(sub))
ipcMain.handle('submissions:update', async (_e, id: string, updates: Partial<Submission>) => updateSubmission(id, updates))
// ── Revisions ─────────────────────────────────────────────────────────────────
ipcMain.handle('revisions:save', async (_e, path: string, content: string) => saveRevision(path, content))
ipcMain.handle('revisions:list', async (_e, path: string) => listRevisions(path))
ipcMain.handle('revisions:load', async (_e, path: string, id: string) => loadRevision(path, id))
// ── Config ────────────────────────────────────────────────────────────────────
ipcMain.handle('config:read', async () => readGlobalConfig())
ipcMain.handle('config:write', async (_e, updates: Partial<GlobalConfig>) => {
writeGlobalConfig(updates)
if (updates.apiKey !== undefined) resetClient()
})
ipcMain.handle('config:pickFolder', async (event): Promise<string | null> => {
const win = BrowserWindow.fromWebContents(event.sender)
const result = await dialog.showOpenDialog(win!, { properties: ['openDirectory'] })
return result.canceled ? null : result.filePaths[0]
})
// ── AI streaming ──────────────────────────────────────────────────────────────
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
try {
let pending = ''
let flushTimer: ReturnType<typeof setTimeout> | null = null
const flush = (): void => {
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null }
if (pending && !event.sender.isDestroyed()) {
event.sender.send('ai:chunk', pending)
pending = ''
}
}
await streamMessage(payload, (chunk: string) => {
pending += chunk
if (!flushTimer) flushTimer = setTimeout(flush, 30)
})
flush()
if (!event.sender.isDestroyed()) event.sender.send('ai:done')
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (!event.sender.isDestroyed()) event.sender.send('ai:error', message)
}
})
}