| 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<void>; |
| 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<string>; |
| renameNode: (id: string, newName: string) => Promise<void>; |
| deleteNode: (id: string) => Promise<void>; |
| duplicateNode: (id: string) => Promise<void>; |
| updateFileContent: (id: string, content: string) => Promise<void>; |
| moveNode: (id: string, newParentId: string | null) => Promise<void>; |
| reorderNodes: (activeId: string, overId: string, newParentId: string | null) => Promise<void>; |
| setNodeIcon: (id: string, icon: string) => Promise<void>; |
| setNodeColor: (id: string, color: string) => Promise<void>; |
| setComment: (fileId: string, lineId: string, comment: string) => Promise<void>; |
| copySelectedContents: () => string; |
| copyNodeContents: (id: string) => string; |
| } |
|
|
| export const useFileStore = create<FileStore>((set, get) => ({ |
| files: [], |
| activeFileId: null, |
| selectedIds: [], |
| navigationHistory: [], |
| historyIndex: -1, |
|
|
| loadFiles: async () => { |
| const data = await getFiles(); |
| |
| 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); |
| |
| if (newHistory[newHistory.length - 1] !== id) { |
| newHistory.push(id); |
| |
| 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(); |
| |
| 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(); |
| |
| |
| const toDelete = new Set<string>(); |
| const findChildren = (nodeId: string) => { |
| toDelete.add(nodeId); |
| files.forEach(f => { |
| if (f.parentId === nodeId) findChildren(f.id); |
| }); |
| }; |
| findChildren(id); |
|
|
| |
| 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); |
| }, |
|
|
| |
| 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; |
| |
| |
| 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); |
| |
| 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) { |
| |
| 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) })); |
| } |
| })); |
|
|