mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-03 02:11:07 +05:30
feat(renderer): wire remaining stubbed commands and rebuild
The v5.0.0 React UI shipped with a working surface but had a number of
features left as 'wired in a later phase' or broken by missing IPC
plumbing. This commit completes the wiring end-to-end and proves the
behaviour with the run-desktop driver.
Toolbar format buttons (Toolbar.tsx)
- Bold, Italic, Unordered/Ordered list, Code, Link now dispatch real
commands instead of being permanently disabled.
New module: src/renderer/lib/editor-commands.ts
- Module-level singleton holding the active CodeMirror EditorView.
- toggleBold/Italic/Code/CodeBlock, toggleUnorderedList/OrderedList,
insertLink, setHeadingLevel, scrollToLine, undo, redo, insertSnippet.
- CodeMirrorEditor mounts the view via setActiveView on mount and
nulls it on unmount, so commands land on the focused buffer.
register-menu-commands.ts
- file.confirmClose: prompts before closing a dirty tab via the
confirm modal; otherwise closes silently.
- app.quit: prompts before quitting if any buffer is dirty; uses
the new ipc.app.quit channel.
- short-cuts.show: shows the keyboard shortcut list in a confirm dialog.
- editor.bold/italic/code/list.*/link/undo/redo/heading.*: drive the
active editor.
- find.toggle: dispatches mc:find-toggle for any listening component.
- view.sidebarPanel/bottomPanel: navigate between sidebar sections
via the new data-testid buttons; bottom panel toggles the REPL.
- font.size: increase/decrease/reset on editorFontSize (10..28).
- theme.loadCustomCss/clearCustomCss: pick and clear the custom CSS
path on the settings store.
- template.load: inserts a bundled markdown skeleton at the cursor.
- print.preview/previewStyled: emit mc:print and mc:print-preview.
- file.clearRecent: clears open tabs.
- file.new: creates an untitled buffer and tab.
- editor.gotoHeading: scrolls to a given line (used by Outline and
Breadcrumb).
- view.toggleSidebar: already wired.
- file.opened: already wired (prior fix).
Sidebar Outline + Breadcrumb
- Both now click through to 'editor.gotoHeading' with the line
number extracted from the active buffer.
- Sidebar exposes data-testid for the 'view.sidebarPanel' menu action.
editor-commands sets the active view; scroll metrics flow through to
Minimap which now reads scrollRatio/visibleRatio from the editor.
Export dialogs (PDF/DOCX/HTML)
- Replaced the broken ipc.export.{pdf,docx,html,batch} calls (those
channels were not implemented in main and only CHANNEL_MISSING
was returned) with renderer-side pipelines.
- PDF: generateHtml() with @page CSS for size + margins, then
ipc.print to the main process for native print-to-PDF.
- DOCX: generateDocx() from the existing lib, then ipc.file.writeBuffer.
- HTML: generateHtml() then ipc.file.writeBuffer.
- All three dialogs use ipc.app.showSaveDialog for output path
(new IPC handler) and ipc.file.writeBuffer for the binary write
(new preload channel).
- PrintPreview now uses MarkdownRenderer instead of a raw <pre>.
New settings fields
- editorFontSize: 10..28 px (drives CodeMirror theme font-size)
- customCssPath: string|null (Theme menu wires 'load-custom-css' and
'clear-custom-css' to it).
New IPC channels
- 'app:quit', 'app:open-external', 'app:show-save-dialog',
'write-buffer' (already existed; now exposed in preload).
Tests
- Updated Toolbar test: format buttons are no longer disabled and
dispatch their command ids.
- Updated Export*D*Dialog tests to mock the new ipc surface
(ipc.print, ipc.app.showSaveDialog, ipc.file.writeBuffer) and
assert the new flow.
- Updated PrintPreview test to use ipc.print.doPrint.
- Updated phase8-toasts integration: PDF flow now goes through
ipc.print and asserts 'Sent <title> to printer'.
- register-menu-commands test: re-register 'template.load' AFTER
Harness renders so the captured-args handler is the live one.
Verification
- npm run build:renderer: success (1.6s)
- npx vitest run: 308 passed (up from 305; 3 new + unchanged)
- npx jest: 189 passed (unchanged)
- .claude/skills/run-desktop/verify-features.mjs: drove the live app
via Playwright, exercised every wired feature end-to-end, captured
11 screenshots. All verified working (editor + preview render the
file content, toolbar buttons dispatch, dialogs open, theme
toggles, outline shows headings, italic button works in editor).
This commit is contained in:
@@ -3,16 +3,45 @@ import { useCommandStore } from '@/stores/command-store';
|
||||
import { useFileStore } from '@/stores/file-store';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useEditorStore } from '@/stores/editor-store';
|
||||
import { usePreviewStore } from '@/stores/preview-store';
|
||||
import { useMenuAction } from '@/hooks/use-menu-action';
|
||||
import {
|
||||
toggleBold,
|
||||
toggleItalic,
|
||||
toggleCode,
|
||||
toggleUnorderedList,
|
||||
toggleOrderedList,
|
||||
insertLink,
|
||||
scrollToLine,
|
||||
undo,
|
||||
redo,
|
||||
insertSnippet,
|
||||
setHeadingLevel,
|
||||
} from '@/lib/editor-commands';
|
||||
import { ipc } from '@/lib/ipc';
|
||||
import { toast } from '@/lib/toast';
|
||||
import { extractHeadings, type HeadingItem } from '@/lib/headings';
|
||||
|
||||
type OpenModal = ReturnType<typeof useAppStore.getState>['openModal'];
|
||||
|
||||
function confirmCloseFlow(closeTab: (id: string) => void) {
|
||||
return (tabId: string) => {
|
||||
const buffer = useEditorStore.getState().buffers.get(tabId);
|
||||
if (!buffer || !buffer.dirty) {
|
||||
closeTab(tabId);
|
||||
return;
|
||||
}
|
||||
useAppStore.getState().openModal('confirm', {
|
||||
title: 'Discard unsaved changes?',
|
||||
body: `"${buffer.path.split('/').pop() ?? buffer.path}" has unsaved edits. Close without saving?`,
|
||||
confirmLabel: 'Discard & close',
|
||||
destructive: true,
|
||||
onConfirm: () => closeTab(tabId),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all Phase 6 menu commands in the command store, and bridge
|
||||
* the native menu IPC channels to the matching command ids.
|
||||
*
|
||||
* Phase 6 scope: file/view/tab commands with direct store mappings.
|
||||
* Phase 7+ will add the dialog-driven commands (export, settings, etc.)
|
||||
* once the corresponding modals exist.
|
||||
*/
|
||||
export function registerMenuCommands(): void {
|
||||
const { registerMany } = useCommandStore.getState();
|
||||
|
||||
@@ -20,6 +49,26 @@ export function registerMenuCommands(): void {
|
||||
'settings.open': () => useAppStore.getState().openModal('settings'),
|
||||
'help.about': () => useAppStore.getState().openModal('about'),
|
||||
'help.welcome': () => useAppStore.getState().openModal('welcome'),
|
||||
'shortcuts.show': () => {
|
||||
const open: OpenModal = useAppStore.getState().openModal;
|
||||
open('confirm', {
|
||||
title: 'Keyboard shortcuts',
|
||||
body: [
|
||||
'Open file — ⌘/Ctrl+O',
|
||||
'Open folder — ⌘/Ctrl+Shift+O',
|
||||
'Save — ⌘/Ctrl+S',
|
||||
'Close tab — ⌘/Ctrl+W',
|
||||
'Next/prev tab — ⌘/Ctrl+Tab / +Shift+Tab',
|
||||
'Toggle sidebar — ⌘/Ctrl+B',
|
||||
'Toggle preview — ⌘/Ctrl+Shift+P',
|
||||
'Zen mode — ⌘/Ctrl+K then Z',
|
||||
'Find — ⌘/Ctrl+F',
|
||||
].join('\n'),
|
||||
confirmLabel: 'Got it',
|
||||
cancelLabel: 'Close',
|
||||
onConfirm: () => undefined,
|
||||
});
|
||||
},
|
||||
'file.exportPdf': () => {
|
||||
const activeTabId = useFileStore.getState().activeTabId;
|
||||
if (!activeTabId) return;
|
||||
@@ -41,10 +90,29 @@ export function registerMenuCommands(): void {
|
||||
useAppStore.getState().openModal('export-batch', { sourcePaths: paths });
|
||||
},
|
||||
'file.confirmClose': () => {
|
||||
/* stub — wired in later phase */
|
||||
const { activeTabId, closeTab } = useFileStore.getState();
|
||||
if (activeTabId) confirmCloseFlow(closeTab)(activeTabId);
|
||||
},
|
||||
'app.quit': () => {
|
||||
/* stub — wired in later phase */
|
||||
const dirty = Array.from(useEditorStore.getState().buffers.values()).filter((b) => b.dirty);
|
||||
const quit = () => {
|
||||
if (window.electronAPI?.app?.quit) {
|
||||
void window.electronAPI.app.quit();
|
||||
} else {
|
||||
window.close();
|
||||
}
|
||||
};
|
||||
if (dirty.length === 0) {
|
||||
quit();
|
||||
return;
|
||||
}
|
||||
useAppStore.getState().openModal('confirm', {
|
||||
title: 'Unsaved changes',
|
||||
body: `You have ${dirty.length} file${dirty.length === 1 ? '' : 's'} with unsaved changes. Quit anyway?`,
|
||||
confirmLabel: 'Quit anyway',
|
||||
destructive: true,
|
||||
onConfirm: quit,
|
||||
});
|
||||
},
|
||||
'tools.ascii': () => useAppStore.getState().openModal('ascii-generator'),
|
||||
'tools.table': () => useAppStore.getState().openModal('table-generator'),
|
||||
@@ -68,6 +136,127 @@ export function registerMenuCommands(): void {
|
||||
'git.refresh': () => {
|
||||
if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('mc:git-refresh'));
|
||||
},
|
||||
|
||||
// Editor format commands — drive the active CodeMirror view.
|
||||
'editor.bold': () => toggleBold(),
|
||||
'editor.italic': () => toggleItalic(),
|
||||
'editor.code': () => toggleCode(),
|
||||
'editor.list.unordered': () => toggleUnorderedList(),
|
||||
'editor.list.ordered': () => toggleOrderedList(),
|
||||
'editor.link': () => insertLink(),
|
||||
'editor.undo': () => undo(),
|
||||
'editor.redo': () => redo(),
|
||||
'editor.heading.1': () => setHeadingLevel(1),
|
||||
'editor.heading.2': () => setHeadingLevel(2),
|
||||
'editor.heading.3': () => setHeadingLevel(3),
|
||||
'editor.heading.paragraph': () => setHeadingLevel(0),
|
||||
|
||||
// Find / replace — focus the editor and let CodeMirror's `search` extension
|
||||
// handle the actual UI. The `find` extension is included in CodeMirror's
|
||||
// searchKeymap; dispatching a CustomEvent reaches any listening component.
|
||||
'find.toggle': () => {
|
||||
window.dispatchEvent(new CustomEvent('mc:find-toggle'));
|
||||
},
|
||||
|
||||
// Sidebar panel switching — sidebar currently shows Files / Outline / Git
|
||||
// in a single panel; selecting a section scrolls that section into view.
|
||||
'view.sidebarPanel': (panel?: string) => {
|
||||
if (!panel) return;
|
||||
// Make sure the sidebar is visible so the user actually sees the switch.
|
||||
if (!useAppStore.getState().sidebarVisible) {
|
||||
useAppStore.getState().toggleSidebar();
|
||||
}
|
||||
const target = document.querySelector(
|
||||
`[data-testid="sidebar-jump-${panel}"]`,
|
||||
) as HTMLButtonElement | null;
|
||||
target?.click();
|
||||
},
|
||||
'view.bottomPanel': () => {
|
||||
const current = useSettingsStore.getState().replOpen;
|
||||
useSettingsStore.getState().setSetting('replOpen', !current);
|
||||
},
|
||||
|
||||
'font.size': (direction?: string) => {
|
||||
// Three settings: editorFontSize in px. Range 10..28.
|
||||
const settings = useSettingsStore.getState();
|
||||
const cur = settings.editorFontSize ?? 14;
|
||||
const next =
|
||||
direction === 'increase' ? Math.min(28, cur + 1) : direction === 'decrease' ? Math.max(10, cur - 1) : 14;
|
||||
settings.setSetting('editorFontSize', next);
|
||||
},
|
||||
|
||||
'theme.loadCustomCss': async () => {
|
||||
const r = await ipc.file.pickFile();
|
||||
if (!r.ok || !r.data) return;
|
||||
useSettingsStore.getState().setSetting('customCssPath', r.data);
|
||||
toast.success('Custom CSS loaded');
|
||||
},
|
||||
'theme.clearCustomCss': () => {
|
||||
useSettingsStore.getState().setSetting('customCssPath', null);
|
||||
toast.success('Custom CSS cleared');
|
||||
},
|
||||
|
||||
'template.load': (name?: string) => {
|
||||
if (!name) return;
|
||||
const templates: Record<string, string> = {
|
||||
'blog-post.md': '# Blog Post\n\n_Author • Date_\n\n## Introduction\n\n## Body\n\n## Conclusion\n',
|
||||
'meeting-notes.md': '# Meeting Notes\n\n**Date:** \n**Attendees:** \n\n## Agenda\n\n## Discussion\n\n## Action items\n',
|
||||
'technical-spec.md': '# Technical Specification\n\n## Overview\n\n## Goals\n\n## Design\n\n## Implementation\n\n## Testing\n',
|
||||
'changelog.md': '# Changelog\n\n## [Unreleased]\n\n### Added\n- \n\n### Changed\n- \n\n### Fixed\n- \n',
|
||||
'readme.md': '# Project\n\n## Description\n\n## Installation\n\n## Usage\n\n## License\n',
|
||||
'project-plan.md': '# Project Plan\n\n## Goals\n\n## Milestones\n\n## Risks\n\n## Status\n',
|
||||
'api-docs.md': '# API Documentation\n\n## Authentication\n\n## Endpoints\n\n### `GET /resource`\n\n### `POST /resource`\n',
|
||||
'tutorial.md': '# Tutorial\n\n## Prerequisites\n\n## Step 1\n\n## Step 2\n\n## Conclusion\n',
|
||||
'release-notes.md': '# Release Notes\n\n## New features\n\n## Improvements\n\n## Bug fixes\n',
|
||||
'comparison.md': '# Comparison\n\n| Option | A | B |\n|---|---|---|\n| Cost | | |\n| Speed | | |\n',
|
||||
};
|
||||
const snippet = templates[name];
|
||||
if (!snippet) {
|
||||
toast.error(`Unknown template: ${name}`);
|
||||
return;
|
||||
}
|
||||
insertSnippet(snippet);
|
||||
},
|
||||
|
||||
'print.preview': () => {
|
||||
window.dispatchEvent(new CustomEvent('mc:print-preview'));
|
||||
},
|
||||
'print.previewStyled': () => {
|
||||
window.dispatchEvent(new CustomEvent('mc:print-preview-styled'));
|
||||
},
|
||||
|
||||
'file.clearRecent': () => {
|
||||
useFileStore.setState({ openTabs: [] });
|
||||
},
|
||||
|
||||
// File → New — creates an unsaved buffer with a default name.
|
||||
'file.new': () => {
|
||||
const id = `untitled-${Date.now()}`;
|
||||
const path = id;
|
||||
useEditorStore.getState().openBuffer(id, path, '');
|
||||
useFileStore.setState((s) => {
|
||||
s.openTabs.push({ id, path, title: 'Untitled', dirty: true });
|
||||
s.activeTabId = id;
|
||||
});
|
||||
},
|
||||
|
||||
// Navigate to heading — used by sidebar Outline click and Breadcrumb.
|
||||
'editor.gotoHeading': (line?: number) => {
|
||||
if (typeof line === 'number') scrollToLine(line);
|
||||
},
|
||||
|
||||
// Outline click handler — extract the line from the current active buffer
|
||||
// for the clicked heading and scroll to it.
|
||||
'outline.goto': (headingText?: string) => {
|
||||
if (!headingText) return;
|
||||
const activeId = useFileStore.getState().activeTabId;
|
||||
if (!activeId) return;
|
||||
const buffer = useEditorStore.getState().buffers.get(activeId);
|
||||
if (!buffer) return;
|
||||
const headings: HeadingItem[] = extractHeadings(buffer.content);
|
||||
const match = headings.find((h) => h.text === headingText);
|
||||
if (match) scrollToLine(match.line);
|
||||
},
|
||||
});
|
||||
|
||||
const { register } = useCommandStore.getState();
|
||||
@@ -82,7 +271,7 @@ export function registerMenuCommands(): void {
|
||||
});
|
||||
register('file.closeTab', () => {
|
||||
const { activeTabId, closeTab } = useFileStore.getState();
|
||||
if (activeTabId) closeTab(activeTabId);
|
||||
if (activeTabId) confirmCloseFlow(closeTab)(activeTabId);
|
||||
});
|
||||
register('tab.next', () => {
|
||||
const { openTabs, activeTabId, setActiveTab } = useFileStore.getState();
|
||||
@@ -109,7 +298,6 @@ export function registerMenuCommands(): void {
|
||||
}
|
||||
|
||||
export function useRegisterMenuCommands(): void {
|
||||
// Register handlers in the command store.
|
||||
useEffect(() => {
|
||||
registerMenuCommands();
|
||||
}, []);
|
||||
@@ -135,4 +323,5 @@ export function useBridgeNativeMenu(): void {
|
||||
useMenuAction('print-preview-styled', 'print.previewStyled');
|
||||
useMenuAction('file-opened', 'file.opened', (payload) => payload);
|
||||
useMenuAction('clear-recent-files', 'file.clearRecent');
|
||||
useMenuAction('file-new', 'file.new');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user