mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
feat(preload): add file.list, file.pickFolder, file.pickFile convenience methods
This commit is contained in:
+49
@@ -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 }) => {
|
ipcMain.on('export-spreadsheet', (event, { content, format }) => {
|
||||||
const outputFile = dialog.showSaveDialogSync(mainWindow, {
|
const outputFile = dialog.showSaveDialogSync(mainWindow, {
|
||||||
defaultPath: currentFile.replace(/\.[^/.]+$/, `.${format}`),
|
defaultPath: currentFile.replace(/\.[^/.]+$/, `.${format}`),
|
||||||
|
|||||||
+10
-2
@@ -237,7 +237,12 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
|||||||
'load-template-menu',
|
'load-template-menu',
|
||||||
'toggle-command-palette',
|
'toggle-command-palette',
|
||||||
'toggle-sidebar-panel',
|
'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),
|
exists: (filePath) => ipcRenderer.invoke('path-exists', filePath),
|
||||||
isDirectory: (filePath) => ipcRenderer.invoke('is-directory', filePath),
|
isDirectory: (filePath) => ipcRenderer.invoke('is-directory', filePath),
|
||||||
copy: (source, destination) => ipcRenderer.invoke('copy-path', { source, destination }),
|
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
|
// Theme Operations
|
||||||
|
|||||||
@@ -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 { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { useFileStore } from '@/stores/file-store';
|
import { useFileStore } from '@/stores/file-store';
|
||||||
@@ -6,7 +7,7 @@ import { FileTree } from './FileTree';
|
|||||||
import { Outline } from './Outline';
|
import { Outline } from './Outline';
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const rootPath = useFileStore((s) => s.rootPath);
|
const tree = useFileStore((s) => s.tree);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-3">
|
<div className="flex h-full flex-col gap-3">
|
||||||
@@ -20,9 +21,12 @@ export function Sidebar() {
|
|||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<ScrollArea className="h-[calc(100vh-240px)]">
|
<ScrollArea className="h-[calc(100vh-240px)]">
|
||||||
{!rootPath ? (
|
{!tree ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-xs text-muted-foreground">
|
<div className="flex flex-col items-center gap-2 p-4 text-xs text-muted-foreground">
|
||||||
<span>No folder opened</span>
|
<span>No folder opened</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => useFileStore.getState().openFolderDialog()}>
|
||||||
|
<FolderOpen className="mr-1" /> Open Folder
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FileTree />
|
<FileTree />
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ export const ipc = {
|
|||||||
safeCall('file', 'write', path, content),
|
safeCall('file', 'write', path, content),
|
||||||
list: (dir: string): Promise<IpcResult<FileEntry[] | ChannelMissing>> =>
|
list: (dir: string): Promise<IpcResult<FileEntry[] | ChannelMissing>> =>
|
||||||
safeCall('file', 'list', dir),
|
safeCall('file', 'list', dir),
|
||||||
|
pickFolder: (): Promise<IpcResult<string | null | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'pickFolder'),
|
||||||
|
pickFile: (): Promise<IpcResult<string | null | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'pickFile'),
|
||||||
onChange: (cb: (path: string) => void): (() => void) => {
|
onChange: (cb: (path: string) => void): (() => void) => {
|
||||||
if (typeof window === 'undefined' || !window.electronAPI?.file?.onChange) {
|
if (typeof window === 'undefined' || !window.electronAPI?.file?.onChange) {
|
||||||
return () => {};
|
return () => {};
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ interface FileState {
|
|||||||
markTabClean: (id: string) => void;
|
markTabClean: (id: string) => void;
|
||||||
markTabDirty: (id: string) => void;
|
markTabDirty: (id: string) => void;
|
||||||
reorderTabs: (fromIndex: number, toIndex: number) => void;
|
reorderTabs: (fromIndex: number, toIndex: number) => void;
|
||||||
|
openFolderDialog: () => Promise<void>;
|
||||||
|
openFileDialog: () => Promise<void>;
|
||||||
|
saveActiveBuffer: () => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function entryToNode(entry: FileEntry): FileNode {
|
function entryToNode(entry: FileEntry): FileNode {
|
||||||
@@ -191,5 +194,32 @@ export const useFileStore = create<FileState>()(
|
|||||||
tabs.splice(toIndex, 0, moved);
|
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;
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
import { ThemeProvider } from '@/components/theme-provider';
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
import { Sidebar } from '@/components/sidebar/Sidebar';
|
import { Sidebar } from '@/components/sidebar/Sidebar';
|
||||||
import { useFileStore } from '@/stores/file-store';
|
import { useFileStore } from '@/stores/file-store';
|
||||||
import { useEditorStore } from '@/stores/editor-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', () => {
|
describe('Sidebar', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useFileStore.setState({ tree: null, rootPath: null, expanded: new Set(), openTabs: [], activeTabId: null });
|
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('Outline')).toBeInTheDocument();
|
||||||
expect(screen.getByText('README.md')).toBeInTheDocument();
|
expect(screen.getByText('README.md')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders "Open Folder" button when no folder is open', () => {
|
||||||
|
useFileStore.setState({ tree: null, rootPath: null });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<Sidebar />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<Sidebar />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const btn = screen.getByRole('button', { name: /open folder/i });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ vi.mock('@/lib/ipc', () => ({
|
|||||||
file: {
|
file: {
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
read: 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<typeof vi.fn>;
|
const fakeList = ipc.file.list as ReturnType<typeof vi.fn>;
|
||||||
const fakeRead = ipc.file.read as ReturnType<typeof vi.fn>;
|
const fakeRead = ipc.file.read as ReturnType<typeof vi.fn>;
|
||||||
|
const fakePickFolder = ipc.file.pickFolder as ReturnType<typeof vi.fn>;
|
||||||
|
const fakePickFile = ipc.file.pickFile as ReturnType<typeof vi.fn>;
|
||||||
|
const fakeWrite = ipc.file.write as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
function fakeFileEntry(name: string, isDirectory: boolean): FileEntry {
|
function fakeFileEntry(name: string, isDirectory: boolean): FileEntry {
|
||||||
return { name, path: `/root/${name}`, isDirectory };
|
return { name, path: `/root/${name}`, isDirectory };
|
||||||
@@ -33,6 +39,9 @@ describe('useFileStore', () => {
|
|||||||
useEditorStore.setState({ buffers: new Map(), activeId: null });
|
useEditorStore.setState({ buffers: new Map(), activeId: null });
|
||||||
fakeList.mockClear();
|
fakeList.mockClear();
|
||||||
fakeRead.mockClear();
|
fakeRead.mockClear();
|
||||||
|
fakePickFolder.mockClear();
|
||||||
|
fakePickFile.mockClear();
|
||||||
|
fakeWrite.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- openFolder ---
|
// --- openFolder ---
|
||||||
@@ -302,4 +311,89 @@ describe('useFileStore', () => {
|
|||||||
const tabs = useFileStore.getState().openTabs;
|
const tabs = useFileStore.getState().openTabs;
|
||||||
expect(tabs.map((t) => t.title)).toEqual(['c.md', 'a.md', 'b.md']);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user