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