✨ session saving
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user