diff --git a/src/renderer/stores/file-store.ts b/src/renderer/stores/file-store.ts new file mode 100644 index 0000000..17a1aab --- /dev/null +++ b/src/renderer/stores/file-store.ts @@ -0,0 +1,195 @@ +import { create } from 'zustand'; +import { immer } from 'zustand/middleware/immer'; +import { enableMapSet } from 'immer'; +import { ipc } from '@/lib/ipc'; +import { useEditorStore } from '@/stores/editor-store'; +import type { FileEntry } from '@/types/ipc'; + +enableMapSet(); + +export interface FileNode { + name: string; + path: string; + isDirectory: boolean; + children: FileNode[] | null; + expanded?: boolean; + loaded?: boolean; +} + +export interface OpenTab { + id: string; + path: string; + title: string; + dirty: boolean; +} + +interface FileState { + tree: FileNode | null; + rootPath: string | null; + expanded: Set; + openTabs: OpenTab[]; + activeTabId: string | null; + + openFolder: (path: string) => Promise; + loadChildren: (dirPath: string) => Promise; + toggleExpanded: (dirPath: string) => void; + openFile: (filePath: string) => Promise; + closeTab: (id: string) => void; + setActiveTab: (id: string) => void; + markTabClean: (id: string) => void; + markTabDirty: (id: string) => void; + reorderTabs: (fromIndex: number, toIndex: number) => void; +} + +function entryToNode(entry: FileEntry): FileNode { + return { + name: entry.name, + path: entry.path, + isDirectory: entry.isDirectory, + children: entry.isDirectory ? null : null, + }; +} + +// Recursively find and update a directory node by path using immer's produce +function updateNode(tree: FileNode, dirPath: string, updater: (node: FileNode) => void): boolean { + if (tree.path === dirPath && tree.isDirectory) { + updater(tree); + return true; + } + if (tree.children) { + for (const child of tree.children) { + if (updateNode(child, dirPath, updater)) return true; + } + } + return false; +} + +export const useFileStore = create()( + immer((set) => ({ + tree: null, + rootPath: null, + expanded: new Set(), + openTabs: [], + activeTabId: null, + + openFolder: async (path) => { + const result = await ipc.file.list(path); + if (!result.ok) return; + + const children: FileNode[] = result.data!.map(entryToNode); + + set((s) => { + s.tree = { + name: path.split('/').filter(Boolean).pop() ?? path, + path, + isDirectory: true, + children, + loaded: true, + }; + s.rootPath = path; + }); + }, + + loadChildren: async (dirPath) => { + // Find the node first + const tree = useFileStore.getState().tree; + if (!tree) return; + + let found = false; + updateNode(tree, dirPath, (node) => { + if (node.loaded) return; + found = true; + }); + if (!found) return; + + const result = await ipc.file.list(dirPath); + if (!result.ok) return; + + set((s) => { + updateNode(s.tree!, dirPath, (node) => { + node.children = result.data!.map(entryToNode); + node.loaded = true; + }); + }); + }, + + toggleExpanded: (dirPath) => { + set((s) => { + if (s.expanded.has(dirPath)) { + s.expanded.delete(dirPath); + } else { + s.expanded.add(dirPath); + } + }); + }, + + openFile: async (filePath) => { + // If already open, sync dirty flag from editor buffer and activate + const existing = useFileStore.getState().openTabs.find((t) => t.id === filePath); + if (existing) { + const buffer = useEditorStore.getState().buffers.get(filePath); + set((s) => { + s.activeTabId = filePath; + if (buffer) { + const tab = s.openTabs.find((t) => t.id === filePath); + if (tab) tab.dirty = buffer.dirty; + } + }); + return; + } + + const result = await ipc.file.read(filePath); + if (!result.ok) return; + + const content = result.data!; + const title = filePath.split('/').pop() ?? filePath; + + // Open in editor store + useEditorStore.getState().openBuffer(filePath, filePath, content); + + set((s) => { + s.openTabs.push({ id: filePath, path: filePath, title, dirty: false }); + s.activeTabId = filePath; + }); + }, + + closeTab: (id) => { + set((s) => { + const idx = s.openTabs.findIndex((t) => t.id === id); + if (idx === -1) return; + s.openTabs.splice(idx, 1); + if (s.activeTabId === id) { + s.activeTabId = idx > 0 ? s.openTabs[idx - 1]?.id ?? null : null; + } + }); + }, + + setActiveTab: (id) => { + set((s) => { + s.activeTabId = id; + }); + }, + + markTabClean: (id) => { + set((s) => { + const tab = s.openTabs.find((t) => t.id === id); + if (tab) tab.dirty = false; + }); + }, + + markTabDirty: (id) => { + set((s) => { + const tab = s.openTabs.find((t) => t.id === id); + if (tab) tab.dirty = true; + }); + }, + + reorderTabs: (fromIndex, toIndex) => { + set((s) => { + const tabs = s.openTabs; + const [moved] = tabs.splice(fromIndex, 1); + tabs.splice(toIndex, 0, moved); + }); + }, + })) +); diff --git a/tests/unit/stores/file-store.test.ts b/tests/unit/stores/file-store.test.ts new file mode 100644 index 0000000..ad0ffe2 --- /dev/null +++ b/tests/unit/stores/file-store.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useEditorStore } from '@/stores/editor-store'; +import { useFileStore, FileNode, OpenTab } from '@/stores/file-store'; +import type { FileEntry } from '@/types/ipc'; + +vi.mock('@/lib/ipc', () => ({ + ipc: { + file: { + list: vi.fn(), + read: vi.fn(), + }, + }, +})); + +import { ipc } from '@/lib/ipc'; + +const fakeList = ipc.file.list as ReturnType; +const fakeRead = ipc.file.read as ReturnType; + +function fakeFileEntry(name: string, isDirectory: boolean): FileEntry { + return { name, path: `/root/${name}`, isDirectory }; +} + +describe('useFileStore', () => { + beforeEach(() => { + useFileStore.setState({ + tree: null, + rootPath: null, + expanded: new Set(), + openTabs: [], + activeTabId: null, + }); + useEditorStore.setState({ buffers: new Map(), activeId: null }); + fakeList.mockClear(); + fakeRead.mockClear(); + }); + + // --- openFolder --- + + it('openFolder calls ipc.file.list and sets tree with root node and mapped children', async () => { + fakeList.mockResolvedValue({ + ok: true, + data: [ + fakeFileEntry('README.md', false), + fakeFileEntry('src', true), + ], + }); + + await useFileStore.getState().openFolder('/root'); + + expect(fakeList).toHaveBeenCalledWith('/root'); + const tree = useFileStore.getState().tree; + expect(tree).not.toBeNull(); + expect(tree!.name).toBe('root'); + expect(tree!.path).toBe('/root'); + expect(tree!.isDirectory).toBe(true); + expect(tree!.loaded).toBe(true); + expect(tree!.children).toHaveLength(2); + expect(tree!.children![0].name).toBe('README.md'); + expect(tree!.children![0].isDirectory).toBe(false); + expect(tree!.children![1].name).toBe('src'); + expect(tree!.children![1].isDirectory).toBe(true); + // children of a directory node should be null (lazy) + expect(tree!.children![1].children).toBeNull(); + expect(useFileStore.getState().rootPath).toBe('/root'); + }); + + it('openFolder failure leaves state unchanged', async () => { + fakeList.mockResolvedValue({ + ok: false, + error: { code: 'ENOENT', message: 'not found' }, + }); + + await useFileStore.getState().openFolder('/nonexistent'); + expect(useFileStore.getState().tree).toBeNull(); + expect(useFileStore.getState().rootPath).toBeNull(); + }); + + // --- loadChildren --- + + it('loadChildren IPC-loads children of the matching directory and sets loaded: true', async () => { + fakeList.mockResolvedValue({ + ok: true, + data: [fakeFileEntry('nested.md', false)], + }); + + // Set up a tree with a loaded parent directory + const tree: FileNode = { + name: 'root', + path: '/root', + isDirectory: true, + loaded: true, + children: [ + { + name: 'src', + path: '/root/src', + isDirectory: true, + children: null, + loaded: false, + }, + ], + }; + useFileStore.setState({ tree, rootPath: '/root' }); + + await useFileStore.getState().loadChildren('/root/src'); + + const srcNode = useFileStore.getState().tree!.children![0]; + expect(srcNode.loaded).toBe(true); + expect(srcNode.children).toHaveLength(1); + expect(srcNode.children![0].name).toBe('nested.md'); + }); + + it('loadChildren on a non-directory node is a no-op', async () => { + const tree: FileNode = { + name: 'root', + path: '/root', + isDirectory: true, + loaded: true, + children: [{ name: 'file.md', path: '/root/file.md', isDirectory: false, children: null }], + }; + useFileStore.setState({ tree }); + + await useFileStore.getState().loadChildren('/root/file.md'); + expect(fakeList).not.toHaveBeenCalled(); + }); + + it('loadChildren on a non-existent path is a no-op', async () => { + const tree: FileNode = { + name: 'root', + path: '/root', + isDirectory: true, + loaded: true, + children: [{ name: 'src', path: '/root/src', isDirectory: true, children: null, loaded: true }], + }; + useFileStore.setState({ tree }); + + await useFileStore.getState().loadChildren('/root/src/not-there'); + // Should not throw + expect(fakeList).not.toHaveBeenCalled(); + }); + + // --- toggleExpanded --- + + it('toggleExpanded adds and removes paths from the expanded Set', () => { + const { toggleExpanded } = useFileStore.getState(); + toggleExpanded('/root/src'); + expect(useFileStore.getState().expanded.has('/root/src')).toBe(true); + toggleExpanded('/root/src'); + expect(useFileStore.getState().expanded.has('/root/src')).toBe(false); + }); + + // --- openFile --- + + it('openFile calls ipc.file.read, opens an editor buffer, adds a tab, and sets it active', async () => { + fakeRead.mockResolvedValue({ ok: true, data: '# hello world' }); + + await useFileStore.getState().openFile('/root/README.md'); + + expect(fakeRead).toHaveBeenCalledWith('/root/README.md'); + // Editor buffer should be open + const buf = useEditorStore.getState().buffers.get('/root/README.md'); + expect(buf).not.toBeUndefined(); + expect(buf!.content).toBe('# hello world'); + expect(buf!.dirty).toBe(false); + // Tab should be added and active + const tabs = useFileStore.getState().openTabs; + expect(tabs).toHaveLength(1); + expect(tabs[0].path).toBe('/root/README.md'); + expect(tabs[0].title).toBe('README.md'); + expect(tabs[0].dirty).toBe(false); + expect(useFileStore.getState().activeTabId).toBe('/root/README.md'); + }); + + it('openFile on an already-open file does not re-read — just activates the existing tab', async () => { + fakeRead.mockResolvedValue({ ok: true, data: '# original' }); + + // Open once + await useFileStore.getState().openFile('/root/README.md'); + expect(fakeRead).toHaveBeenCalledTimes(1); + + // Mark it dirty to prove dirty flag is preserved + useEditorStore.getState().updateContent('/root/README.md', '# modified'); + + // Open again + await useFileStore.getState().openFile('/root/README.md'); + + expect(fakeRead).toHaveBeenCalledTimes(1); // no second read + expect(useEditorStore.getState().buffers.get('/root/README.md')!.content).toBe('# modified'); + expect(useFileStore.getState().openTabs[0].dirty).toBe(true); // dirty flag preserved + expect(useFileStore.getState().activeTabId).toBe('/root/README.md'); + }); + + it('openFile failure does nothing', async () => { + fakeRead.mockResolvedValue({ + ok: false, + error: { code: 'ENOENT', message: 'not found' }, + }); + + await useFileStore.getState().openFile('/root/missing.md'); + expect(useFileStore.getState().openTabs).toHaveLength(0); + expect(useEditorStore.getState().buffers.size).toBe(0); + }); + + // --- closeTab --- + + it('closeTab removes the tab and updates activeTabId to the previous neighbor', async () => { + fakeRead.mockResolvedValue({ ok: true, data: 'a' }); + fakeRead.mockResolvedValue({ ok: true, data: 'b' }); + + await useFileStore.getState().openFile('/root/a.md'); + await useFileStore.getState().openFile('/root/b.md'); + + // Tab order: [a.md, b.md], active = b.md + useFileStore.getState().closeTab('/root/b.md'); + + const tabs = useFileStore.getState().openTabs; + expect(tabs).toHaveLength(1); + expect(tabs[0].id).toBe('/root/a.md'); + expect(useFileStore.getState().activeTabId).toBe('/root/a.md'); + }); + + it('closeTab on the active tab when it is the only tab sets activeTabId to null', async () => { + fakeRead.mockResolvedValue({ ok: true, data: '# only' }); + await useFileStore.getState().openFile('/root/only.md'); + + useFileStore.getState().closeTab('/root/only.md'); + + expect(useFileStore.getState().openTabs).toHaveLength(0); + expect(useFileStore.getState().activeTabId).toBeNull(); + }); + + // --- setActiveTab --- + + it('setActiveTab updates activeTabId', async () => { + fakeRead.mockResolvedValue({ ok: true, data: 'a' }); + fakeRead.mockResolvedValue({ ok: true, data: 'b' }); + + await useFileStore.getState().openFile('/root/a.md'); + await useFileStore.getState().openFile('/root/b.md'); + useFileStore.getState().setActiveTab('/root/a.md'); + + expect(useFileStore.getState().activeTabId).toBe('/root/a.md'); + }); + + // --- markTabClean / markTabDirty --- + + it('markTabClean sets dirty to false on the matching tab', async () => { + fakeRead.mockResolvedValue({ ok: true, data: '# doc' }); + await useFileStore.getState().openFile('/root/doc.md'); + useEditorStore.getState().updateContent('/root/doc.md', '# changed'); + + useFileStore.getState().markTabClean('/root/doc.md'); + + expect(useFileStore.getState().openTabs[0].dirty).toBe(false); + }); + + it('markTabDirty sets dirty to true on the matching tab', async () => { + fakeRead.mockResolvedValue({ ok: true, data: '# doc' }); + await useFileStore.getState().openFile('/root/doc.md'); + + useFileStore.getState().markTabDirty('/root/doc.md'); + + expect(useFileStore.getState().openTabs[0].dirty).toBe(true); + }); + + // --- reorderTabs --- + + it('reorderTabs moves a tab from one index to another', async () => { + fakeRead.mockResolvedValue({ ok: true, data: 'a' }); + fakeRead.mockResolvedValue({ ok: true, data: 'b' }); + fakeRead.mockResolvedValue({ ok: true, data: 'c' }); + + await useFileStore.getState().openFile('/root/a.md'); + await useFileStore.getState().openFile('/root/b.md'); + await useFileStore.getState().openFile('/root/c.md'); + + // [a, b, c] — move c to front + useFileStore.getState().reorderTabs(2, 0); + + const tabs = useFileStore.getState().openTabs; + expect(tabs.map((t) => t.title)).toEqual(['c.md', 'a.md', 'b.md']); + }); +});