From 3598d0624007c7fac16d3998aa707d86e4a21009 Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Fri, 5 Jun 2026 15:13:16 +0530 Subject: [PATCH] feat(preload): add file.list, file.pickFolder, file.pickFile convenience methods --- src/main.js | 49 +++++++++++ src/preload.js | 12 ++- src/renderer/components/sidebar/Sidebar.tsx | 12 ++- src/renderer/lib/ipc.ts | 4 + src/renderer/stores/file-store.ts | 30 +++++++ tests/component/sidebar/Sidebar.test.tsx | 48 ++++++++++- tests/unit/stores/file-store.test.ts | 94 +++++++++++++++++++++ 7 files changed, 241 insertions(+), 8 deletions(-) diff --git a/src/main.js b/src/main.js index 1d7868f..3298fa2 100644 --- a/src/main.js +++ b/src/main.js @@ -3039,6 +3039,55 @@ ipcMain.on('select-folder', (event, type) => { } }); +// List directory contents as FileEntry[] +ipcMain.handle('list-directory', async (event, dirPath) => { + try { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + return entries.map((dirent) => { + const fullPath = path.join(dirPath, dirent.name); + let size; + let modifiedAt; + if (!dirent.isDirectory()) { + try { + const stat = fs.statSync(fullPath); + size = stat.size; + modifiedAt = stat.mtime.toISOString(); + } catch { + // ignore stat errors + } + } + return { + name: dirent.name, + path: fullPath, + isDirectory: dirent.isDirectory(), + size, + modifiedAt, + }; + }); + } catch (err) { + return { error: err.message }; + } +}); + +// Show folder picker dialog and return selected path or null +ipcMain.handle('pick-folder', async () => { + const result = await dialog.showOpenDialog(mainWindow, { + properties: ['openDirectory'], + }); + if (result.canceled || result.filePaths.length === 0) return null; + return result.filePaths[0]; +}); + +// Show file picker dialog for markdown files and return selected path or null +ipcMain.handle('pick-file', async () => { + const result = await dialog.showOpenDialog(mainWindow, { + properties: ['openFile'], + filters: [{ name: 'Markdown', extensions: ['md', 'markdown', 'mdown', 'mkd'] }], + }); + if (result.canceled || result.filePaths.length === 0) return null; + return result.filePaths[0]; +}); + ipcMain.on('export-spreadsheet', (event, { content, format }) => { const outputFile = dialog.showSaveDialogSync(mainWindow, { defaultPath: currentFile.replace(/\.[^/.]+$/, `.${format}`), diff --git a/src/preload.js b/src/preload.js index 854f521..e641e6f 100644 --- a/src/preload.js +++ b/src/preload.js @@ -237,7 +237,12 @@ const ALLOWED_RECEIVE_CHANNELS = [ 'load-template-menu', 'toggle-command-palette', 'toggle-sidebar-panel', - 'toggle-bottom-panel' + 'toggle-bottom-panel', + + // File dialog / directory listing + 'list-directory', + 'pick-folder', + 'pick-file' ]; /** @@ -344,7 +349,10 @@ contextBridge.exposeInMainWorld('electronAPI', { exists: (filePath) => ipcRenderer.invoke('path-exists', filePath), isDirectory: (filePath) => ipcRenderer.invoke('is-directory', filePath), copy: (source, destination) => ipcRenderer.invoke('copy-path', { source, destination }), - move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination }) + move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination }), + list: (dirPath) => ipcRenderer.invoke('list-directory', dirPath), + pickFolder: () => ipcRenderer.invoke('pick-folder'), + pickFile: () => ipcRenderer.invoke('pick-file') }, // Theme Operations diff --git a/src/renderer/components/sidebar/Sidebar.tsx b/src/renderer/components/sidebar/Sidebar.tsx index 21740d0..ade9746 100644 --- a/src/renderer/components/sidebar/Sidebar.tsx +++ b/src/renderer/components/sidebar/Sidebar.tsx @@ -1,4 +1,5 @@ -import { ChevronRight } from 'lucide-react'; +import { ChevronRight, FolderOpen } from 'lucide-react'; +import { Button } from '@/components/ui/button'; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible'; import { ScrollArea } from '@/components/ui/scroll-area'; import { useFileStore } from '@/stores/file-store'; @@ -6,7 +7,7 @@ import { FileTree } from './FileTree'; import { Outline } from './Outline'; export function Sidebar() { - const rootPath = useFileStore((s) => s.rootPath); + const tree = useFileStore((s) => s.tree); return (
@@ -20,9 +21,12 @@ export function Sidebar() { - {!rootPath ? ( -
+ {!tree ? ( +
No folder opened +
) : ( diff --git a/src/renderer/lib/ipc.ts b/src/renderer/lib/ipc.ts index 7f0b4be..340ed3c 100644 --- a/src/renderer/lib/ipc.ts +++ b/src/renderer/lib/ipc.ts @@ -66,6 +66,10 @@ export const ipc = { safeCall('file', 'write', path, content), list: (dir: string): Promise> => safeCall('file', 'list', dir), + pickFolder: (): Promise> => + safeCall('file', 'pickFolder'), + pickFile: (): Promise> => + safeCall('file', 'pickFile'), onChange: (cb: (path: string) => void): (() => void) => { if (typeof window === 'undefined' || !window.electronAPI?.file?.onChange) { return () => {}; diff --git a/src/renderer/stores/file-store.ts b/src/renderer/stores/file-store.ts index 7e67271..dc01353 100644 --- a/src/renderer/stores/file-store.ts +++ b/src/renderer/stores/file-store.ts @@ -39,6 +39,9 @@ interface FileState { markTabClean: (id: string) => void; markTabDirty: (id: string) => void; reorderTabs: (fromIndex: number, toIndex: number) => void; + openFolderDialog: () => Promise; + openFileDialog: () => Promise; + saveActiveBuffer: () => Promise; } function entryToNode(entry: FileEntry): FileNode { @@ -191,5 +194,32 @@ export const useFileStore = create()( tabs.splice(toIndex, 0, moved); }); }, + + openFolderDialog: async () => { + const result = await ipc.file.pickFolder(); + if (!result.ok || result.data === null) return; + await useFileStore.getState().openFolder(result.data); + }, + + openFileDialog: async () => { + const result = await ipc.file.pickFile(); + if (!result.ok || result.data === null) return; + await useFileStore.getState().openFile(result.data); + }, + + saveActiveBuffer: async () => { + const { activeTabId } = useFileStore.getState(); + if (!activeTabId) return false; + + const buffer = useEditorStore.getState().buffers.get(activeTabId); + if (!buffer) return false; + + const writeResult = await ipc.file.write(activeTabId, buffer.content); + if (!writeResult.ok) return false; + + useEditorStore.getState().markSaved(activeTabId); + useFileStore.getState().markTabClean(activeTabId); + return true; + }, })) ); diff --git a/tests/component/sidebar/Sidebar.test.tsx b/tests/component/sidebar/Sidebar.test.tsx index 61313e6..353311a 100644 --- a/tests/component/sidebar/Sidebar.test.tsx +++ b/tests/component/sidebar/Sidebar.test.tsx @@ -1,10 +1,22 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; import { ThemeProvider } from '@/components/theme-provider'; import { Sidebar } from '@/components/sidebar/Sidebar'; import { useFileStore } from '@/stores/file-store'; import { useEditorStore } from '@/stores/editor-store'; +vi.mock('@/lib/ipc', () => ({ + ipc: { + file: { + pickFolder: vi.fn(), + list: vi.fn(), + pickFile: vi.fn(), + read: vi.fn(), + write: vi.fn(), + }, + }, +})); + describe('Sidebar', () => { beforeEach(() => { useFileStore.setState({ tree: null, rootPath: null, expanded: new Set(), openTabs: [], activeTabId: null }); @@ -45,4 +57,36 @@ describe('Sidebar', () => { expect(screen.getByText('Outline')).toBeInTheDocument(); expect(screen.getByText('README.md')).toBeInTheDocument(); }); + + it('renders "Open Folder" button when no folder is open', () => { + useFileStore.setState({ tree: null, rootPath: null }); + + render( + + + + ); + + expect(screen.getByText(/no folder opened/i)).toBeInTheDocument(); + const btn = screen.getByRole('button', { name: /open folder/i }); + expect(btn).toBeInTheDocument(); + }); + + it('clicking "Open Folder" button calls openFolderDialog', async () => { + const { ipc } = await import('@/lib/ipc'); + const spy = vi.spyOn(useFileStore.getState(), 'openFolderDialog'); + + useFileStore.setState({ tree: null, rootPath: null }); + + render( + + + + ); + + const btn = screen.getByRole('button', { name: /open folder/i }); + fireEvent.click(btn); + + expect(spy).toHaveBeenCalled(); + }); }); diff --git a/tests/unit/stores/file-store.test.ts b/tests/unit/stores/file-store.test.ts index 9d0cb99..f07fdf1 100644 --- a/tests/unit/stores/file-store.test.ts +++ b/tests/unit/stores/file-store.test.ts @@ -8,6 +8,9 @@ vi.mock('@/lib/ipc', () => ({ file: { list: vi.fn(), read: vi.fn(), + pickFolder: vi.fn(), + pickFile: vi.fn(), + write: vi.fn(), }, }, })); @@ -16,6 +19,9 @@ import { ipc } from '@/lib/ipc'; const fakeList = ipc.file.list as ReturnType; const fakeRead = ipc.file.read as ReturnType; +const fakePickFolder = ipc.file.pickFolder as ReturnType; +const fakePickFile = ipc.file.pickFile as ReturnType; +const fakeWrite = ipc.file.write as ReturnType; function fakeFileEntry(name: string, isDirectory: boolean): FileEntry { return { name, path: `/root/${name}`, isDirectory }; @@ -33,6 +39,9 @@ describe('useFileStore', () => { useEditorStore.setState({ buffers: new Map(), activeId: null }); fakeList.mockClear(); fakeRead.mockClear(); + fakePickFolder.mockClear(); + fakePickFile.mockClear(); + fakeWrite.mockClear(); }); // --- openFolder --- @@ -302,4 +311,89 @@ describe('useFileStore', () => { const tabs = useFileStore.getState().openTabs; expect(tabs.map((t) => t.title)).toEqual(['c.md', 'a.md', 'b.md']); }); + + // --- openFolderDialog --- + + it('openFolderDialog calls pickFolder, then openFolder on the returned path', async () => { + fakePickFolder.mockResolvedValue({ ok: true, data: '/projects/myapp' }); + fakeList.mockResolvedValue({ + ok: true, + data: [fakeFileEntry('README.md', false)], + }); + + await useFileStore.getState().openFolderDialog(); + + expect(fakePickFolder).toHaveBeenCalled(); + expect(fakeList).toHaveBeenCalledWith('/projects/myapp'); + expect(useFileStore.getState().rootPath).toBe('/projects/myapp'); + }); + + it('openFolderDialog on cancelled (null) is a no-op', async () => { + fakePickFolder.mockResolvedValue({ ok: true, data: null }); + + await useFileStore.getState().openFolderDialog(); + + expect(fakeList).not.toHaveBeenCalled(); + expect(useFileStore.getState().tree).toBeNull(); + }); + + // --- openFileDialog --- + + it('openFileDialog calls pickFile, then openFile on the returned path', async () => { + fakePickFile.mockResolvedValue({ ok: true, data: '/root/README.md' }); + fakeRead.mockResolvedValue({ ok: true, data: '# hello' }); + + await useFileStore.getState().openFileDialog(); + + expect(fakePickFile).toHaveBeenCalled(); + expect(fakeRead).toHaveBeenCalledWith('/root/README.md'); + const tabs = useFileStore.getState().openTabs; + expect(tabs).toHaveLength(1); + expect(tabs[0].path).toBe('/root/README.md'); + }); + + it('openFileDialog on cancelled (null) is a no-op', async () => { + fakePickFile.mockResolvedValue({ ok: true, data: null }); + + await useFileStore.getState().openFileDialog(); + + expect(fakeRead).not.toHaveBeenCalled(); + expect(useFileStore.getState().openTabs).toHaveLength(0); + }); + + // --- saveActiveBuffer --- + + it('saveActiveBuffer with no active tab returns false', async () => { + const result = await useFileStore.getState().saveActiveBuffer(); + expect(result).toBe(false); + expect(fakeWrite).not.toHaveBeenCalled(); + }); + + it('saveActiveBuffer with active buffer calls write, then marks saved and clean, returns true', async () => { + fakeRead.mockResolvedValue({ ok: true, data: '# content' }); + await useFileStore.getState().openFile('/root/doc.md'); + fakeWrite.mockResolvedValue({ ok: true, data: undefined }); + + const result = await useFileStore.getState().saveActiveBuffer(); + + expect(fakeWrite).toHaveBeenCalledWith('/root/doc.md', '# content'); + expect(useEditorStore.getState().buffers.get('/root/doc.md')!.dirty).toBe(false); + expect(useFileStore.getState().openTabs[0].dirty).toBe(false); + expect(result).toBe(true); + }); + + it('saveActiveBuffer on write failure returns false and does NOT mark clean', async () => { + fakeRead.mockResolvedValue({ ok: true, data: '# content' }); + await useFileStore.getState().openFile('/root/doc.md'); + // Mark the buffer dirty and sync the tab dirty flag before attempting save + useEditorStore.getState().updateContent('/root/doc.md', '# modified'); + useFileStore.getState().markTabDirty('/root/doc.md'); + fakeWrite.mockResolvedValue({ ok: false, error: { code: 'EIO', message: 'write error' } }); + + const result = await useFileStore.getState().saveActiveBuffer(); + + expect(result).toBe(false); + expect(useEditorStore.getState().buffers.get('/root/doc.md')!.dirty).toBe(true); + expect(useFileStore.getState().openTabs[0].dirty).toBe(true); + }); });