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

@@ -7,15 +7,20 @@ import { useEditorStore } from './store/editorStore'
import './styles/app.css'
export default function App(): JSX.Element {
const { setFileTree, activeFilePath, isDirty, markSaved, activeFileContent, theme, toggleTheme } =
const { setFileTree, activeFilePath, isDirty, markSaved, activeFileContent, theme, toggleTheme, loadSession } =
useEditorStore()
const [sidebarOpen, setSidebarOpen] = useState(true)
const [chatOpen, setChatOpen] = useState(true)
const [sidebarOpen, setSidebarOpen] = useState(
() => 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(() => {
window.api.listFiles().then(setFileTree)
document.documentElement.classList.toggle('light', theme === 'light')
loadSession()
}, [])
// Handle Cmd+S / Ctrl+S
@@ -45,13 +50,13 @@ export default function App(): JSX.Element {
<div className="app-layout-toggle">
<button
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'}
/>
<div className="app-layout-toggle-seg app-layout-toggle-seg--mid" />
<button
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'}
/>
</div>

View File

@@ -306,7 +306,7 @@ function buildTheme(fontSize: number, dark: boolean): ReturnType<typeof EditorVi
export function MarkdownEditor(): JSX.Element {
const containerRef = useRef<HTMLDivElement>(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
useEffect(() => {
@@ -352,6 +352,17 @@ export function MarkdownEditor(): JSX.Element {
viewRef.current = 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)
if (import.meta.env.DEV) {
const w = window as unknown as Record<string, unknown>
@@ -363,13 +374,15 @@ export function MarkdownEditor(): JSX.Element {
})
}
return () => {
view.scrollDOM.removeEventListener('scroll', onScroll)
if (scrollTimer) clearTimeout(scrollTimer)
view.destroy()
viewRef.current = null
currentEditorView = null
}
}, []) // 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(() => {
const view = viewRef.current
if (!view) return
@@ -381,9 +394,13 @@ export function MarkdownEditor(): JSX.Element {
changes: { from: 0, to: current.length, insert: activeFileContent },
annotations: Transaction.addToHistory.of(false)
})
// Scroll to top on file switch
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

View File

@@ -1,6 +1,11 @@
import { create } from 'zustand'
import type { FileNode, ChatMessage, TextAnnotation, AnalysisMode } from '../types/editor'
interface AnnotationFileState {
mode: AnalysisMode
annotations: TextAnnotation[]
}
interface EditorState {
// File tree
fileTree: FileNode[]
@@ -26,8 +31,9 @@ interface EditorState {
setAIError: (error: string | null) => void
clearChat: () => void
// Annotations (highlights in editor)
// Annotations (highlights in editor) — also persisted per file
annotations: TextAnnotation[]
annotationsByFile: Record<string, AnnotationFileState>
setAnnotations: (annotations: TextAnnotation[]) => void
clearAnnotations: () => void
@@ -35,6 +41,10 @@ interface EditorState {
analysisMode: AnalysisMode
setAnalysisMode: (mode: AnalysisMode) => void
// Scroll positions per file
scrollPositions: Record<string, number>
setScrollPosition: (filePath: string, scrollTop: number) => void
// File tree reordering
moveNode: (dirPath: string, fromIdx: number, toIdx: number) => void
@@ -49,6 +59,19 @@ interface EditorState {
// Theme
theme: 'dark' | 'light'
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) => ({
@@ -90,14 +113,25 @@ export const useEditorStore = create<EditorState>((set, get) => ({
activeFileContent: '',
isDirty: false,
setActiveFile: (path, content) => {
const existing = get().chatHistoryByFile[path] ?? []
const s = get()
const existing = s.chatHistoryByFile[path] ?? []
const savedAnnotationState = s.annotationsByFile[path]
set({
activeFilePath: path,
activeFileContent: content,
isDirty: false,
chatHistory: existing,
annotations: [],
analysisMode: 'none'
annotations: savedAnnotationState?.annotations ?? [],
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 }),
@@ -117,6 +151,15 @@ export const useEditorStore = create<EditorState>((set, get) => ({
: s.chatHistoryByFile
return { chatHistory: history, chatHistoryByFile: byFile }
})
scheduleSave(() => {
const st = get()
return {
activeFilePath: st.activeFilePath,
scrollPositions: st.scrollPositions,
chatHistoryByFile: st.chatHistoryByFile,
annotationsByFile: st.annotationsByFile
}
})
},
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 }),
clearChat: () => {
@@ -154,14 +211,77 @@ export const useEditorStore = create<EditorState>((set, get) => ({
: s.chatHistoryByFile
return { chatHistory: [], chatHistoryByFile: byFile }
})
scheduleSave(() => {
const st = get()
return {
activeFilePath: st.activeFilePath,
scrollPositions: st.scrollPositions,
chatHistoryByFile: st.chatHistoryByFile,
annotationsByFile: st.annotationsByFile
}
})
},
annotations: [],
setAnnotations: (annotations) => set({ annotations }),
clearAnnotations: () => set({ annotations: [], analysisMode: 'none' }),
annotationsByFile: {},
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',
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,
setProjectWordCount: (projectWordCount) => set({ projectWordCount }),
@@ -181,5 +301,38 @@ export const useEditorStore = create<EditorState>((set, get) => ({
document.documentElement.classList.toggle('light', next === 'light')
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
}
}
}))