rem-notepad / src /stores /fileStore.ts
algorembrant's picture
Upload 31 files
4af09f9 verified
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();
// 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<string>();
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) }));
}
}));