mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 10:00:17 +05:30
feat(renderer): file store with FileNode tree, openTabs, lazy loadChildren
This commit is contained in:
@@ -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<string>;
|
||||
openTabs: OpenTab[];
|
||||
activeTabId: string | null;
|
||||
|
||||
openFolder: (path: string) => Promise<void>;
|
||||
loadChildren: (dirPath: string) => Promise<void>;
|
||||
toggleExpanded: (dirPath: string) => void;
|
||||
openFile: (filePath: string) => Promise<void>;
|
||||
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<FileState>()(
|
||||
immer((set) => ({
|
||||
tree: null,
|
||||
rootPath: null,
|
||||
expanded: new Set<string>(),
|
||||
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);
|
||||
});
|
||||
},
|
||||
}))
|
||||
);
|
||||
@@ -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<typeof vi.fn>;
|
||||
const fakeRead = ipc.file.read as ReturnType<typeof vi.fn>;
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user