session saving

This commit is contained in:
2026-02-24 18:35:03 +10:00
parent 1cbef965af
commit ee048084b0
8 changed files with 224 additions and 22 deletions

View File

@@ -6,6 +6,7 @@ const DRAFT_ROOT =
process.env.DRAFT_PATH ?? '/Users/pori/WebstormProjects/hohoff/draft' process.env.DRAFT_PATH ?? '/Users/pori/WebstormProjects/hohoff/draft'
const ORDER_FILE = join(DRAFT_ROOT, '.order.json') const ORDER_FILE = join(DRAFT_ROOT, '.order.json')
const SESSION_FILE = join(DRAFT_ROOT, '.session.json')
const PART_ORDER = ['Prologue', 'Content Warning', 'Part I', 'Part II', 'Part III', 'Part IV', 'Epilogue', 'The first time'] const PART_ORDER = ['Prologue', 'Content Warning', 'Part I', 'Part II', 'Part III', 'Part IV', 'Epilogue', 'The first time']
@@ -30,6 +31,18 @@ export async function saveOrderFile(order: Record<string, string[]>): Promise<vo
await writeFile(ORDER_FILE, JSON.stringify(order, null, 2), 'utf-8') await writeFile(ORDER_FILE, JSON.stringify(order, null, 2), 'utf-8')
} }
export async function readSession(): Promise<Record<string, unknown>> {
try {
return JSON.parse(await readFile(SESSION_FILE, 'utf-8'))
} catch {
return {}
}
}
export async function writeSession(data: Record<string, unknown>): Promise<void> {
await writeFile(SESSION_FILE, JSON.stringify(data), 'utf-8')
}
function applyOrder(nodes: FileNode[], savedNames: string[]): FileNode[] { function applyOrder(nodes: FileNode[], savedNames: string[]): FileNode[] {
const map = new Map(nodes.map((n) => [n.name, n])) const map = new Map(nodes.map((n) => [n.name, n]))
const ordered = savedNames.filter((n) => map.has(n)).map((n) => map.get(n)!) const ordered = savedNames.filter((n) => map.has(n)).map((n) => map.get(n)!)

View File

@@ -1,5 +1,5 @@
import { ipcMain } from 'electron' import { ipcMain } from 'electron'
import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile } from './fileSystem' import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession } from './fileSystem'
import { streamMessage } from './aiService' import { streamMessage } from './aiService'
import type { AIPayload } from '../renderer/types/editor' import type { AIPayload } from '../renderer/types/editor'
@@ -24,6 +24,14 @@ export function registerIpcHandlers(): void {
await saveOrderFile(order) await saveOrderFile(order)
}) })
ipcMain.handle('session:read', async () => {
return await readSession()
})
ipcMain.handle('session:write', async (_event, data: Record<string, unknown>) => {
await writeSession(data)
})
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => { ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
try { try {
await streamMessage(payload, (chunk: string) => { await streamMessage(payload, (chunk: string) => {

View File

@@ -48,5 +48,11 @@ contextBridge.exposeInMainWorld('api', {
getProjectWordCount: (): Promise<number> => ipcRenderer.invoke('fs:projectWordCount'), getProjectWordCount: (): Promise<number> => ipcRenderer.invoke('fs:projectWordCount'),
saveOrder: (order: Record<string, string[]>): Promise<void> => saveOrder: (order: Record<string, string[]>): Promise<void> =>
ipcRenderer.invoke('fs:saveOrder', order) ipcRenderer.invoke('fs:saveOrder', order),
readSession: (): Promise<Record<string, unknown>> =>
ipcRenderer.invoke('session:read'),
writeSession: (data: Record<string, unknown>): Promise<void> =>
ipcRenderer.invoke('session:write', data)
}) })

View File

@@ -7,15 +7,20 @@ import { useEditorStore } from './store/editorStore'
import './styles/app.css' import './styles/app.css'
export default function App(): JSX.Element { export default function App(): JSX.Element {
const { setFileTree, activeFilePath, isDirty, markSaved, activeFileContent, theme, toggleTheme } = const { setFileTree, activeFilePath, isDirty, markSaved, activeFileContent, theme, toggleTheme, loadSession } =
useEditorStore() useEditorStore()
const [sidebarOpen, setSidebarOpen] = useState(true) const [sidebarOpen, setSidebarOpen] = useState(
const [chatOpen, setChatOpen] = useState(true) () => localStorage.getItem('sidebarOpen') !== 'false'
)
const [chatOpen, setChatOpen] = useState(
() => localStorage.getItem('chatOpen') !== 'false'
)
// Load file tree on mount and apply persisted theme // Load file tree, apply persisted theme, and restore last session
useEffect(() => { useEffect(() => {
window.api.listFiles().then(setFileTree) window.api.listFiles().then(setFileTree)
document.documentElement.classList.toggle('light', theme === 'light') document.documentElement.classList.toggle('light', theme === 'light')
loadSession()
}, []) }, [])
// Handle Cmd+S / Ctrl+S // Handle Cmd+S / Ctrl+S
@@ -45,13 +50,13 @@ export default function App(): JSX.Element {
<div className="app-layout-toggle"> <div className="app-layout-toggle">
<button <button
className={`app-layout-toggle-seg${sidebarOpen ? ' active' : ''}`} className={`app-layout-toggle-seg${sidebarOpen ? ' active' : ''}`}
onClick={() => setSidebarOpen((v) => !v)} onClick={() => setSidebarOpen((v) => { const next = !v; localStorage.setItem('sidebarOpen', String(next)); return next })}
title={sidebarOpen ? 'Hide file tree' : 'Show file tree'} title={sidebarOpen ? 'Hide file tree' : 'Show file tree'}
/> />
<div className="app-layout-toggle-seg app-layout-toggle-seg--mid" /> <div className="app-layout-toggle-seg app-layout-toggle-seg--mid" />
<button <button
className={`app-layout-toggle-seg${chatOpen ? ' active' : ''}`} className={`app-layout-toggle-seg${chatOpen ? ' active' : ''}`}
onClick={() => setChatOpen((v) => !v)} onClick={() => setChatOpen((v) => { const next = !v; localStorage.setItem('chatOpen', String(next)); return next })}
title={chatOpen ? 'Hide AI chat' : 'Show AI chat'} title={chatOpen ? 'Hide AI chat' : 'Show AI chat'}
/> />
</div> </div>

View File

@@ -306,7 +306,7 @@ function buildTheme(fontSize: number, dark: boolean): ReturnType<typeof EditorVi
export function MarkdownEditor(): JSX.Element { export function MarkdownEditor(): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null) const viewRef = useRef<EditorView | null>(null)
const { activeFilePath, activeFileContent, setContent, annotations, fontSize, theme } = useEditorStore() const { activeFilePath, activeFileContent, setContent, annotations, fontSize, theme, scrollPositions } = useEditorStore()
// Initialize CodeMirror once // Initialize CodeMirror once
useEffect(() => { useEffect(() => {
@@ -352,6 +352,17 @@ export function MarkdownEditor(): JSX.Element {
viewRef.current = view viewRef.current = view
currentEditorView = view currentEditorView = view
// Track scroll position — debounced so we don't thrash IPC on every pixel
let scrollTimer: ReturnType<typeof setTimeout> | null = null
const onScroll = (): void => {
if (scrollTimer) clearTimeout(scrollTimer)
scrollTimer = setTimeout(() => {
const { activeFilePath: fp, setScrollPosition: save } = useEditorStore.getState()
if (fp) save(fp, view.scrollDOM.scrollTop)
}, 300)
}
view.scrollDOM.addEventListener('scroll', onScroll, { passive: true })
// Expose view + undo for dev-mode testing (stripped in production) // Expose view + undo for dev-mode testing (stripped in production)
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
const w = window as unknown as Record<string, unknown> const w = window as unknown as Record<string, unknown>
@@ -363,13 +374,15 @@ export function MarkdownEditor(): JSX.Element {
}) })
} }
return () => { return () => {
view.scrollDOM.removeEventListener('scroll', onScroll)
if (scrollTimer) clearTimeout(scrollTimer)
view.destroy() view.destroy()
viewRef.current = null viewRef.current = null
currentEditorView = null currentEditorView = null
} }
}, []) // eslint-disable-line react-hooks/exhaustive-deps }, []) // eslint-disable-line react-hooks/exhaustive-deps
// When the active file changes, replace editor content // When the active file changes, replace editor content and restore scroll
useEffect(() => { useEffect(() => {
const view = viewRef.current const view = viewRef.current
if (!view) return if (!view) return
@@ -381,9 +394,13 @@ export function MarkdownEditor(): JSX.Element {
changes: { from: 0, to: current.length, insert: activeFileContent }, changes: { from: 0, to: current.length, insert: activeFileContent },
annotations: Transaction.addToHistory.of(false) annotations: Transaction.addToHistory.of(false)
}) })
// Scroll to top on file switch
view.dispatch({ selection: { anchor: 0 } }) view.dispatch({ selection: { anchor: 0 } })
view.scrollDOM.scrollTop = 0 // Restore saved scroll position, or go to top for new files
const savedScroll = activeFilePath ? scrollPositions[activeFilePath] ?? 0 : 0
// Defer scroll restoration so CodeMirror finishes laying out the new content
requestAnimationFrame(() => {
view.scrollDOM.scrollTop = savedScroll
})
} }
}, [activeFilePath]) // Only sync on file switch }, [activeFilePath]) // Only sync on file switch

View File

@@ -1,6 +1,11 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { FileNode, ChatMessage, TextAnnotation, AnalysisMode } from '../types/editor' import type { FileNode, ChatMessage, TextAnnotation, AnalysisMode } from '../types/editor'
interface AnnotationFileState {
mode: AnalysisMode
annotations: TextAnnotation[]
}
interface EditorState { interface EditorState {
// File tree // File tree
fileTree: FileNode[] fileTree: FileNode[]
@@ -26,8 +31,9 @@ interface EditorState {
setAIError: (error: string | null) => void setAIError: (error: string | null) => void
clearChat: () => void clearChat: () => void
// Annotations (highlights in editor) // Annotations (highlights in editor) — also persisted per file
annotations: TextAnnotation[] annotations: TextAnnotation[]
annotationsByFile: Record<string, AnnotationFileState>
setAnnotations: (annotations: TextAnnotation[]) => void setAnnotations: (annotations: TextAnnotation[]) => void
clearAnnotations: () => void clearAnnotations: () => void
@@ -35,6 +41,10 @@ interface EditorState {
analysisMode: AnalysisMode analysisMode: AnalysisMode
setAnalysisMode: (mode: AnalysisMode) => void setAnalysisMode: (mode: AnalysisMode) => void
// Scroll positions per file
scrollPositions: Record<string, number>
setScrollPosition: (filePath: string, scrollTop: number) => void
// File tree reordering // File tree reordering
moveNode: (dirPath: string, fromIdx: number, toIdx: number) => void moveNode: (dirPath: string, fromIdx: number, toIdx: number) => void
@@ -49,6 +59,19 @@ interface EditorState {
// Theme // Theme
theme: 'dark' | 'light' theme: 'dark' | 'light'
toggleTheme: () => void toggleTheme: () => void
// Session persistence
loadSession: () => Promise<void>
}
// Debounced session writer — coalesces rapid changes into one write
let _sessionTimer: ReturnType<typeof setTimeout> | null = null
function scheduleSave(getData: () => Record<string, unknown>): void {
if (_sessionTimer) clearTimeout(_sessionTimer)
_sessionTimer = setTimeout(() => {
const api = (window as unknown as { api?: { writeSession: (d: Record<string, unknown>) => Promise<void> } }).api
api?.writeSession(getData()).catch(console.error)
}, 1500)
} }
export const useEditorStore = create<EditorState>((set, get) => ({ export const useEditorStore = create<EditorState>((set, get) => ({
@@ -90,14 +113,25 @@ export const useEditorStore = create<EditorState>((set, get) => ({
activeFileContent: '', activeFileContent: '',
isDirty: false, isDirty: false,
setActiveFile: (path, content) => { setActiveFile: (path, content) => {
const existing = get().chatHistoryByFile[path] ?? [] const s = get()
const existing = s.chatHistoryByFile[path] ?? []
const savedAnnotationState = s.annotationsByFile[path]
set({ set({
activeFilePath: path, activeFilePath: path,
activeFileContent: content, activeFileContent: content,
isDirty: false, isDirty: false,
chatHistory: existing, chatHistory: existing,
annotations: [], annotations: savedAnnotationState?.annotations ?? [],
analysisMode: 'none' analysisMode: savedAnnotationState?.mode ?? 'none'
})
scheduleSave(() => {
const st = get()
return {
activeFilePath: st.activeFilePath,
scrollPositions: st.scrollPositions,
chatHistoryByFile: st.chatHistoryByFile,
annotationsByFile: st.annotationsByFile
}
}) })
}, },
setContent: (content) => set({ activeFileContent: content, isDirty: true }), setContent: (content) => set({ activeFileContent: content, isDirty: true }),
@@ -117,6 +151,15 @@ export const useEditorStore = create<EditorState>((set, get) => ({
: s.chatHistoryByFile : s.chatHistoryByFile
return { chatHistory: history, chatHistoryByFile: byFile } return { chatHistory: history, chatHistoryByFile: byFile }
}) })
scheduleSave(() => {
const st = get()
return {
activeFilePath: st.activeFilePath,
scrollPositions: st.scrollPositions,
chatHistoryByFile: st.chatHistoryByFile,
annotationsByFile: st.annotationsByFile
}
})
}, },
startAssistantMessage: () => { startAssistantMessage: () => {
@@ -144,7 +187,21 @@ export const useEditorStore = create<EditorState>((set, get) => ({
}) })
}, },
setAILoading: (isAILoading) => set({ isAILoading }), setAILoading: (isAILoading) => {
set({ isAILoading })
// When AI finishes, persist the completed chat history
if (!isAILoading) {
scheduleSave(() => {
const st = get()
return {
activeFilePath: st.activeFilePath,
scrollPositions: st.scrollPositions,
chatHistoryByFile: st.chatHistoryByFile,
annotationsByFile: st.annotationsByFile
}
})
}
},
setAIError: (aiError) => set({ aiError }), setAIError: (aiError) => set({ aiError }),
clearChat: () => { clearChat: () => {
@@ -154,14 +211,77 @@ export const useEditorStore = create<EditorState>((set, get) => ({
: s.chatHistoryByFile : s.chatHistoryByFile
return { chatHistory: [], chatHistoryByFile: byFile } return { chatHistory: [], chatHistoryByFile: byFile }
}) })
scheduleSave(() => {
const st = get()
return {
activeFilePath: st.activeFilePath,
scrollPositions: st.scrollPositions,
chatHistoryByFile: st.chatHistoryByFile,
annotationsByFile: st.annotationsByFile
}
})
}, },
annotations: [], annotations: [],
setAnnotations: (annotations) => set({ annotations }), annotationsByFile: {},
clearAnnotations: () => set({ annotations: [], analysisMode: 'none' }), setAnnotations: (annotations) => {
set((s) => {
const annotationsByFile = s.activeFilePath
? { ...s.annotationsByFile, [s.activeFilePath]: { mode: s.analysisMode, annotations } }
: s.annotationsByFile
return { annotations, annotationsByFile }
})
scheduleSave(() => {
const st = get()
return {
activeFilePath: st.activeFilePath,
scrollPositions: st.scrollPositions,
chatHistoryByFile: st.chatHistoryByFile,
annotationsByFile: st.annotationsByFile
}
})
},
clearAnnotations: () => {
set((s) => {
const annotationsByFile = s.activeFilePath
? { ...s.annotationsByFile, [s.activeFilePath]: { mode: 'none' as AnalysisMode, annotations: [] } }
: s.annotationsByFile
return { annotations: [], analysisMode: 'none', annotationsByFile }
})
scheduleSave(() => {
const st = get()
return {
activeFilePath: st.activeFilePath,
scrollPositions: st.scrollPositions,
chatHistoryByFile: st.chatHistoryByFile,
annotationsByFile: st.annotationsByFile
}
})
},
analysisMode: 'none', analysisMode: 'none',
setAnalysisMode: (analysisMode) => set({ analysisMode }), setAnalysisMode: (analysisMode) => {
set((s) => {
const annotationsByFile = s.activeFilePath
? { ...s.annotationsByFile, [s.activeFilePath]: { mode: analysisMode, annotations: s.annotations } }
: s.annotationsByFile
return { analysisMode, annotationsByFile }
})
},
scrollPositions: {},
setScrollPosition: (filePath, scrollTop) => {
set((s) => ({ scrollPositions: { ...s.scrollPositions, [filePath]: scrollTop } }))
scheduleSave(() => {
const st = get()
return {
activeFilePath: st.activeFilePath,
scrollPositions: st.scrollPositions,
chatHistoryByFile: st.chatHistoryByFile,
annotationsByFile: st.annotationsByFile
}
})
},
projectWordCount: 0, projectWordCount: 0,
setProjectWordCount: (projectWordCount) => set({ projectWordCount }), setProjectWordCount: (projectWordCount) => set({ projectWordCount }),
@@ -181,5 +301,38 @@ export const useEditorStore = create<EditorState>((set, get) => ({
document.documentElement.classList.toggle('light', next === 'light') document.documentElement.classList.toggle('light', next === 'light')
return { theme: next } return { theme: next }
}) })
},
loadSession: async () => {
const api = (window as unknown as { api?: { readSession: () => Promise<Record<string, unknown>>; readFile: (p: string) => Promise<string> } }).api
if (!api) return
try {
const data = await api.readSession()
const patch: Partial<EditorState> = {}
if (data.scrollPositions && typeof data.scrollPositions === 'object') {
patch.scrollPositions = data.scrollPositions as Record<string, number>
}
if (data.chatHistoryByFile && typeof data.chatHistoryByFile === 'object') {
patch.chatHistoryByFile = data.chatHistoryByFile as Record<string, ChatMessage[]>
}
if (data.annotationsByFile && typeof data.annotationsByFile === 'object') {
patch.annotationsByFile = data.annotationsByFile as Record<string, AnnotationFileState>
}
set(patch)
// Restore active file last so setActiveFile can read the patched annotationsByFile
if (typeof data.activeFilePath === 'string') {
try {
const content = await api.readFile(data.activeFilePath)
get().setActiveFile(data.activeFilePath, content)
} catch {
// File may have been moved/deleted — open nothing
}
}
} catch {
// No session yet — start fresh
}
} }
})) }))

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long