diff --git a/src/main/menu/items.js b/src/main/menu/items.js
index 896188f..49d2cc5 100644
--- a/src/main/menu/items.js
+++ b/src/main/menu/items.js
@@ -226,10 +226,9 @@ function viewItems(mainWindow) {
{
label: 'Sidebar',
submenu: [
- { label: 'File Explorer', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'explorer') },
- { label: 'Git', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'git') },
- { label: 'Snippets', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'snippets') },
- { label: 'Templates', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'templates') }
+ { label: 'Files', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'explorer') },
+ { label: 'Outline', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'snippets') },
+ { label: 'Git', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'git') }
]
},
{
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 9a721f0..d350cb7 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -17,7 +17,13 @@ function App() {
useEffect(() => {
const handler = () => setPrintOpen(true);
window.addEventListener('mc:print', handler);
- return () => window.removeEventListener('mc:print', handler);
+ window.addEventListener('mc:print-preview', handler);
+ window.addEventListener('mc:print-preview-styled', handler);
+ return () => {
+ window.removeEventListener('mc:print', handler);
+ window.removeEventListener('mc:print-preview', handler);
+ window.removeEventListener('mc:print-preview-styled', handler);
+ };
}, []);
return (
diff --git a/src/renderer/components/modals/ExportPdfDialog.tsx b/src/renderer/components/modals/ExportPdfDialog.tsx
index f4e991d..7a6a65c 100644
--- a/src/renderer/components/modals/ExportPdfDialog.tsx
+++ b/src/renderer/components/modals/ExportPdfDialog.tsx
@@ -51,7 +51,7 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
const m = MARGIN_MAP[margins];
const pageCss = `@page { size: ${fmt.width} ${fmt.height}; margin: ${m.top}mm ${m.right}mm ${m.bottom}mm ${m.left}mm; }`;
const finalHtml = html.replace('', `${pageCss}`);
- const result = await ipc.print({ html: finalHtml, withStyles: embed });
+ const result = await ipc.print.show({ html: finalHtml, withStyles: embed });
if (!result.ok) {
const msg = result.error?.message ?? 'PDF export failed';
toast.error(`Export failed: ${msg}`);
diff --git a/src/renderer/components/sidebar/Sidebar.tsx b/src/renderer/components/sidebar/Sidebar.tsx
index 2bc9899..d29d248 100644
--- a/src/renderer/components/sidebar/Sidebar.tsx
+++ b/src/renderer/components/sidebar/Sidebar.tsx
@@ -3,14 +3,12 @@ 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';
-import { useCommandStore } from '@/stores/command-store';
import { FileTree } from './FileTree';
import { Outline } from './Outline';
import { GitStatusPanel } from './GitStatusPanel';
export function Sidebar() {
const tree = useFileStore((s) => s.tree);
- const dispatch = useCommandStore((s) => s.dispatch);
function scrollToSection(label: string) {
const el = document.querySelector(`[data-sidebar-section="${label}"]`);
@@ -86,10 +84,10 @@ export function Sidebar() {
matching section into view. The hidden elements expose a hook for
Playwright tests and the menu handler. */}
-
-
-
-
+
+
+
+
);
diff --git a/src/renderer/lib/commands/register-menu-commands.ts b/src/renderer/lib/commands/register-menu-commands.ts
index cc12ee4..23db0c7 100644
--- a/src/renderer/lib/commands/register-menu-commands.ts
+++ b/src/renderer/lib/commands/register-menu-commands.ts
@@ -225,8 +225,35 @@ export function registerMenuCommands(): void {
window.dispatchEvent(new CustomEvent('mc:print-preview-styled'));
},
+ // Batch converter — PDF batch reuses ExportBatchDialog; others show coming-soon toast.
+ 'batch.showConverter': (type?: string) => {
+ if (!type) return;
+ if (type === 'pdf') {
+ const paths = useFileStore.getState().openTabs.map((t) => t.path);
+ if (paths.length > 0) {
+ useAppStore.getState().openModal('export-batch', { sourcePaths: paths });
+ } else {
+ toast.info('Open files first to batch-export as PDF');
+ }
+ return;
+ }
+ toast.info(
+ `${type.charAt(0).toUpperCase() + type.slice(1)} batch conversion — coming soon!`,
+ );
+ },
+
+ // Document compare — no modal yet, acknowledge with toast.
+ 'tools.documentCompare': () => {
+ toast.info('Document compare — coming soon!');
+ },
+
+ // Header & footer settings — point users to Settings → Editor.
+ 'settings.headerFooter': () => {
+ useAppStore.getState().openModal('settings');
+ },
+
'file.clearRecent': () => {
- useFileStore.setState({ openTabs: [] });
+ window.electronAPI?.send?.('clear-recent-files');
},
// File → New — creates an unsaved buffer with a default name.
@@ -324,4 +351,7 @@ export function useBridgeNativeMenu(): void {
useMenuAction('file-opened', 'file.opened', (payload) => payload);
useMenuAction('clear-recent-files', 'file.clearRecent');
useMenuAction('file-new', 'file.new');
+ useMenuAction('show-batch-converter', 'batch.showConverter', (type) => type as string);
+ useMenuAction('show-document-compare', 'tools.documentCompare');
+ useMenuAction('open-header-footer-dialog', 'settings.headerFooter');
}
diff --git a/src/renderer/lib/ipc.ts b/src/renderer/lib/ipc.ts
index c67832f..cabc0eb 100644
--- a/src/renderer/lib/ipc.ts
+++ b/src/renderer/lib/ipc.ts
@@ -83,8 +83,12 @@ export const ipc = {
writeBuffer: (args: { path: string; buffer: Uint8Array }): Promise> =>
safeCall('file', 'writeBuffer', args),
},
- print: (args: { html: string }): Promise> =>
- safeCall('print', 'show', args),
+ print: {
+ show: (args: { html: string }): Promise> =>
+ safeCall('print', 'show', args),
+ doPrint: (args: { withStyles?: boolean }): Promise> =>
+ safeCall('print', 'doPrint', args),
+ },
export: {
pdf: (opts: PdfOptions): Promise> =>
safeCall('export', 'pdf', opts),
diff --git a/src/renderer/lib/pdf-export.ts b/src/renderer/lib/pdf-export.ts
index d38dd85..6c2ca00 100644
--- a/src/renderer/lib/pdf-export.ts
+++ b/src/renderer/lib/pdf-export.ts
@@ -41,7 +41,7 @@ export async function generatePdf(options: PdfExportOptions): Promise {
// Hand the rendered HTML to the main process for native print-to-PDF.
// This avoids the print dialog and works headlessly.
- const result = await ipc.print({ html: finalHtml });
+ const result = await ipc.print.show({ html: finalHtml });
if (!result.ok) {
toast.error(`PDF export failed: ${result.error?.message ?? 'unknown error'}`);
} else {
diff --git a/tests/component/modals/ExportPdfDialog.test.tsx b/tests/component/modals/ExportPdfDialog.test.tsx
index 21883cc..5e4375a 100644
--- a/tests/component/modals/ExportPdfDialog.test.tsx
+++ b/tests/component/modals/ExportPdfDialog.test.tsx
@@ -8,7 +8,10 @@ import { useSettingsStore } from '@/stores/settings-store';
vi.mock('@/lib/ipc', () => ({
ipc: {
- print: vi.fn().mockResolvedValue({ ok: true }),
+ print: {
+ show: vi.fn().mockResolvedValue({ ok: true }),
+ doPrint: vi.fn().mockResolvedValue({ ok: true }),
+ },
},
}));
@@ -29,12 +32,12 @@ describe('ExportPdfDialog', () => {
expect(screen.getByRole('combobox', { name: /format/i })).toBeInTheDocument();
});
- it('toggles ASCII tables and submits via ipc.print', async () => {
+ it('toggles ASCII tables and submits via ipc.print.show', async () => {
render();
await userEvent.click(screen.getByRole('checkbox', { name: /ascii/i }));
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
- await waitFor(() => expect(ipc.print).toHaveBeenCalledTimes(1));
- const [arg] = (ipc.print as any).mock.calls[0];
+ await waitFor(() => expect(ipc.print.show).toHaveBeenCalledTimes(1));
+ const [arg] = (ipc.print.show as any).mock.calls[0];
expect(arg.html).toContain('');
// The Markdown source is rendered to HTML inside the document body.
expect(arg.html).toContain('hi
');
@@ -43,7 +46,7 @@ describe('ExportPdfDialog', () => {
});
it('renders an error banner when IPC fails', async () => {
- (ipc.print as any).mockRejectedValueOnce(new Error('Pandoc not found'));
+ (ipc.print.show as any).mockRejectedValueOnce(new Error('Pandoc not found'));
render();
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
expect(await screen.findByText(/pandoc not found/i)).toBeInTheDocument();
diff --git a/tests/integration/phase8-toasts-smoke.test.tsx b/tests/integration/phase8-toasts-smoke.test.tsx
index f00de84..de2fc23 100644
--- a/tests/integration/phase8-toasts-smoke.test.tsx
+++ b/tests/integration/phase8-toasts-smoke.test.tsx
@@ -27,7 +27,10 @@ vi.mock('@/lib/ipc', () => ({
pickFile: vi.fn(),
onChange: vi.fn(),
},
- print: vi.fn().mockResolvedValue({ ok: true }),
+ print: {
+ show: vi.fn().mockResolvedValue({ ok: true }),
+ doPrint: vi.fn().mockResolvedValue({ ok: true }),
+ },
menu: {
on: vi.fn(() => () => {}),
},
@@ -93,7 +96,7 @@ describe('Phase 8 toasts integration', () => {
});
it('exporting a file calls toast.success on success', async () => {
- (ipc.print as any).mockResolvedValue({ ok: true });
+ (ipc.print.show as any).mockResolvedValue({ ok: true });
registerMenuCommands();
render();
@@ -107,7 +110,7 @@ describe('Phase 8 toasts integration', () => {
// The PDF flow hands the rendered HTML to the main process for print.
await waitFor(() => {
- expect(ipc.print).toHaveBeenCalledTimes(1);
+ expect(ipc.print.show).toHaveBeenCalledTimes(1);
});
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Sent test.md'));
});
diff --git a/tests/unit/App.test.tsx b/tests/unit/App.test.tsx
new file mode 100644
index 0000000..eec1aab
--- /dev/null
+++ b/tests/unit/App.test.tsx
@@ -0,0 +1,67 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render, act, screen, fireEvent } from '@testing-library/react';
+import App from '@/App';
+import { useCommandStore } from '@/stores/command-store';
+import { useAppStore } from '@/stores/app-store';
+import { useFileStore } from '@/stores/file-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { useEditorStore } from '@/stores/editor-store';
+
+vi.mock('@/lib/ipc', () => ({
+ ipc: {
+ file: {
+ pickFolder: vi.fn(),
+ pickFile: vi.fn(),
+ read: vi.fn(),
+ write: vi.fn(),
+ list: vi.fn(),
+ },
+ app: { getVersion: vi.fn().mockResolvedValue({ ok: true, data: '5.0.1' }) },
+ menu: { on: vi.fn(() => () => {}) },
+ updater: { check: vi.fn(), install: vi.fn(), getState: vi.fn(), onStatus: vi.fn(() => () => {}) },
+ crash: { read: vi.fn(), openDir: vi.fn(), delete: vi.fn() },
+ },
+}));
+
+vi.mock('@/hooks/use-welcome-trigger', () => ({
+ useWelcomeTrigger: () => {},
+}));
+
+vi.mock('@/hooks/useAutoUpdateCheck', () => ({
+ useAutoUpdateCheck: () => {},
+}));
+
+describe('App — print preview event listeners', () => {
+ beforeEach(() => {
+ useCommandStore.setState({ handlers: {} } as any);
+ useAppStore.setState({ modal: { kind: null } } as any);
+ useFileStore.setState({ tree: null, rootPath: null, expanded: new Set(), openTabs: [], activeTabId: null });
+ useSettingsStore.getState().resetToDefaults?.();
+ useEditorStore.setState({ buffers: new Map(), activeId: null });
+ localStorage.clear();
+ });
+
+ it('opens PrintPreview on mc:print event', () => {
+ render();
+ act(() => {
+ window.dispatchEvent(new CustomEvent('mc:print'));
+ });
+ expect(screen.getByText(/print preview/i)).toBeInTheDocument();
+ });
+
+ it('opens PrintPreview on mc:print-preview event', () => {
+ render();
+ act(() => {
+ window.dispatchEvent(new CustomEvent('mc:print-preview'));
+ });
+ expect(screen.getByText(/print preview/i)).toBeInTheDocument();
+ });
+
+ it('opens PrintPreview on mc:print-preview-styled event', () => {
+ render();
+ act(() => {
+ window.dispatchEvent(new CustomEvent('mc:print-preview-styled'));
+ });
+ expect(screen.getByText(/print preview/i)).toBeInTheDocument();
+ });
+});
diff --git a/tests/unit/lib/register-menu-commands.test.tsx b/tests/unit/lib/register-menu-commands.test.tsx
index 10122c0..5c17d3d 100644
--- a/tests/unit/lib/register-menu-commands.test.tsx
+++ b/tests/unit/lib/register-menu-commands.test.tsx
@@ -143,4 +143,49 @@ describe('useRegisterMenuCommands + useBridgeNativeMenu', () => {
act(() => fireMenu('load-template-menu', 'blog-post.md'));
expect(captured).toBe('blog-post.md');
});
+
+ it('file.clearRecent does NOT clear openTabs', () => {
+ useFileStore.setState({
+ openTabs: [
+ { id: '/a.md', path: '/a.md', title: 'a.md', dirty: false },
+ ],
+ activeTabId: '/a.md',
+ });
+ render();
+ const sendSpy = vi.fn();
+ (window as any).electronAPI = { send: sendSpy };
+ act(() => useCommandStore.getState().dispatch('file.clearRecent'));
+ expect(useFileStore.getState().openTabs).toHaveLength(1);
+ expect(sendSpy).toHaveBeenCalledWith('clear-recent-files');
+ });
+
+ it('show-batch-converter IPC event dispatches batch.showConverter', () => {
+ let captured: unknown;
+ render();
+ useCommandStore.getState().register('batch.showConverter', (args) => {
+ captured = args;
+ });
+ act(() => fireMenu('show-batch-converter', 'image'));
+ expect(captured).toBe('image');
+ });
+
+ it('show-document-compare IPC event dispatches tools.documentCompare', () => {
+ let called = false;
+ render();
+ useCommandStore.getState().register('tools.documentCompare', () => {
+ called = true;
+ });
+ act(() => fireMenu('show-document-compare'));
+ expect(called).toBe(true);
+ });
+
+ it('open-header-footer-dialog IPC event dispatches settings.headerFooter', () => {
+ let called = false;
+ render();
+ useCommandStore.getState().register('settings.headerFooter', () => {
+ called = true;
+ });
+ act(() => fireMenu('open-header-footer-dialog'));
+ expect(called).toBe(true);
+ });
});