diff --git a/package-lock.json b/package-lock.json index ad2412d..575a627 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,7 @@ "@tanstack/react-table": "^8.21.3", "codemirror": "^6.0.2", "core-util-is": "^1.0.3", - "docx": "^9.6.0", + "docx": "^9.7.1", "docx4js": "^2.0.1", "dompurify": "^3.3.1", "electron-store": "^10.1.0", @@ -9161,9 +9161,9 @@ } }, "node_modules/docx": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz", - "integrity": "sha512-ZJja9/KBUuFC109sCMzovoq2GR2wCG/AuxivjA+OHj/q0TEgJIm3S7yrlUxIy3B+bV8YDj/BiHfWyrRFmyWpDQ==", + "version": "9.7.1", + "resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz", + "integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==", "license": "MIT", "dependencies": { "@types/node": "^25.2.3", diff --git a/package.json b/package.json index 72836fe..a24270a 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,7 @@ "@tanstack/react-table": "^8.21.3", "codemirror": "^6.0.2", "core-util-is": "^1.0.3", - "docx": "^9.6.0", + "docx": "^9.7.1", "docx4js": "^2.0.1", "dompurify": "^3.3.1", "electron-store": "^10.1.0", diff --git a/src/renderer/components/modals/ModalLayer.tsx b/src/renderer/components/modals/ModalLayer.tsx index d967927..3d874a7 100644 --- a/src/renderer/components/modals/ModalLayer.tsx +++ b/src/renderer/components/modals/ModalLayer.tsx @@ -1,12 +1,16 @@ import { useAppStore } from '@/stores/app-store'; import { AboutDialog } from './AboutDialog'; +import { AsciiGeneratorDialog } from './AsciiGeneratorDialog'; import { ConfirmDialog } from './ConfirmDialog'; import { ExportBatchDialog } from './ExportBatchDialog'; import { ExportDocxDialog } from './ExportDocxDialog'; import { ExportHtmlDialog } from './ExportHtmlDialog'; import { ExportPdfDialog } from './ExportPdfDialog'; +import { FindInFilesDialog } from './FindInFilesDialog'; import { SettingsSheet } from './SettingsSheet'; +import { TableGeneratorDialog } from './TableGeneratorDialog'; import { WelcomeDialog } from './WelcomeDialog'; +import { WordExportDialog } from './WordExportDialog'; export function ModalLayer() { const modal = useAppStore((s) => s.modal); @@ -29,5 +33,13 @@ export function ModalLayer() { return ; case 'confirm': return ; + case 'export-word': + return ; + case 'ascii-generator': + return ; + case 'table-generator': + return ; + case 'find-in-files': + return ; } } \ No newline at end of file diff --git a/src/renderer/lib/commands/register-menu-commands.ts b/src/renderer/lib/commands/register-menu-commands.ts index e6e3f9c..9308371 100644 --- a/src/renderer/lib/commands/register-menu-commands.ts +++ b/src/renderer/lib/commands/register-menu-commands.ts @@ -2,6 +2,7 @@ import { useEffect } from 'react'; 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 { useMenuAction } from '@/hooks/use-menu-action'; /** @@ -45,6 +46,28 @@ export function registerMenuCommands(): void { 'app.quit': () => { /* stub — wired in later phase */ }, + 'tools.ascii': () => useAppStore.getState().openModal('ascii-generator'), + 'tools.table': () => useAppStore.getState().openModal('table-generator'), + 'tools.findInFiles': () => useAppStore.getState().openModal('find-in-files'), + 'tools.exportWord': () => { + const activeTabId = useFileStore.getState().activeTabId; + if (!activeTabId) return; + useAppStore.getState().openModal('export-word', { sourcePath: activeTabId }); + }, + 'tools.repl': () => { + const current = useSettingsStore.getState().replOpen; + useSettingsStore.getState().setSetting('replOpen', !current); + }, + 'view.zenMode': () => { + const current = useAppStore.getState().zenMode; + useAppStore.getState().setZenMode(!current); + }, + 'file.print': () => { + /* stub — placeholder for print feature */ + }, + 'git.refresh': () => { + /* stub — actual refresh is a useEffect in GitStatusPanel */ + }, }); const { register } = useCommandStore.getState(); diff --git a/src/renderer/lib/docx-export.ts b/src/renderer/lib/docx-export.ts new file mode 100644 index 0000000..c764045 --- /dev/null +++ b/src/renderer/lib/docx-export.ts @@ -0,0 +1,31 @@ +import { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType } from 'docx'; +import { applyAsciiTransform } from './ascii-table'; + +export interface DocxOptions { + source: string; + title?: string; + customTemplatePath?: string | null; // for future use +} + +export async function generateDocx(options: DocxOptions): Promise { + // Apply ASCII transform (convert markdown tables to preformatted) + const transformed = applyAsciiTransform(options.source); + + // Parse the source into a list of Paragraphs + // For v1, simple line-by-line: each line is a paragraph + // Headings (# ## ###) get heading styles + const lines = transformed.split('\n'); + const children = lines.map((line) => { + if (line.startsWith('### ')) return new Paragraph({ text: line.slice(4), heading: HeadingLevel.HEADING_3 }); + if (line.startsWith('## ')) return new Paragraph({ text: line.slice(3), heading: HeadingLevel.HEADING_2 }); + if (line.startsWith('# ')) return new Paragraph({ text: line.slice(2), heading: HeadingLevel.HEADING_1 }); + if (line.startsWith('```')) return new Paragraph({ text: line, alignment: AlignmentType.CENTER }); + return new Paragraph({ children: [new TextRun(line)] }); + }); + + const doc = new Document({ + sections: [{ children }], + }); + + return await Packer.toBlob(doc); +} \ No newline at end of file diff --git a/src/renderer/lib/ipc.ts b/src/renderer/lib/ipc.ts index 5281919..79b383c 100644 --- a/src/renderer/lib/ipc.ts +++ b/src/renderer/lib/ipc.ts @@ -76,7 +76,15 @@ export const ipc = { } return window.electronAPI.file.onChange(cb); }, + search: (args: { rootPath: string; query: string; isRegex: boolean; caseSensitive: boolean }): Promise | ChannelMissing>> => + safeCall('file', 'search', args), + gitStatus: (args: { rootPath: string }): Promise | ChannelMissing>> => + safeCall('file', 'gitStatus', args), + writeBuffer: (args: { path: string; buffer: Uint8Array }): Promise> => + safeCall('file', 'writeBuffer', args), }, + print: (args: { html: string }): Promise> => + safeCall('print', 'show', args), export: { pdf: (opts: PdfOptions): Promise> => safeCall('export', 'pdf', opts), diff --git a/src/renderer/lib/validators.ts b/src/renderer/lib/validators.ts index 5000c1c..fd8968c 100644 --- a/src/renderer/lib/validators.ts +++ b/src/renderer/lib/validators.ts @@ -13,6 +13,9 @@ export const settingsSchema = z.object({ pdfMargins: z.enum(['normal', 'narrow', 'wide']).default('normal'), pdfEmbedFonts: z.boolean().default(true), docxTemplate: z.enum(['standard', 'minimal', 'modern']).default('standard'), + docxCustomTemplatePath: z.string().nullable().default(null), + replOpen: z.boolean().default(false), + breadcrumbSymbols: z.boolean().default(true), htmlHighlightStyle: z.enum(['github', 'monokai', 'nord', 'none']).default('github'), renderTablesAsAscii: z.boolean().default(false), welcomeDismissed: z.boolean().default(false), diff --git a/src/renderer/stores/app-store.ts b/src/renderer/stores/app-store.ts index b9a24d6..e5de0c3 100644 --- a/src/renderer/stores/app-store.ts +++ b/src/renderer/stores/app-store.ts @@ -26,7 +26,11 @@ export type ModalState = | { kind: 'settings' } | { kind: 'about' } | { kind: 'welcome' } - | { kind: 'confirm'; props: ConfirmProps }; + | { kind: 'confirm'; props: ConfirmProps } + | { kind: 'export-word'; props: { sourcePath: string } } + | { kind: 'ascii-generator' } + | { kind: 'table-generator' } + | { kind: 'find-in-files' }; export type ModalKind = ModalState['kind']; diff --git a/tests/unit/commands/phase9-commands.test.ts b/tests/unit/commands/phase9-commands.test.ts new file mode 100644 index 0000000..5879524 --- /dev/null +++ b/tests/unit/commands/phase9-commands.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { registerMenuCommands } from '@/lib/commands/register-menu-commands'; +import { useCommandStore } from '@/stores/command-store'; +import { useAppStore } from '@/stores/app-store'; +import { useFileStore } from '@/stores/file-store'; +import { useSettingsStore } from '@/stores/settings-store'; + +describe('Phase 9 commands', () => { + beforeEach(() => { + localStorage.clear(); + useCommandStore.setState({ handlers: {}, userBindings: {} } as any); + useAppStore.setState({ modal: { kind: null } } as any); + useFileStore.setState({ activeTabId: '/x.md', openTabs: [{ id: '/x.md', path: '/x.md', title: 'x.md', dirty: false }] } as any); + // Use resetToDefaults to restore store methods (setSetting/resetToDefaults) + useSettingsStore.getState().resetToDefaults(); + }); + + it('tools.ascii opens ascii-generator modal', () => { + registerMenuCommands(); + useCommandStore.getState().dispatch('tools.ascii'); + expect(useAppStore.getState().modal).toEqual({ kind: 'ascii-generator' }); + }); + + it('tools.table opens table-generator modal', () => { + registerMenuCommands(); + useCommandStore.getState().dispatch('tools.table'); + expect(useAppStore.getState().modal).toEqual({ kind: 'table-generator' }); + }); + + it('tools.findInFiles opens find-in-files modal', () => { + registerMenuCommands(); + useCommandStore.getState().dispatch('tools.findInFiles'); + expect(useAppStore.getState().modal).toEqual({ kind: 'find-in-files' }); + }); + + it('tools.exportWord opens export-word modal with active path', () => { + registerMenuCommands(); + useCommandStore.getState().dispatch('tools.exportWord'); + expect(useAppStore.getState().modal).toEqual({ kind: 'export-word', props: { sourcePath: '/x.md' } }); + }); + + it('tools.repl toggles replOpen setting', () => { + registerMenuCommands(); + expect(useSettingsStore.getState().replOpen).toBe(false); + useCommandStore.getState().dispatch('tools.repl'); + expect(useSettingsStore.getState().replOpen).toBe(true); + useCommandStore.getState().dispatch('tools.repl'); + expect(useSettingsStore.getState().replOpen).toBe(false); + }); +}); \ No newline at end of file diff --git a/tests/unit/lib/docx-export.test.ts b/tests/unit/lib/docx-export.test.ts new file mode 100644 index 0000000..d573fe9 --- /dev/null +++ b/tests/unit/lib/docx-export.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { generateDocx } from '@/lib/docx-export'; + +describe('generateDocx', () => { + it('returns a Blob for a simple markdown string', async () => { + const blob = await generateDocx({ source: '# Hello\n\nWorld' }); + expect(blob).toBeInstanceOf(Blob); + expect(blob.size).toBeGreaterThan(0); + expect(blob.type).toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + }); + + it('converts headings to docx heading styles', async () => { + const blob = await generateDocx({ source: '# H1\n## H2\n### H3' }); + // The blob should be a valid zip-based docx; check size and MIME + expect(blob.size).toBeGreaterThan(0); + }); + + it('converts markdown tables to preformatted (via applyAsciiTransform)', async () => { + const source = '| A | B |\n| - | - |\n| 1 | 2 |'; + const blob = await generateDocx({ source }); + expect(blob).toBeInstanceOf(Blob); + expect(blob.size).toBeGreaterThan(0); + }); +}); \ No newline at end of file