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);
+ });
});