mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
fix: wire orphaned IPC channels, fix data-loss bug, print preview events, sidebar navigation
- Fix file.clearRecent sending IPC to main process instead of clearing openTabs - Wire show-batch-converter, show-document-compare, open-header-footer-dialog IPC channels - Add print preview event listeners (mc:print-preview, mc:print-preview-styled) in App.tsx - Fix sidebar hidden bridge buttons toggling sidebar closed after scroll - Update sidebar menu labels to match actual sections (Files, Outline, Git) - Fix ipc.print to be an object with show/doPrint methods (was bare function) - Update callers: ExportPdfDialog, pdf-export, PrintPreview - Add 7 regression tests for all fixes - Clean 2 obsolete snapshots - Build Linux packages (deb, AppImage, snap) — 549 tests passing
This commit is contained in:
@@ -226,10 +226,9 @@ function viewItems(mainWindow) {
|
|||||||
{
|
{
|
||||||
label: 'Sidebar',
|
label: 'Sidebar',
|
||||||
submenu: [
|
submenu: [
|
||||||
{ label: 'File Explorer', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'explorer') },
|
{ label: 'Files', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'explorer') },
|
||||||
{ label: 'Git', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'git') },
|
{ label: 'Outline', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'snippets') },
|
||||||
{ label: 'Snippets', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'snippets') },
|
{ label: 'Git', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'git') }
|
||||||
{ label: 'Templates', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'templates') }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,7 +17,13 @@ function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = () => setPrintOpen(true);
|
const handler = () => setPrintOpen(true);
|
||||||
window.addEventListener('mc:print', handler);
|
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 (
|
return (
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
|
|||||||
const m = MARGIN_MAP[margins];
|
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 pageCss = `@page { size: ${fmt.width} ${fmt.height}; margin: ${m.top}mm ${m.right}mm ${m.bottom}mm ${m.left}mm; }`;
|
||||||
const finalHtml = html.replace('</style>', `${pageCss}</style>`);
|
const finalHtml = html.replace('</style>', `${pageCss}</style>`);
|
||||||
const result = await ipc.print({ html: finalHtml, withStyles: embed });
|
const result = await ipc.print.show({ html: finalHtml, withStyles: embed });
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
const msg = result.error?.message ?? 'PDF export failed';
|
const msg = result.error?.message ?? 'PDF export failed';
|
||||||
toast.error(`Export failed: ${msg}`);
|
toast.error(`Export failed: ${msg}`);
|
||||||
|
|||||||
@@ -3,14 +3,12 @@ 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';
|
||||||
import { useCommandStore } from '@/stores/command-store';
|
|
||||||
import { FileTree } from './FileTree';
|
import { FileTree } from './FileTree';
|
||||||
import { Outline } from './Outline';
|
import { Outline } from './Outline';
|
||||||
import { GitStatusPanel } from './GitStatusPanel';
|
import { GitStatusPanel } from './GitStatusPanel';
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const tree = useFileStore((s) => s.tree);
|
const tree = useFileStore((s) => s.tree);
|
||||||
const dispatch = useCommandStore((s) => s.dispatch);
|
|
||||||
|
|
||||||
function scrollToSection(label: string) {
|
function scrollToSection(label: string) {
|
||||||
const el = document.querySelector(`[data-sidebar-section="${label}"]`);
|
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
|
matching section into view. The hidden elements expose a hook for
|
||||||
Playwright tests and the menu handler. */}
|
Playwright tests and the menu handler. */}
|
||||||
<div className="sr-only" aria-hidden="true">
|
<div className="sr-only" aria-hidden="true">
|
||||||
<button data-testid="sidebar-jump-explorer" onClick={() => { scrollToSection('Files'); dispatch('view.toggleSidebar'); }}>jump-explorer</button>
|
<button data-testid="sidebar-jump-explorer" onClick={() => scrollToSection('Files')}>jump-explorer</button>
|
||||||
<button data-testid="sidebar-jump-git" onClick={() => { scrollToSection('Git'); dispatch('view.toggleSidebar'); }}>jump-git</button>
|
<button data-testid="sidebar-jump-git" onClick={() => scrollToSection('Git')}>jump-git</button>
|
||||||
<button data-testid="sidebar-jump-snippets" onClick={() => { scrollToSection('Outline'); dispatch('view.toggleSidebar'); }}>jump-snippets</button>
|
<button data-testid="sidebar-jump-snippets" onClick={() => scrollToSection('Outline')}>jump-snippets</button>
|
||||||
<button data-testid="sidebar-jump-templates" onClick={() => { scrollToSection('Files'); dispatch('view.toggleSidebar'); }}>jump-templates</button>
|
<button data-testid="sidebar-jump-templates" onClick={() => scrollToSection('Files')}>jump-templates</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -225,8 +225,35 @@ export function registerMenuCommands(): void {
|
|||||||
window.dispatchEvent(new CustomEvent('mc:print-preview-styled'));
|
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': () => {
|
'file.clearRecent': () => {
|
||||||
useFileStore.setState({ openTabs: [] });
|
window.electronAPI?.send?.('clear-recent-files');
|
||||||
},
|
},
|
||||||
|
|
||||||
// File → New — creates an unsaved buffer with a default name.
|
// 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('file-opened', 'file.opened', (payload) => payload);
|
||||||
useMenuAction('clear-recent-files', 'file.clearRecent');
|
useMenuAction('clear-recent-files', 'file.clearRecent');
|
||||||
useMenuAction('file-new', 'file.new');
|
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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,8 +83,12 @@ export const ipc = {
|
|||||||
writeBuffer: (args: { path: string; buffer: Uint8Array }): Promise<IpcResult<void | ChannelMissing>> =>
|
writeBuffer: (args: { path: string; buffer: Uint8Array }): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
safeCall('file', 'writeBuffer', args),
|
safeCall('file', 'writeBuffer', args),
|
||||||
},
|
},
|
||||||
print: (args: { html: string }): Promise<IpcResult<void | ChannelMissing>> =>
|
print: {
|
||||||
|
show: (args: { html: string }): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
safeCall('print', 'show', args),
|
safeCall('print', 'show', args),
|
||||||
|
doPrint: (args: { withStyles?: boolean }): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
|
safeCall('print', 'doPrint', args),
|
||||||
|
},
|
||||||
export: {
|
export: {
|
||||||
pdf: (opts: PdfOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
|
pdf: (opts: PdfOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
|
||||||
safeCall('export', 'pdf', opts),
|
safeCall('export', 'pdf', opts),
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export async function generatePdf(options: PdfExportOptions): Promise<void> {
|
|||||||
|
|
||||||
// Hand the rendered HTML to the main process for native print-to-PDF.
|
// Hand the rendered HTML to the main process for native print-to-PDF.
|
||||||
// This avoids the print dialog and works headlessly.
|
// 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) {
|
if (!result.ok) {
|
||||||
toast.error(`PDF export failed: ${result.error?.message ?? 'unknown error'}`);
|
toast.error(`PDF export failed: ${result.error?.message ?? 'unknown error'}`);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import { useSettingsStore } from '@/stores/settings-store';
|
|||||||
|
|
||||||
vi.mock('@/lib/ipc', () => ({
|
vi.mock('@/lib/ipc', () => ({
|
||||||
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();
|
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(<ExportPdfDialog sourcePath="/test.md" />);
|
render(<ExportPdfDialog sourcePath="/test.md" />);
|
||||||
await userEvent.click(screen.getByRole('checkbox', { name: /ascii/i }));
|
await userEvent.click(screen.getByRole('checkbox', { name: /ascii/i }));
|
||||||
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
||||||
await waitFor(() => expect(ipc.print).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(ipc.print.show).toHaveBeenCalledTimes(1));
|
||||||
const [arg] = (ipc.print as any).mock.calls[0];
|
const [arg] = (ipc.print.show as any).mock.calls[0];
|
||||||
expect(arg.html).toContain('<!DOCTYPE html>');
|
expect(arg.html).toContain('<!DOCTYPE html>');
|
||||||
// The Markdown source is rendered to HTML inside the document body.
|
// The Markdown source is rendered to HTML inside the document body.
|
||||||
expect(arg.html).toContain('<h1>hi</h1>');
|
expect(arg.html).toContain('<h1>hi</h1>');
|
||||||
@@ -43,7 +46,7 @@ describe('ExportPdfDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders an error banner when IPC fails', async () => {
|
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(<ExportPdfDialog sourcePath="/test.md" />);
|
render(<ExportPdfDialog sourcePath="/test.md" />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
||||||
expect(await screen.findByText(/pandoc not found/i)).toBeInTheDocument();
|
expect(await screen.findByText(/pandoc not found/i)).toBeInTheDocument();
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ vi.mock('@/lib/ipc', () => ({
|
|||||||
pickFile: vi.fn(),
|
pickFile: vi.fn(),
|
||||||
onChange: 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: {
|
menu: {
|
||||||
on: vi.fn(() => () => {}),
|
on: vi.fn(() => () => {}),
|
||||||
},
|
},
|
||||||
@@ -93,7 +96,7 @@ describe('Phase 8 toasts integration', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('exporting a file calls toast.success on success', async () => {
|
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();
|
registerMenuCommands();
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
@@ -107,7 +110,7 @@ describe('Phase 8 toasts integration', () => {
|
|||||||
|
|
||||||
// The PDF flow hands the rendered HTML to the main process for print.
|
// The PDF flow hands the rendered HTML to the main process for print.
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(ipc.print).toHaveBeenCalledTimes(1);
|
expect(ipc.print.show).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Sent test.md'));
|
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Sent test.md'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(<App />);
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new CustomEvent('mc:print'));
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/print preview/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens PrintPreview on mc:print-preview event', () => {
|
||||||
|
render(<App />);
|
||||||
|
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(<App />);
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new CustomEvent('mc:print-preview-styled'));
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/print preview/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -143,4 +143,49 @@ describe('useRegisterMenuCommands + useBridgeNativeMenu', () => {
|
|||||||
act(() => fireMenu('load-template-menu', 'blog-post.md'));
|
act(() => fireMenu('load-template-menu', 'blog-post.md'));
|
||||||
expect(captured).toBe('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(<Harness />);
|
||||||
|
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(<Harness />);
|
||||||
|
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(<Harness />);
|
||||||
|
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(<Harness />);
|
||||||
|
useCommandStore.getState().register('settings.headerFooter', () => {
|
||||||
|
called = true;
|
||||||
|
});
|
||||||
|
act(() => fireMenu('open-header-footer-dialog'));
|
||||||
|
expect(called).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user