import { create } from 'zustand'; import { v4 as uuidv4 } from 'uuid'; import { FileNode, getFiles, saveFile, deleteFile } from '../lib/db'; import { arrayMove } from '@dnd-kit/sortable'; interface FileStore { files: FileNode[]; activeFileId: string | null; selectedIds: string[]; loadFiles: () => Promise; setActiveFile: (id: string | null, skipHistory?: boolean) => void; navigationHistory: string[]; historyIndex: number; goBack: () => void; goForward: () => void; toggleSelect: (id: string, multi: boolean) => void; clearSelection: () => void; createNode: (name: string, type: 'file' | 'folder', parentId: string | null) => Promise; renameNode: (id: string, newName: string) => Promise; deleteNode: (id: string) => Promise; duplicateNode: (id: string) => Promise; updateFileContent: (id: string, content: string) => Promise; moveNode: (id: string, newParentId: string | null) => Promise; reorderNodes: (activeId: string, overId: string, newParentId: string | null) => Promise; setNodeIcon: (id: string, icon: string) => Promise; setNodeColor: (id: string, color: string) => Promise; setComment: (fileId: string, lineId: string, comment: string) => Promise; copySelectedContents: () => string; copyNodeContents: (id: string) => string; } export const useFileStore = create((set, get) => ({ files: [], activeFileId: null, selectedIds: [], navigationHistory: [], historyIndex: -1, loadFiles: async () => { const data = await getFiles(); // Sort all files by orderIndex initially const sorted = [...data].sort((a, b) => a.orderIndex - b.orderIndex); set({ files: sorted }); }, setActiveFile: (id, skipHistory = false) => { const { navigationHistory, historyIndex } = get(); if (id && !skipHistory) { const newHistory = navigationHistory.slice(0, historyIndex + 1); // Only push if it's different from current if (newHistory[newHistory.length - 1] !== id) { newHistory.push(id); // Limit history to 50 items if (newHistory.length > 50) newHistory.shift(); set({ activeFileId: id, selectedIds: [id], navigationHistory: newHistory, historyIndex: newHistory.length - 1 }); return; } } set({ activeFileId: id, selectedIds: id ? [id] : [] }); }, goBack: () => { const { navigationHistory, historyIndex, setActiveFile } = get(); if (historyIndex > 0) { const newIndex = historyIndex - 1; set({ historyIndex: newIndex }); setActiveFile(navigationHistory[newIndex], true); } }, goForward: () => { const { navigationHistory, historyIndex, setActiveFile } = get(); if (historyIndex < navigationHistory.length - 1) { const newIndex = historyIndex + 1; set({ historyIndex: newIndex }); setActiveFile(navigationHistory[newIndex], true); } }, toggleSelect: (id, multi) => { const { selectedIds } = get(); if (multi) { set({ selectedIds: selectedIds.includes(id) ? selectedIds.filter(i => i !== id) : [...selectedIds, id] }); } else { set({ selectedIds: [id], activeFileId: id }); } }, clearSelection: () => set({ selectedIds: [] }), createNode: async (name, type, parentId) => { const { files } = get(); // Find max orderIndex in current parent const peerIndices = files.filter(f => f.parentId === parentId).map(f => f.orderIndex); const nextIndex = peerIndices.length > 0 ? Math.max(...peerIndices) + 1 : 0; const newNode: FileNode = { id: uuidv4(), name, type, parentId, content: type === 'file' ? '' : undefined, orderIndex: nextIndex }; await saveFile(newNode); set(state => ({ files: [...state.files, newNode] })); return newNode.id; }, renameNode: async (id, newName) => { const { files } = get(); const node = files.find(f => f.id === id); if (!node) return; const updated = { ...node, name: newName }; await saveFile(updated); set(state => ({ files: state.files.map(f => f.id === id ? updated : f) })); }, deleteNode: async (id) => { const { files, activeFileId, selectedIds } = get(); // Recursive delete logic const toDelete = new Set(); const findChildren = (nodeId: string) => { toDelete.add(nodeId); files.forEach(f => { if (f.parentId === nodeId) findChildren(f.id); }); }; findChildren(id); // DB delete for (const delId of toDelete) { await deleteFile(delId); } set(state => ({ files: state.files.filter(f => !toDelete.has(f.id)), activeFileId: activeFileId && toDelete.has(activeFileId) ? null : state.activeFileId, selectedIds: selectedIds.filter(sid => !toDelete.has(sid)), })); }, duplicateNode: async (id) => { const { files, createNode } = get(); const node = files.find(f => f.id === id); if (!node) return; await createNode(`${node.name} (copy)`, node.type, node.parentId); }, // Write queue for updating contents to prevent rapid IDB transaction crashes updateFileContent: (() => { let writeQueue = Promise.resolve(); return async (id: string, content: string) => { writeQueue = writeQueue.then(async () => { const { files } = get(); const node = files.find(f => f.id === id); if (!node) return; const updated = { ...node, content }; await saveFile(updated); set(state => ({ files: state.files.map(f => f.id === id ? updated : f) })); }).catch(console.error); return writeQueue; }; })(), moveNode: async (id, newParentId) => { const { files } = get(); const node = files.find(f => f.id === id); if (!node) return; // Auto-calculate new index at end of target parent const peerIndices = files.filter(f => f.parentId === newParentId).map(f => f.orderIndex); const nextIndex = peerIndices.length > 0 ? Math.max(...peerIndices) + 1 : 0; const updated = { ...node, parentId: newParentId, orderIndex: nextIndex }; await saveFile(updated); set(state => ({ files: state.files.map(f => f.id === id ? updated : f) })); }, reorderNodes: async (activeId, overId, newParentId) => { const { files } = get(); const activeNode = files.find(f => f.id === activeId); if (!activeNode) return; const peerNodes = files.filter(f => f.parentId === newParentId).sort((a,b) => a.orderIndex - b.orderIndex); const oldIndex = peerNodes.findIndex(n => n.id === activeId); const newIndex = peerNodes.findIndex(n => n.id === overId); if (oldIndex !== -1 && newIndex !== -1) { const newOrder = arrayMove(peerNodes, oldIndex, newIndex); // Batch update all peer indices const updates = newOrder.map((node, idx) => ({ ...node, orderIndex: idx, parentId: newParentId })); for (const updated of updates) { await saveFile(updated); } set(state => ({ files: state.files.map(f => { const match = updates.find(u => u.id === f.id); return match || f; }) })); } else if (activeNode.parentId !== newParentId) { // Different parent move await get().moveNode(activeId, newParentId); } }, copySelectedContents: () => { const { files, selectedIds } = get(); const contents: string[] = []; const addNodeContent = (id: string, depth = 0) => { const node = files.find(f => f.id === id); if (!node) return; if (node.type === 'folder') { files.filter(f => f.parentId === id) .sort((a,b) => a.orderIndex - b.orderIndex) .forEach(child => addNodeContent(child.id, depth + 1)); } else { try { const parsed = JSON.parse(node.content || '{}'); const extractText = (nodes: any[]): string => { return nodes?.map(n => { if (n.type === 'linebreak') return '\n'; if (n.children) return extractText(n.children) + (n.type === 'paragraph' ? '\n' : ''); return n.text || ''; }).join('') || ''; }; const plainText = extractText(parsed.root?.children || []); const prefixed = plainText.split('\n').map(line => line.trim().length > 0 ? `— ${line.trim()}` : line).join('\n'); if (prefixed) contents.push(prefixed); } catch { if (node.content && node.content.trim().length > 0) { const prefixed = node.content.split('\n').map(line => line.trim().length > 0 ? `— ${line.trim()}` : line).join('\n'); contents.push(prefixed); } } } }; selectedIds.forEach(id => addNodeContent(id)); return contents.join('\n'); }, copyNodeContents: (id) => { const { files } = get(); const contents: string[] = []; const addNodeContent = (nodeId: string, depth = 0) => { const node = files.find(f => f.id === nodeId); if (!node) return; if (node.type === 'folder') { files.filter(f => f.parentId === nodeId) .sort((a,b) => a.orderIndex - b.orderIndex) .forEach(child => addNodeContent(child.id, depth + 1)); } else { try { const parsed = JSON.parse(node.content || '{}'); const extractText = (nodes: any[]): string => { return nodes?.map(n => { if (n.type === 'linebreak') return '\n'; if (n.children) return extractText(n.children) + (n.type === 'paragraph' ? '\n' : ''); return n.text || ''; }).join('') || ''; }; const plainText = extractText(parsed.root?.children || []); const prefixed = plainText.split('\n').map(line => line.trim().length > 0 ? `— ${line.trim()}` : line).join('\n'); if (prefixed) contents.push(prefixed); } catch { if (node.content && node.content.trim().length > 0) { const prefixed = node.content.split('\n').map(line => line.trim().length > 0 ? `— ${line.trim()}` : line).join('\n'); contents.push(prefixed); } } } }; addNodeContent(id); return contents.join('\n'); }, setNodeIcon: async (id, icon) => { const { files } = get(); const node = files.find(f => f.id === id); if (!node) return; const updated = { ...node, icon }; await saveFile(updated); set(state => ({ files: state.files.map(f => f.id === id ? updated : f) })); }, setNodeColor: async (id, color) => { const { files } = get(); const node = files.find(f => f.id === id); if (!node) return; const updated = { ...node, color }; await saveFile(updated); set(state => ({ files: state.files.map(f => f.id === id ? updated : f) })); }, setComment: async (fileId, lineId, comment) => { const { files } = get(); const node = files.find(f => f.id === fileId); if (!node) return; const newComments = { ...node.comments, [lineId]: comment }; if (!comment) delete newComments[lineId]; const updated = { ...node, comments: newComments }; await saveFile(updated); set(state => ({ files: state.files.map(f => f.id === fileId ? updated : f) })); } }));