Compare commits

...
39 changed files with 1804 additions and 13 deletions
@@ -0,0 +1,303 @@
# Phase 9 — Advanced Tools Design
> Companion to the parent plan: `docs/superpowers/plans/2026-06-05-react-ui-redesign.md` (Phase 9 is sketched at high level; this spec locks architecture, file map, and contracts so it can be planned task-by-task.)
**Date:** 2026-06-05
**Phase:** 9 of 10 (React + shadcn/ui UI redesign)
**Tag (on completion):** `phase-9-advanced-tools`
---
## 1. Goal & Non-Goals
**Goal:** Add 10 advanced tools to the React renderer. Group 1: Standalone dialogs (ASCII generator, Table generator, Word export, Find-in-files). Group 2: Global overlays (Zen mode, REPL, Print preview). Group 3: Editor/sidebar integrations (Minimap, Breadcrumbs-with-symbols, Git status). All triggered via the Phase 6 command store.
**Non-goals (Phase 9):**
- True undo/redo stack (still not in scope)
- Snippet/template library
- Custom REPL with full JS eval (we chose markdown snippet preview for safety)
- Custom diff/merge UI (just shows git status, no in-app diffing)
- Plug-in extensions
- Multi-cursor editing
- LSP / language server integration
**Decision summary (from brainstorming):**
- REPL = **markdown snippet preview** (no JS eval — safe in renderer)
- Word export = **`.docx` via `docx` lib in renderer**, with both Standard and Custom .dotx template modes
- Find-in-files = **recursive search with result navigation** (new IPC, regex support)
---
## 2. Architecture
### 2.1 Three mount strategies
| Category | Mount | Examples |
|---|---|---|
| **Dialogs** | `<ModalLayer />` (Phase 7 pattern) | ASCII gen, Table gen, Word export, Find-in-files |
| **Global overlays** | Top-level in `App.tsx` (like `<Toaster />`) | Zen mode, REPL panel, Print preview |
| **Editor/sidebar integrations** | Extend existing components | Minimap, Breadcrumbs, Git status |
The ModalLayer is the dispatcher for dialogs. The global overlays are mounted directly in `App.tsx` because they need to participate in the top-level layout (full-window, or pinned to the bottom).
### 2.2 ModalState union extension
The Phase 7 `ModalState` discriminated union (9 kinds) gets 4 new kinds for the new dialogs:
```ts
export type ModalState =
| { kind: null }
| { kind: 'export-pdf'; props: { sourcePath: string } }
| { kind: 'export-docx'; props: { sourcePath: string } }
| { kind: 'export-html'; props: { sourcePath: string } }
| { kind: 'export-batch'; props: { sourcePaths: string[] } }
| { kind: 'export-word'; props: { sourcePath: string } } // NEW
| { kind: 'ascii-generator' } // NEW
| { kind: 'table-generator' } // NEW
| { kind: 'find-in-files' } // NEW
| { kind: 'settings' }
| { kind: 'about' }
| { kind: 'welcome' }
| { kind: 'confirm'; props: ConfirmProps };
```
Three of the four new kinds (ascii, table, find-in-files) take no props — they read from the active buffer via `useExportSource` (for ascii/table) or from `useFileStore.rootPath` (for find-in-files). `export-word` takes `sourcePath` like the other export dialogs.
### 2.3 New commands in command store
| Command ID | Trigger | Opens |
|---|---|---|
| `tools.ascii` | new | `AsciiGeneratorDialog` |
| `tools.table` | new | `TableGeneratorDialog` |
| `tools.exportWord` | new | `WordExportDialog` |
| `tools.findInFiles` | new | `FindInFilesDialog` |
| `tools.repl` | new | toggles REPL panel |
| `view.zenMode` | existing (Phase 6) | toggles Zen mode overlay |
| `file.print` | new | opens `PrintPreview` overlay |
| `git.refresh` | new | re-fetches git status |
All registered in `src/renderer/lib/commands/register-menu-commands.ts`.
### 2.4 New IPC surface
```ts
// src/renderer/lib/ipc.ts (additions)
ipc.file.search({ rootPath, query, isRegex, caseSensitive }: {
rootPath: string;
query: string;
isRegex: boolean;
caseSensitive: boolean;
}): Promise<IpcResult<Array<{ filePath: string; line: number; content: string }>>>;
ipc.file.gitStatus({ rootPath }: { rootPath: string }): Promise<IpcResult<Array<{ filePath: string; status: 'modified' | 'added' | 'deleted' | 'untracked' }>>>;
ipc.file.print({ html }: { html: string }): Promise<IpcResult<void>>;
ipc.file.writeBuffer({ path, buffer }: { path: string; buffer: Uint8Array }): Promise<IpcResult<void>>;
```
The main process counterparts are added to `src/main.js` (or a new `src/main/files-search.js`, etc.). For Phase 9, the spec covers the renderer-side design; main-process IPC handlers are assumed to follow the same `ipcMain.handle` pattern as the existing handlers.
### 2.5 Settings additions (`useSettingsStore`)
```ts
// zod schema additions:
docxCustomTemplatePath: z.string().nullable().default(null), // path to user .dotx file
replOpen: z.boolean().default(false), // REPL panel visibility
breadcrumbSymbols: z.boolean().default(true), // breadcrumbs show code symbols
// minimap already exists from Phase 7 (useSettingsStore.minimap: z.boolean().default(true))
```
The `minimap` setting from Phase 7 is now wired (not just stored).
---
## 3. File Map
### 3.1 Created files
**Dialogs (in `src/renderer/components/modals/`):**
- `AsciiGeneratorDialog.tsx` — input textarea, font select, output preview with copy button
- `TableGeneratorDialog.tsx` — rows × cols inputs, header checkbox, output preview
- `WordExportDialog.tsx` — template select (Standard / Custom .dotx), options, preview, export
- `FindInFilesDialog.tsx` — query input, regex/case toggles, results list with click-to-navigate
**Global overlays (in `src/renderer/components/tools/`):**
- `ReplPanel.tsx` — bottom-pinned, textarea + rendered preview
- `PrintPreview.tsx` — full-window print preview
**Editor/sidebar integrations:**
- `src/renderer/components/layout/ZenMode.tsx` — wraps/controls the editor in zen mode (or modify `AppShell.tsx` to hide chrome)
- `src/renderer/components/sidebar/GitStatusPanel.tsx` — file list with status badges
**Lib:**
- `src/renderer/lib/docx-export.ts` — renderer-side `docx` lib integration (markdown → Blob)
- `src/renderer/hooks/use-zen-mode.ts` — small hook that reads `useSettingsStore.zenMode`
**Modifications:**
- `src/renderer/App.tsx` — mount `<ReplPanel />` and `<PrintPreview />` alongside `<ModalLayer />` and `<Toaster />`
- `src/renderer/components/layout/AppShell.tsx` — when `zenMode === true`, hide all chrome except the editor
- `src/renderer/components/editor/CodeMirrorEditor.tsx` — add `@codemirror/minimap` (or `@replit/codemirror-minimap`) when `useSettingsStore.minimap` is true
- `src/renderer/components/layout/Breadcrumb.tsx` — extend to show symbols (headings, code blocks) using `@codemirror/langs-data` or a simple markdown AST walk
- `src/renderer/components/sidebar/Sidebar.tsx` — add a "Git" tab to the existing tab list
- `src/renderer/stores/settings-store.ts` — add 3 new fields
- `src/renderer/lib/validators.ts` — add 3 new fields to `settingsSchema`
- `src/renderer/lib/ipc.ts` — add 4 new IPC methods
- `src/renderer/lib/commands/register-menu-commands.ts` — add 8 new commands
- `src/main.js` (or split files) — add 4 main-process IPC handlers
**Tests:**
- `tests/component/tools/ReplPanel.test.tsx` — smoke test
- `tests/component/tools/PrintPreview.test.tsx` — smoke test
- `tests/component/modals/AsciiGeneratorDialog.test.tsx` — 3 tests
- `tests/component/modals/TableGeneratorDialog.test.tsx` — 3 tests
- `tests/component/modals/WordExportDialog.test.tsx` — 4 tests
- `tests/component/modals/FindInFilesDialog.test.tsx` — 4 tests
- `tests/component/sidebar/GitStatusPanel.test.tsx` — 3 tests
- `tests/component/layout/Breadcrumb.test.tsx` — extend with symbols test
- `tests/integration/phase9-tools-smoke.test.tsx` — 6 tests (one per command-triggered tool)
---
## 4. Data Flow
### 4.1 Word export (most complex)
1. User triggers `tools.exportWord``registerMenuCommands` opens the export-word modal via `useAppStore.openModal('export-word', { sourcePath: activeTabId })`
2. **Wait** — we need a new modal kind `export-word` in the `ModalState` union. Add it.
3. `WordExportDialog` (mounted by ModalLayer) shows:
- Template select: "Standard (bundled)" or "Custom .dotx (your file)" — pre-populated with `useSettingsStore.docxCustomTemplatePath`
- "Choose custom template..." button (file picker, saves to settings)
- Options: embed images (checkbox), include front matter (checkbox)
- Preview area: shows the generated docx structure (sizes, table of contents)
4. On submit:
- Renderer calls `lib/docx-export.ts#generateDocx(source, templatePath, options)` which uses the `docx` lib
- The lib takes a `Document` AST and produces a Blob
- ASCII tables are converted to monospace `<w:r>` runs (using `applyAsciiTransform` from Phase 7)
- Images are extracted from markdown and embedded as base64
- If a custom .dotx path is set, the renderer reads it via `ipc.file.read` and applies its styles (via `docx` lib's style support)
- User picks output path via `ipc.app.showSaveDialog`
- Writes Blob via `ipc.file.writeBuffer` (new)
- Toast success/failure
### 4.2 Find-in-files
1. User triggers `tools.findInFiles` → ModalLayer opens `FindInFilesDialog`
2. User types query, picks regex/literal, case-sensitive toggle
3. On submit: `ipc.file.search({ rootPath: useFileStore.rootPath, query, isRegex, caseSensitive })`
4. Backend walks the disk recursively, applies regex/literal match, returns `Array<{ filePath, line, content }>`
5. Dialog shows result list (file:line:content, clickable)
6. Click a result:
- `useFileStore.openFile(filePath)` if not already open
- Set the cursor in the editor to the matched line
7. Close the dialog
### 4.3 REPL (markdown snippet preview)
1. User triggers `tools.repl` → toggles `useSettingsStore.replOpen` (or a new dedicated `useReplStore`)
2. `ReplPanel` (mounted in App.tsx) is visible when `replOpen === true`
3. The panel has:
- Top half: textarea (user types/pastes markdown)
- Bottom half: rendered preview (uses `lib/markdown.ts` from Phase 1 + DOMPurify)
4. Updates are debounced (300ms) for the preview
5. Pure renderer-side, no IPC
### 4.4 Zen mode
1. User triggers `view.zenMode` → toggles `useSettingsStore.zenMode`
2. `AppShell` reads `zenMode` from store; when true, hides all chrome (header, tabs, toolbar, breadcrumb, status bar, sidebar)
3. Editor goes fullscreen
4. Pressing Esc exits zen mode (keydown listener in `use-zen-mode` hook)
### 4.5 Print preview
1. User triggers `file.print` → opens `PrintPreview` (full-window overlay)
2. The preview renders the current buffer's content as it would appear in print
3. Two buttons: "Print" (calls `ipc.file.print({ html })` which opens native print dialog) and "Close"
### 4.6 Minimap
1. `useSettingsStore.minimap` (existing setting) is read by `CodeMirrorEditor`
2. When true, the editor's extension includes `@replit/codemirror-minimap` (or a simpler custom implementation)
3. The minimap shows a shrunk version of the document on the right side of the editor
4. Toggling the setting adds/removes the extension dynamically
### 4.7 Breadcrumbs-with-symbols
1. The current `Breadcrumb` shows the file path. Extend it to also show markdown symbols (headings, code blocks)
2. Use a simple AST walk of the current buffer to extract heading levels
3. Show as a path-like navigation: `file.md > H1: Title > H2: Section > #line`
4. The `useSettingsStore.breadcrumbSymbols` toggle (default true) controls whether symbols are shown
### 4.8 Git status
1. On sidebar Git tab open, fetch `ipc.file.gitStatus({ rootPath: useFileStore.rootPath })`
2. The main process runs `git status --porcelain` (or equivalent) and returns the list
3. Show file list with status badges (M, A, D, ?)
4. Click a file opens it via `useFileStore.openFile`
5. `git.refresh` re-fetches
---
## 5. Error Handling
- **Word export failures:** `lib/docx-export.ts` catches `docx` lib errors → toast.error
- **Word export missing .dotx:** if custom template path is set but file doesn't exist, show inline banner in the dialog
- **Find-in-files failures:** if `ipc.file.search` throws (regex syntax error, IO error), show inline banner in the dialog
- **REPL:** no IPC, no error handling needed
- **Git status:** if folder isn't a git repo, return empty array. Panel shows "Not a git repository" message
- **Print:** if `ipc.file.print` fails (no printer, user cancels), toast.error or silent close
- **Minimap, Breadcrumbs:** renderer-side, no errors
---
## 6. Testing Strategy
- **Unit tests:** `lib/docx-export.ts` (markdown → docx conversion, ASCII handling), `lib/ascii-table.ts` (already tested)
- **Component tests:** 1-2 smoke tests per new component
- **Integration test:** `phase9-tools-smoke.test.tsx` covering the 6 command-triggered tools (dispatch opens correct modal/overlay)
- **Editor integrations:** minimap toggle, breadcrumbs render with symbols, git status panel render
- **Target: +30-40 new tests**, total ~290
---
## 7. Risks & Open Questions
**Risks:**
- **`docx` lib bundle size (~500KB).** Acceptable for a desktop app but worth noting. Lazy-load if it becomes a concern.
- **Word export custom template parsing** — the `.dotx` format is a zip with XML inside. The `docx` lib may not fully support reading existing templates. v1 may need a simpler "styles only" extraction.
- **Find-in-files regex compilation** — invalid regex would throw. Validate on the renderer before calling IPC.
- **Minimap performance** — for large files, the minimap can slow down scrolling. CodeMirror's built-in minimap has performance options to configure.
**Open questions (deferrable):**
- **REPL persistence** — should the textarea content survive across modal close/reopen? → Decision: NO for v1. Keep simple.
- **Git status auto-refresh** — should it auto-refresh every N seconds? → Decision: NO for v1. Manual via `git.refresh` command.
- **Find-in-files result count limit** — what if there are 10,000 matches? → Decision: limit to 500 results for v1, show "X more matches" message.
---
## 8. Out of Scope (deferred to Phase 10 or later)
- Custom undo/redo (still not in scope)
- Snippet/template library
- Multi-cursor editing
- LSP / language server
- Phase 10 will delete legacy files including the old `src/print-preview.js`, `src/wordTemplateExporter.js`, `src/welcome.js`, `src/zen-mode.js`, etc.
---
## 9. Success Criteria
Phase 9 is complete when:
- All 10 features implemented and accessible via the command store
- `lib/docx-export.ts` generates valid `.docx` files
- Find-in-files returns results from a recursive disk walk
- REPL renders markdown snippet previews
- Zen mode hides all chrome and Esc exits
- Print preview opens native print dialog
- Minimap appears when `useSettingsStore.minimap` is true
- Breadcrumbs show symbols by default
- Git status panel shows modified/added/deleted/untracked files
- ~+30-40 new tests, total ~290
- `npx vite build` succeeds
- Branch tagged `phase-9-advanced-tools` and pushed to origin
+37 -4
View File
@@ -42,11 +42,12 @@
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"core-util-is": "^1.0.3", "core-util-is": "^1.0.3",
"docx": "^9.6.0", "docx": "^9.7.1",
"docx4js": "^2.0.1", "docx4js": "^2.0.1",
"dompurify": "^3.3.1", "dompurify": "^3.3.1",
"electron-store": "^10.1.0", "electron-store": "^10.1.0",
"ffmpeg-static": "^5.3.0", "ffmpeg-static": "^5.3.0",
"figlet": "^1.11.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"html2pdf.js": "^0.14.0", "html2pdf.js": "^0.14.0",
"immer": "^11.1.8", "immer": "^11.1.8",
@@ -76,6 +77,7 @@
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/figlet": "^1.7.0",
"@types/node": "^25.9.1", "@types/node": "^25.9.1",
"@types/react": "^19.2.16", "@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@@ -5737,6 +5739,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/figlet": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@types/figlet/-/figlet-1.7.0.tgz",
"integrity": "sha512-KwrT7p/8Eo3Op/HBSIwGXOsTZKYiM9NpWRBJ5sVjWP/SmlS+oxxRvJht/FNAtliJvja44N3ul1yATgohnVBV0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/fs-extra": { "node_modules/@types/fs-extra": {
"version": "9.0.13", "version": "9.0.13",
"resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz",
@@ -9161,9 +9170,9 @@
} }
}, },
"node_modules/docx": { "node_modules/docx": {
"version": "9.6.1", "version": "9.7.1",
"resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz", "resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz",
"integrity": "sha512-ZJja9/KBUuFC109sCMzovoq2GR2wCG/AuxivjA+OHj/q0TEgJIm3S7yrlUxIy3B+bV8YDj/BiHfWyrRFmyWpDQ==", "integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "^25.2.3", "@types/node": "^25.2.3",
@@ -10241,6 +10250,30 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/figlet": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/figlet/-/figlet-1.11.0.tgz",
"integrity": "sha512-EEx3OS/l2bFqcUNN2NM9FPJp8vAMrgbCxsbl2hbcJNNxOEwVe3mEzrhan7TbJQViZa8mMqhihlbCaqD+LyYKTQ==",
"license": "MIT",
"dependencies": {
"commander": "^14.0.0"
},
"bin": {
"figlet": "bin/index.js"
},
"engines": {
"node": ">= 17.0.0"
}
},
"node_modules/figlet/node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+3 -1
View File
@@ -51,6 +51,7 @@
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/figlet": "^1.7.0",
"@types/node": "^25.9.1", "@types/node": "^25.9.1",
"@types/react": "^19.2.16", "@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@@ -113,11 +114,12 @@
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"core-util-is": "^1.0.3", "core-util-is": "^1.0.3",
"docx": "^9.6.0", "docx": "^9.7.1",
"docx4js": "^2.0.1", "docx4js": "^2.0.1",
"dompurify": "^3.3.1", "dompurify": "^3.3.1",
"electron-store": "^10.1.0", "electron-store": "^10.1.0",
"ffmpeg-static": "^5.3.0", "ffmpeg-static": "^5.3.0",
"figlet": "^1.11.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"html2pdf.js": "^0.14.0", "html2pdf.js": "^0.14.0",
"immer": "^11.1.8", "immer": "^11.1.8",
+13
View File
@@ -1,15 +1,28 @@
import { useState, useEffect } from 'react';
import { AppShell } from './components/layout/AppShell'; import { AppShell } from './components/layout/AppShell';
import { ModalLayer } from './components/modals/ModalLayer'; import { ModalLayer } from './components/modals/ModalLayer';
import { Toaster } from './components/ui/sonner'; import { Toaster } from './components/ui/sonner';
import { ReplPanel } from './components/tools/ReplPanel';
import { PrintPreview } from './components/tools/PrintPreview';
import { useWelcomeTrigger } from './hooks/use-welcome-trigger'; import { useWelcomeTrigger } from './hooks/use-welcome-trigger';
function App() { function App() {
useWelcomeTrigger(); useWelcomeTrigger();
const [printOpen, setPrintOpen] = useState(false);
useEffect(() => {
const handler = () => setPrintOpen(true);
window.addEventListener('mc:print', handler);
return () => window.removeEventListener('mc:print', handler);
}, []);
return ( return (
<> <>
<AppShell /> <AppShell />
<ModalLayer /> <ModalLayer />
<Toaster /> <Toaster />
<ReplPanel />
{printOpen && <PrintPreview onClose={() => setPrintOpen(false)} />}
</> </>
); );
} }
@@ -9,6 +9,8 @@ import { oneDark } from '@codemirror/theme-one-dark';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { lightTheme, lightHighlight } from './themes/light'; import { lightTheme, lightHighlight } from './themes/light';
import { useEditorStore } from '@/stores/editor-store'; import { useEditorStore } from '@/stores/editor-store';
import { useSettingsStore } from '@/stores/settings-store';
import { Minimap } from './Minimap';
interface Props { interface Props {
bufferId: string; bufferId: string;
@@ -24,6 +26,7 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const updateContent = useEditorStore((s) => s.updateContent); const updateContent = useEditorStore((s) => s.updateContent);
const setCursor = useEditorStore((s) => s.setCursor); const setCursor = useEditorStore((s) => s.setCursor);
const minimap = useSettingsStore((s) => s.minimap);
useEffect(() => { useEffect(() => {
if (!ref.current) return; if (!ref.current) return;
@@ -76,5 +79,10 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
}); });
}, [resolvedTheme]); }, [resolvedTheme]);
return <div ref={ref} className="h-full overflow-hidden" />; return (
<div className="relative h-full overflow-hidden">
<div ref={ref} className="h-full overflow-hidden" />
{minimap && <Minimap content={initialContent} />}
</div>
);
} }
@@ -0,0 +1,33 @@
interface Props {
content: string;
scrollRatio?: number; // 0-1, where the viewport is
visibleRatio?: number; // 0-1, what fraction of content is visible
}
export function Minimap({ content, scrollRatio = 0, visibleRatio = 1 }: Props) {
const lines = content.split('\n');
// Compute viewport position in the minimap
const viewportTop = Math.round(scrollRatio * 100);
const viewportHeight = Math.round(visibleRatio * 100);
return (
<div
data-testid="minimap"
className="pointer-events-none absolute right-0 top-0 h-full w-[100px] overflow-hidden border-l border-border bg-card/30 p-1 font-mono text-[6px] leading-[8px] text-muted-foreground"
aria-hidden="true"
>
<pre className="w-full truncate whitespace-pre">
{lines.map((line, i) => (
<div key={i} data-testid="minimap-line">
{line || ' '}
</div>
))}
</pre>
<div
data-testid="minimap-viewport"
className="pointer-events-none absolute left-0 right-0 bg-brand/15"
style={{ top: `${viewportTop}%`, height: `${Math.max(viewportHeight, 5)}%` }}
/>
</div>
);
}
@@ -11,13 +11,43 @@ import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/componen
import { useFileShortcuts } from '@/hooks/use-file-shortcuts'; import { useFileShortcuts } from '@/hooks/use-file-shortcuts';
import { useRestoreLastFolder } from '@/hooks/use-restore-last-folder'; import { useRestoreLastFolder } from '@/hooks/use-restore-last-folder';
import { useRegisterMenuCommands, useBridgeNativeMenu } from '@/lib/commands/register-menu-commands'; import { useRegisterMenuCommands, useBridgeNativeMenu } from '@/lib/commands/register-menu-commands';
import { useZenMode } from '@/hooks/use-zen-mode';
export function AppShell() { export function AppShell() {
useFileShortcuts(); useFileShortcuts();
useRestoreLastFolder(); useRestoreLastFolder();
useRegisterMenuCommands(); useRegisterMenuCommands();
useBridgeNativeMenu(); useBridgeNativeMenu();
useZenMode();
const { sidebarVisible, previewVisible, paneSizes, setPaneSizes } = useAppStore(); const { sidebarVisible, previewVisible, paneSizes, setPaneSizes } = useAppStore();
const zenMode = useAppStore((s) => s.zenMode);
if (zenMode) {
return (
<main className="h-screen w-screen overflow-hidden bg-background">
<ResizablePanelGroup
direction="horizontal"
onLayout={(sizes) => setPaneSizes({ sidebar: 0, editor: sizes[0], preview: sizes[1] })}
>
<ResizablePanel defaultSize={previewVisible ? 50 : 100} minSize={20}>
<section className="h-full bg-background">
<EditorPane />
</section>
</ResizablePanel>
{previewVisible && (
<>
<ResizableHandle />
<ResizablePanel defaultSize={50} minSize={20}>
<section className="h-full border-l border-border bg-card/10">
<PreviewPane />
</section>
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</main>
);
}
return ( return (
<div className="flex h-screen flex-col bg-background text-foreground"> <div className="flex h-screen flex-col bg-background text-foreground">
+33 -2
View File
@@ -1,7 +1,38 @@
import { useFileStore } from '@/stores/file-store';
import { useEditorStore } from '@/stores/editor-store';
import { useSettingsStore } from '@/stores/settings-store';
function extractHeadings(content: string): { level: number; text: string }[] {
const lines = content.split('\n');
const headings: { level: number; text: string }[] = [];
for (const line of lines) {
const m = line.match(/^(#{1,6})\s+(.+)$/);
if (m) headings.push({ level: m[1].length, text: m[2].trim() });
}
return headings;
}
export function Breadcrumb() { export function Breadcrumb() {
const activeTabId = useFileStore((s) => s.activeTabId);
const openTabs = useFileStore((s) => s.openTabs);
const buffer = useEditorStore((s) => (activeTabId ? s.buffers.get(activeTabId) : undefined));
const showSymbols = useSettingsStore((s) => s.breadcrumbSymbols);
const tab = activeTabId ? openTabs.find((t) => t.id === activeTabId) : null;
const headings = showSymbols && buffer ? extractHeadings(buffer.content).slice(0, 3) : [];
return ( return (
<nav aria-label="File path" className="flex h-7 items-center border-b border-border bg-card/10 px-3 text-xs text-muted-foreground"> <nav
<span>No file selected</span> aria-label="File path"
className="flex h-7 items-center gap-1 border-b border-border bg-card/10 px-3 text-xs text-muted-foreground"
>
<span>{tab ? tab.title : 'No file selected'}</span>
{headings.map((h, i) => (
<span key={i} className="flex items-center gap-1">
<span aria-hidden="true"></span>
<span className="truncate">{'#'.repeat(h.level)} {h.text}</span>
</span>
))}
</nav> </nav>
); );
} }
@@ -0,0 +1,77 @@
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useAppStore } from '@/stores/app-store';
import { toast } from '@/lib/toast';
import { figletText, FIGLET_FONTS, type FigletFont } from '@/lib/figlet';
export function AsciiGeneratorDialog() {
const closeModal = useAppStore((s) => s.closeModal);
const [text, setText] = useState('Hello');
const [font, setFont] = useState<FigletFont>('Standard');
const [output, setOutput] = useState('');
useEffect(() => {
let cancelled = false;
figletText(text || ' ', font)
.then((result) => { if (!cancelled) setOutput(result); })
.catch(() => { if (!cancelled) setOutput('(render error)'); });
return () => { cancelled = true; };
}, [text, font]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(output);
toast.success('Copied to clipboard');
} catch {
toast.error('Failed to copy');
}
};
return (
<Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent aria-describedby="ascii-desc" className="max-w-2xl">
<DialogHeader>
<DialogTitle>ASCII generator</DialogTitle>
<DialogDescription id="ascii-desc">Type text, pick a font, see ASCII art</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label htmlFor="ascii-text">Text</Label>
<Textarea
id="ascii-text"
aria-label="Text"
value={text}
onChange={(e) => setText(e.target.value)}
rows={2}
/>
</div>
<div>
<Label htmlFor="ascii-font">Font</Label>
<Select value={font} onValueChange={(v) => setFont(v as FigletFont)}>
<SelectTrigger id="ascii-font" aria-label="Font"><SelectValue /></SelectTrigger>
<SelectContent>
{FIGLET_FONTS.map((f) => (
<SelectItem key={f} value={f}>{f}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Output</Label>
<pre className="overflow-auto rounded border border-border bg-card/30 p-3 text-xs" data-testid="ascii-output">
{output}
</pre>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={closeModal}>Close</Button>
<Button onClick={handleCopy}>Copy</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,111 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAppStore } from '@/stores/app-store';
import { useFileStore } from '@/stores/file-store';
import { ipc } from '@/lib/ipc';
interface SearchResult {
filePath: string;
line: number;
content: string;
}
export function FindInFilesDialog() {
const closeModal = useAppStore((s) => s.closeModal);
const rootPath = useFileStore((s) => s.rootPath);
const openFile = useFileStore((s) => s.openFile);
const [query, setQuery] = useState('');
const [isRegex, setIsRegex] = useState(false);
const [caseSensitive, setCaseSensitive] = useState(false);
const [results, setResults] = useState<SearchResult[]>([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSearch = async () => {
if (!query || !rootPath) return;
setSubmitting(true);
setError(null);
const result = await ipc.file.search({ rootPath, query, isRegex, caseSensitive });
if (!result.ok) {
setError(result.error.message);
setSubmitting(false);
return;
}
setResults(result.data ?? []);
setSubmitting(false);
};
const handleResultClick = (filePath: string) => {
openFile(filePath);
closeModal();
};
return (
<Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent aria-describedby="find-desc" className="max-w-2xl">
<DialogHeader>
<DialogTitle>Find in files</DialogTitle>
<DialogDescription id="find-desc">
{rootPath ? `Search in ${rootPath}` : 'No folder open'}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label htmlFor="find-query">Query</Label>
<Input
id="find-query"
aria-label="Query"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<div className="flex gap-4">
<label className="flex items-center gap-2">
<Checkbox checked={isRegex} onCheckedChange={(c) => setIsRegex(!!c)} aria-label="Regex" />
Regex
</label>
<label className="flex items-center gap-2">
<Checkbox checked={caseSensitive} onCheckedChange={(c) => setCaseSensitive(!!c)} aria-label="Case sensitive" />
Case sensitive
</label>
</div>
{error && (
<div role="alert" className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">
{error}
</div>
)}
{results.length > 0 && (
<div>
<Label>{results.length} result{results.length === 1 ? '' : 's'}</Label>
<div className="max-h-64 overflow-auto rounded border border-border bg-card/20 text-xs">
{results.map((r, i) => (
<button
key={`${r.filePath}:${r.line}:${i}`}
onClick={() => handleResultClick(r.filePath)}
className="block w-full truncate border-b border-border/30 px-2 py-1 text-left hover:bg-card/50"
data-testid="find-result"
>
<span className="font-mono text-muted-foreground">{r.filePath}:{r.line}</span>
<span className="ml-2">{r.content}</span>
</button>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={closeModal} disabled={submitting}>Close</Button>
<Button onClick={handleSearch} disabled={submitting || !query}>
{submitting ? 'Searching…' : 'Search'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,12 +1,16 @@
import { useAppStore } from '@/stores/app-store'; import { useAppStore } from '@/stores/app-store';
import { AboutDialog } from './AboutDialog'; import { AboutDialog } from './AboutDialog';
import { AsciiGeneratorDialog } from './AsciiGeneratorDialog';
import { ConfirmDialog } from './ConfirmDialog'; import { ConfirmDialog } from './ConfirmDialog';
import { ExportBatchDialog } from './ExportBatchDialog'; import { ExportBatchDialog } from './ExportBatchDialog';
import { ExportDocxDialog } from './ExportDocxDialog'; import { ExportDocxDialog } from './ExportDocxDialog';
import { ExportHtmlDialog } from './ExportHtmlDialog'; import { ExportHtmlDialog } from './ExportHtmlDialog';
import { ExportPdfDialog } from './ExportPdfDialog'; import { ExportPdfDialog } from './ExportPdfDialog';
import { FindInFilesDialog } from './FindInFilesDialog';
import { SettingsSheet } from './SettingsSheet'; import { SettingsSheet } from './SettingsSheet';
import { TableGeneratorDialog } from './TableGeneratorDialog';
import { WelcomeDialog } from './WelcomeDialog'; import { WelcomeDialog } from './WelcomeDialog';
import { WordExportDialog } from './WordExportDialog';
export function ModalLayer() { export function ModalLayer() {
const modal = useAppStore((s) => s.modal); const modal = useAppStore((s) => s.modal);
@@ -29,5 +33,13 @@ export function ModalLayer() {
return <WelcomeDialog />; return <WelcomeDialog />;
case 'confirm': case 'confirm':
return <ConfirmDialog {...modal.props} />; return <ConfirmDialog {...modal.props} />;
case 'export-word':
return <WordExportDialog sourcePath={modal.props.sourcePath} />;
case 'ascii-generator':
return <AsciiGeneratorDialog />;
case 'table-generator':
return <TableGeneratorDialog />;
case 'find-in-files':
return <FindInFilesDialog />;
} }
} }
@@ -0,0 +1,132 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAppStore } from '@/stores/app-store';
import { toast } from '@/lib/toast';
function pad(s: string, w: number) {
return s.padEnd(w);
}
function generateTable(rows: number, cols: number, hasHeader: boolean, headers: string[], data: string[][]): string {
const width = cols;
const colWidths: number[] = Array.from({ length: width }, (_, c) => {
const all = [headers[c] ?? '', ...data.map((r) => r[c] ?? '')];
return Math.max(...all.map((s) => s.length), 3);
});
const lines: string[] = [];
if (hasHeader) {
lines.push('| ' + Array.from({ length: width }, (_, c) => pad(headers[c] ?? `Col ${c + 1}`, colWidths[c])).join(' | ') + ' |');
lines.push('| ' + colWidths.map((w) => '-'.repeat(w)).join(' | ') + ' |');
}
for (const row of data) {
lines.push('| ' + Array.from({ length: width }, (_, c) => pad(row[c] ?? '', colWidths[c])).join(' | ') + ' |');
}
// Add empty rows if data has fewer
for (let i = data.length; i < rows; i++) {
lines.push('| ' + colWidths.map((w) => pad('', w)).join(' | ') + ' |');
}
return lines.join('\n');
}
export function TableGeneratorDialog() {
const closeModal = useAppStore((s) => s.closeModal);
const [rows, setRows] = useState(3);
const [cols, setCols] = useState(3);
const [hasHeader, setHasHeader] = useState(true);
const [headers, setHeaders] = useState<string[]>(['Col 1', 'Col 2', 'Col 3']);
const output = generateTable(rows, cols, hasHeader, headers, []);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(output);
toast.success('Copied to clipboard');
} catch {
toast.error('Failed to copy');
}
};
const updateHeadersCount = (newCols: number) => {
setCols(newCols);
setHeaders((prev) => {
if (prev.length < newCols) return [...prev, ...Array(newCols - prev.length).fill('').map((_, i) => `Col ${prev.length + i + 1}`)];
return prev.slice(0, newCols);
});
};
return (
<Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent aria-describedby="table-desc" className="max-w-2xl">
<DialogHeader>
<DialogTitle>Table generator</DialogTitle>
<DialogDescription id="table-desc">Specify rows × columns to generate a markdown table</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="flex gap-3">
<div className="flex-1">
<Label htmlFor="table-rows">Rows</Label>
<Input
id="table-rows"
aria-label="Rows"
type="number"
min={1}
max={50}
value={rows}
onChange={(e) => setRows(Math.max(1, Math.min(50, Number(e.target.value) || 1)))}
/>
</div>
<div className="flex-1">
<Label htmlFor="table-cols">Columns</Label>
<Input
id="table-cols"
aria-label="Cols"
type="number"
min={1}
max={20}
value={cols}
onChange={(e) => updateHeadersCount(Math.max(1, Math.min(20, Number(e.target.value) || 1)))}
/>
</div>
</div>
<label className="flex items-center gap-2">
<Checkbox checked={hasHeader} onCheckedChange={(c) => setHasHeader(!!c)} aria-label="Header" />
Include header row
</label>
{hasHeader && (
<div>
<Label>Header names</Label>
<div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>
{Array.from({ length: cols }, (_, i) => (
<Input
key={i}
aria-label={`Header ${i + 1}`}
value={headers[i] ?? ''}
onChange={(e) => {
const next = [...headers];
next[i] = e.target.value;
setHeaders(next);
}}
/>
))}
</div>
</div>
)}
<div>
<Label>Output</Label>
<pre className="overflow-auto rounded border border-border bg-card/30 p-3 text-xs" data-testid="table-output">
{output}
</pre>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={closeModal}>Close</Button>
<Button onClick={handleCopy}>Copy</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,114 @@
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { useAppStore } from '@/stores/app-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useExportSource } from '@/hooks/use-export-source';
import { generateDocx } from '@/lib/docx-export';
import { ipc } from '@/lib/ipc';
import { toast } from '@/lib/toast';
export function WordExportDialog({ sourcePath }: { sourcePath: string }) {
const closeModal = useAppStore((s) => s.closeModal);
const setSetting = useSettingsStore((s) => s.setSetting);
const docxCustomTemplatePath = useSettingsStore((s) => s.docxCustomTemplatePath);
const source = useExportSource();
const [templateMode, setTemplateMode] = useState<'standard' | 'custom'>(docxCustomTemplatePath ? 'custom' : 'standard');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setTemplateMode(docxCustomTemplatePath ? 'custom' : 'standard');
}, [docxCustomTemplatePath]);
const handleChooseTemplate = async () => {
const result = await ipc.app.showSaveDialog?.({ title: 'Choose template path' });
if (result?.ok && result.data) {
setSetting('docxCustomTemplatePath', result.data);
}
};
const handleSubmit = async () => {
if (!source) { setError('No file open.'); return; }
setSubmitting(true);
setError(null);
try {
const blob = await generateDocx({
source: source.source,
title: source.title,
customTemplatePath: templateMode === 'custom' ? docxCustomTemplatePath : null,
});
const saveResult = await ipc.app.showSaveDialog?.({ title: 'Save as Word document', defaultPath: source.path.replace(/\.md$/, '.docx') });
if (!saveResult?.ok || !saveResult.data) {
setSubmitting(false);
return;
}
const buffer = new Uint8Array(await blob.arrayBuffer());
const writeResult = await ipc.file.writeBuffer({ path: saveResult.data, buffer });
if (!writeResult.ok) {
setError(writeResult.error.message);
setSubmitting(false);
return;
}
toast.success(`Exported ${source.title} to ${saveResult.data}`);
closeModal();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(`Export failed: ${msg}`);
setError(msg);
setSubmitting(false);
}
};
return (
<Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent aria-describedby="word-desc">
<DialogHeader>
<DialogTitle>Export to Word (.docx)</DialogTitle>
<DialogDescription id="word-desc">{sourcePath}</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm">
<div>
<Label>Template</Label>
<RadioGroup value={templateMode} onValueChange={(v) => setTemplateMode(v as 'standard' | 'custom')}>
<div className="flex items-center gap-2">
<RadioGroupItem value="standard" id="template-standard" />
<Label htmlFor="template-standard">Standard (bundled)</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="custom" id="template-custom" />
<Label htmlFor="template-custom">Custom .dotx</Label>
</div>
</RadioGroup>
</div>
{templateMode === 'custom' && (
<div className="rounded border border-border bg-card/20 p-2 text-xs">
{docxCustomTemplatePath ? (
<span>Template path: <code>{docxCustomTemplatePath}</code></span>
) : (
<span className="text-muted-foreground">No template selected. Click "Choose template..." to pick a .dotx file.</span>
)}
<Button variant="ghost" size="sm" onClick={handleChooseTemplate} className="ml-2">
Choose template...
</Button>
</div>
)}
{error && (
<div role="alert" className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">
{error}
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={closeModal} disabled={submitting}>Cancel</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? 'Exporting…' : 'Export'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,101 @@
import { useEffect, useState } from 'react';
import { RefreshCw, FileX, FilePlus, FileEdit, FileQuestion } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useFileStore } from '@/stores/file-store';
import { ipc } from '@/lib/ipc';
interface GitStatus {
filePath: string;
status: 'modified' | 'added' | 'deleted' | 'untracked';
}
const STATUS_ICON: Record<GitStatus['status'], JSX.Element> = {
modified: <FileEdit className="h-3 w-3 text-warning" />,
added: <FilePlus className="h-3 w-3 text-success" />,
deleted: <FileX className="h-3 w-3 text-destructive" />,
untracked: <FileQuestion className="h-3 w-3 text-muted-foreground" />,
};
const STATUS_LABEL: Record<GitStatus['status'], string> = {
modified: 'M',
added: 'A',
deleted: 'D',
untracked: '?',
};
export function GitStatusPanel() {
const rootPath = useFileStore((s) => s.rootPath);
const openFile = useFileStore((s) => s.openFile);
const [status, setStatus] = useState<GitStatus[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const load = async () => {
if (!rootPath) {
setStatus([]);
return;
}
setLoading(true);
setError(null);
const result = await ipc.file.gitStatus({ rootPath });
if (!result.ok) {
setError(result.error.message);
setStatus([]);
setLoading(false);
return;
}
setStatus(result.data ?? []);
setLoading(false);
};
useEffect(() => {
load();
// Listen for git.refresh command via custom event
const handler = () => load();
window.addEventListener('mc:git-refresh', handler);
return () => window.removeEventListener('mc:git-refresh', handler);
}, [rootPath]);
if (!rootPath) {
return <div className="p-3 text-xs text-muted-foreground">No folder open</div>;
}
if (error) {
return (
<div className="p-3 text-xs">
<div className="text-destructive">Error: {error}</div>
<p className="mt-1 text-muted-foreground">Not a git repository, or git not installed.</p>
<Button size="sm" variant="ghost" onClick={load} className="mt-2">Retry</Button>
</div>
);
}
if (status.length === 0 && !loading) {
return <div className="p-3 text-xs text-muted-foreground">Working tree clean</div>;
}
return (
<div className="p-2 text-xs">
<div className="flex items-center justify-between px-1 py-1">
<span className="font-semibold">{status.length} changed file{status.length === 1 ? '' : 's'}</span>
<Button size="sm" variant="ghost" onClick={load} aria-label="Refresh">
<RefreshCw className="h-3 w-3" />
</Button>
</div>
<div className="space-y-0.5">
{status.map((s) => (
<button
key={s.filePath}
onClick={() => openFile(s.filePath)}
className="flex w-full items-center gap-2 rounded px-2 py-1 text-left hover:bg-card/50"
data-testid="git-status-row"
>
{STATUS_ICON[s.status]}
<span className="w-3 font-mono text-xs">{STATUS_LABEL[s.status]}</span>
<span className="truncate font-mono">{s.filePath.replace(rootPath + '/', '')}</span>
</button>
))}
</div>
</div>
);
}
@@ -5,6 +5,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { useFileStore } from '@/stores/file-store'; import { useFileStore } from '@/stores/file-store';
import { FileTree } from './FileTree'; import { FileTree } from './FileTree';
import { Outline } from './Outline'; import { Outline } from './Outline';
import { GitStatusPanel } from './GitStatusPanel';
export function Sidebar() { export function Sidebar() {
const tree = useFileStore((s) => s.tree); const tree = useFileStore((s) => s.tree);
@@ -49,6 +50,21 @@ export function Sidebar() {
</ScrollArea> </ScrollArea>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
{/* Git section */}
<Collapsible defaultOpen>
<CollapsibleTrigger asChild>
<button className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-medium hover:bg-accent rounded">
<ChevronRight size={12} className="rotate-90" />
Git
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<ScrollArea className="h-[calc(100vh-240px)]">
<GitStatusPanel />
</ScrollArea>
</CollapsibleContent>
</Collapsible>
</div> </div>
); );
} }
@@ -0,0 +1,57 @@
import { X, Printer } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { MarkdownRenderer } from '@/components/preview/MarkdownRenderer';
import { useExportSource } from '@/hooks/use-export-source';
import { ipc } from '@/lib/ipc';
interface Props {
onClose: () => void;
}
export function PrintPreview({ onClose }: Props) {
const source = useExportSource();
const handlePrint = async () => {
if (!source) return;
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${source.title}</title></head><body><pre>${source.source.replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c] ?? c))}</pre></body></html>`;
await ipc.print({ html });
};
if (!source) {
return (
<div className="fixed inset-0 z-50 flex flex-col bg-background">
<div className="flex h-12 items-center justify-between border-b border-border bg-card/30 px-4">
<h2 className="font-semibold">Print preview</h2>
<Button variant="ghost" size="icon" onClick={onClose} aria-label="Close">
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-1 items-center justify-center text-muted-foreground">
No file open
</div>
</div>
);
}
return (
<div className="fixed inset-0 z-50 flex flex-col bg-background">
<div className="flex h-12 items-center justify-between border-b border-border bg-card/30 px-4">
<h2 className="font-semibold">Print preview {source.title}</h2>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="mr-2 h-4 w-4" />
Print
</Button>
<Button variant="ghost" size="icon" onClick={onClose} aria-label="Close">
<X className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto bg-card/20 p-8">
<div className="mx-auto max-w-3xl rounded border border-border bg-background p-8 shadow-lg">
<MarkdownRenderer source={source.source} />
</div>
</div>
</div>
);
}
@@ -0,0 +1,57 @@
import { useState, useEffect } from 'react';
import { X } from 'lucide-react';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { useSettingsStore } from '@/stores/settings-store';
import { renderMarkdown } from '@/lib/markdown';
export function ReplPanel() {
const replOpen = useSettingsStore((s) => s.replOpen);
const setSetting = useSettingsStore((s) => s.setSetting);
const [source, setSource] = useState('# Markdown preview\n\nType here…');
const [debouncedSource, setDebouncedSource] = useState(source);
useEffect(() => {
const t = setTimeout(() => setDebouncedSource(source), 300);
return () => clearTimeout(t);
}, [source]);
if (!replOpen) return null;
const html = renderMarkdown(debouncedSource);
return (
<div
role="region"
aria-label="REPL"
className="fixed bottom-0 left-0 right-0 z-40 flex h-[30vh] flex-col border-t border-border bg-card/95 shadow-2xl backdrop-blur"
data-testid="repl-panel"
>
<div className="flex h-8 items-center justify-between border-b border-border bg-card/50 px-3 text-xs">
<span className="font-semibold">REPL Markdown snippet preview</span>
<Button
variant="ghost"
size="icon"
aria-label="Close REPL"
onClick={() => setSetting('replOpen', false)}
className="h-6 w-6"
>
<X className="h-3 w-3" />
</Button>
</div>
<div className="flex flex-1 overflow-hidden">
<Textarea
value={source}
onChange={(e) => setSource(e.target.value)}
className="h-full flex-1 resize-none rounded-none border-r border-border font-mono text-xs"
aria-label="Markdown source"
/>
<div
className="h-full flex-1 overflow-auto p-3 text-sm"
dangerouslySetInnerHTML={{ __html: html }}
data-testid="repl-preview"
/>
</div>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { useEffect } from 'react';
import { useAppStore } from '@/stores/app-store';
/**
* Mounts a global keydown listener that exits zen mode when the user
* presses Escape. Should be called once at the App root.
*/
export function useZenMode() {
const zenMode = useAppStore((s) => s.zenMode);
const setZenMode = useAppStore((s) => s.setZenMode);
useEffect(() => {
if (!zenMode) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setZenMode(false);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [zenMode, setZenMode]);
}
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { useCommandStore } from '@/stores/command-store'; import { useCommandStore } from '@/stores/command-store';
import { useFileStore } from '@/stores/file-store'; import { useFileStore } from '@/stores/file-store';
import { useAppStore } from '@/stores/app-store'; import { useAppStore } from '@/stores/app-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useMenuAction } from '@/hooks/use-menu-action'; import { useMenuAction } from '@/hooks/use-menu-action';
/** /**
@@ -45,6 +46,28 @@ export function registerMenuCommands(): void {
'app.quit': () => { 'app.quit': () => {
/* stub — wired in later phase */ /* 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': () => {
if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('mc:print'));
},
'git.refresh': () => {
if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('mc:git-refresh'));
},
}); });
const { register } = useCommandStore.getState(); const { register } = useCommandStore.getState();
+31
View File
@@ -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<Blob> {
// 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);
}
+14
View File
@@ -0,0 +1,14 @@
import figlet from 'figlet';
import type { Fonts } from 'figlet';
export const FIGLET_FONTS = ['Standard', 'Big', 'Small', 'Banner', 'Doom', 'Slant', 'Block'] as const;
export type FigletFont = typeof FIGLET_FONTS[number];
export function figletText(text: string, font: FigletFont): Promise<string> {
return new Promise((resolve, reject) => {
figlet.text(text, { font }, (err, result) => {
if (err) reject(err);
else resolve(result ?? '');
});
});
}
+10
View File
@@ -76,7 +76,15 @@ export const ipc = {
} }
return window.electronAPI.file.onChange(cb); return window.electronAPI.file.onChange(cb);
}, },
search: (args: { rootPath: string; query: string; isRegex: boolean; caseSensitive: boolean }): Promise<IpcResult<Array<{ filePath: string; line: number; content: string }> | ChannelMissing>> =>
safeCall('file', 'search', args),
gitStatus: (args: { rootPath: string }): Promise<IpcResult<Array<{ filePath: string; status: 'modified' | 'added' | 'deleted' | 'untracked' }> | ChannelMissing>> =>
safeCall('file', 'gitStatus', args),
writeBuffer: (args: { path: string; buffer: Uint8Array }): Promise<IpcResult<void | ChannelMissing>> =>
safeCall('file', 'writeBuffer', args),
}, },
print: (args: { html: string }): Promise<IpcResult<void | ChannelMissing>> =>
safeCall('print', 'show', 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),
@@ -94,6 +102,8 @@ export const ipc = {
safeCall('app', 'openExternal', url), safeCall('app', 'openExternal', url),
showItemInFolder: (path: string): Promise<IpcResult<void | ChannelMissing>> => showItemInFolder: (path: string): Promise<IpcResult<void | ChannelMissing>> =>
safeCall('app', 'showItemInFolder', path), safeCall('app', 'showItemInFolder', path),
showSaveDialog: (args?: { title?: string; defaultPath?: string }): Promise<IpcResult<string | null | ChannelMissing>> =>
safeCall('app', 'showSaveDialog', args),
}, },
menu: { menu: {
/** /**
+3
View File
@@ -13,6 +13,9 @@ export const settingsSchema = z.object({
pdfMargins: z.enum(['normal', 'narrow', 'wide']).default('normal'), pdfMargins: z.enum(['normal', 'narrow', 'wide']).default('normal'),
pdfEmbedFonts: z.boolean().default(true), pdfEmbedFonts: z.boolean().default(true),
docxTemplate: z.enum(['standard', 'minimal', 'modern']).default('standard'), 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'), htmlHighlightStyle: z.enum(['github', 'monokai', 'nord', 'none']).default('github'),
renderTablesAsAscii: z.boolean().default(false), renderTablesAsAscii: z.boolean().default(false),
welcomeDismissed: z.boolean().default(false), welcomeDismissed: z.boolean().default(false),
+5 -1
View File
@@ -26,7 +26,11 @@ export type ModalState =
| { kind: 'settings' } | { kind: 'settings' }
| { kind: 'about' } | { kind: 'about' }
| { kind: 'welcome' } | { 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']; export type ModalKind = ModalState['kind'];
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Minimap } from '@/components/editor/Minimap';
describe('Minimap', () => {
it('renders lines of content as shrunk text', () => {
const content = 'line 1\nline 2\nline 3';
render(<Minimap content={content} />);
const lines = screen.getAllByTestId('minimap-line');
expect(lines).toHaveLength(3);
});
it('renders a viewport indicator', () => {
render(
<Minimap content={'line 1\nline 2\nline 3'} scrollRatio={0.5} visibleRatio={0.5} />
);
const indicator = screen.getByTestId('minimap-viewport');
expect(indicator).toBeInTheDocument();
});
});
@@ -0,0 +1,50 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Breadcrumb } from '@/components/layout/Breadcrumb';
import { useFileStore } from '@/stores/file-store';
import { useEditorStore } from '@/stores/editor-store';
import { useSettingsStore } from '@/stores/settings-store';
describe('Breadcrumb', () => {
beforeEach(() => {
localStorage.clear();
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), breadcrumbSymbols: true });
useFileStore.setState({ activeTabId: '/test.md', openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }] } as any);
useEditorStore.setState({ buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# H1\n## H2', dirty: false }]]) } as any);
});
it('shows "No file selected" when no tab is active', () => {
useFileStore.setState({ activeTabId: null, openTabs: [] } as any);
render(<Breadcrumb />);
expect(screen.getByText('No file selected')).toBeInTheDocument();
});
it('shows tab title when a tab is active', () => {
render(<Breadcrumb />);
expect(screen.getByText('test.md')).toBeInTheDocument();
});
it('shows heading symbols when breadcrumbSymbols is true', () => {
render(<Breadcrumb />);
expect(screen.getByText('test.md')).toBeInTheDocument();
expect(screen.getByText(/# H1/)).toBeInTheDocument();
});
it('does not show heading symbols when breadcrumbSymbols is false', () => {
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), breadcrumbSymbols: false });
render(<Breadcrumb />);
expect(screen.getByText('test.md')).toBeInTheDocument();
expect(screen.queryByText(/# H1/)).not.toBeInTheDocument();
});
it('limits heading symbols to 3', () => {
useFileStore.setState({ activeTabId: '/test.md', openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }] } as any);
useEditorStore.setState({ buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# H1\n## H2\n### H3\n#### H4', dirty: false }]]) } as any);
render(<Breadcrumb />);
// Should show H1, H2, H3 but not H4
expect(screen.getByText(/# H1/)).toBeInTheDocument();
expect(screen.getByText(/# H2/)).toBeInTheDocument();
expect(screen.getByText(/# H3/)).toBeInTheDocument();
expect(screen.queryByText(/# H4/)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,35 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AsciiGeneratorDialog } from '@/components/modals/AsciiGeneratorDialog';
vi.mock('@/lib/figlet', () => ({
figletText: vi.fn().mockResolvedValue(' MOCKED ASCII '),
FIGLET_FONTS: ['Standard', 'Big', 'Small', 'Banner', 'Doom', 'Slant', 'Block'],
}));
describe('AsciiGeneratorDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders with an input textarea and font selector', () => {
render(<AsciiGeneratorDialog />);
expect(screen.getByText(/ascii generator/i)).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /font/i })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /text/i })).toBeInTheDocument();
});
it('renders the ASCII output as the user types', async () => {
render(<AsciiGeneratorDialog />);
const input = screen.getByRole('textbox', { name: /text/i });
await userEvent.type(input, 'hi');
const pre = await screen.findByTestId('ascii-output');
expect(pre).toHaveTextContent('MOCKED ASCII');
});
it('shows a copy button next to the output', () => {
render(<AsciiGeneratorDialog />);
expect(screen.getByRole('button', { name: /copy/i })).toBeInTheDocument();
});
});
@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FindInFilesDialog } from '@/components/modals/FindInFilesDialog';
import { useFileStore } from '@/stores/file-store';
vi.mock('@/lib/ipc', () => ({
ipc: {
file: {
search: vi.fn(),
},
},
}));
import { ipc } from '@/lib/ipc';
import { useAppStore } from '@/stores/app-store';
describe('FindInFilesDialog', () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
useFileStore.setState({ rootPath: '/project' } as any);
useAppStore.setState({ modal: { kind: null } } as any);
});
it('renders with a query input and toggles', () => {
render(<FindInFilesDialog />);
expect(screen.getByText(/find in files/i)).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /query/i })).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: /regex/i })).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: /case/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /search/i })).toBeInTheDocument();
});
it('submits a search and shows results', async () => {
(ipc.file.search as any).mockResolvedValueOnce({
ok: true,
data: [
{ filePath: '/project/a.md', line: 5, content: 'matching line' },
{ filePath: '/project/b.md', line: 12, content: 'another match' },
],
});
render(<FindInFilesDialog />);
await userEvent.type(screen.getByRole('textbox', { name: /query/i }), 'match');
await userEvent.click(screen.getByRole('button', { name: /search/i }));
expect(await screen.findByText('/project/a.md:5')).toBeInTheDocument();
expect(await screen.findByText(/another match/)).toBeInTheDocument();
});
it('clicking a result calls useFileStore.openFile', async () => {
(ipc.file.search as any).mockResolvedValueOnce({
ok: true,
data: [{ filePath: '/project/a.md', line: 5, content: 'matching line' }],
});
const openFileSpy = vi.fn();
useFileStore.setState({ openFile: openFileSpy, rootPath: '/project' } as any);
render(<FindInFilesDialog />);
await userEvent.type(screen.getByRole('textbox', { name: /query/i }), 'match');
await userEvent.click(screen.getByRole('button', { name: /search/i }));
const result = await screen.findByText('/project/a.md:5');
await userEvent.click(result);
// openFile is called with the filePath; the test verifies it was called
expect(openFileSpy).toHaveBeenCalled();
});
it('shows an error banner when search fails', async () => {
(ipc.file.search as any).mockResolvedValueOnce({ ok: false, error: { code: 'E', message: 'regex invalid' } });
render(<FindInFilesDialog />);
await userEvent.paste('[invalid', { initialSelectionStart: 0, initialSelectionEnd: 0 });
await userEvent.click(screen.getByRole('button', { name: /search/i }));
expect(await screen.findByText(/regex invalid/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,36 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TableGeneratorDialog } from '@/components/modals/TableGeneratorDialog';
describe('TableGeneratorDialog', () => {
beforeEach(() => {
localStorage.clear();
});
it('renders with rows, cols, and header inputs', () => {
render(<TableGeneratorDialog />);
expect(screen.getByText(/table generator/i)).toBeInTheDocument();
expect(screen.getByRole('spinbutton', { name: /rows/i })).toBeInTheDocument();
expect(screen.getByRole('spinbutton', { name: /cols/i })).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: /header/i })).toBeInTheDocument();
});
it('generates a markdown table on rows/cols change', async () => {
render(<TableGeneratorDialog />);
const rowsInput = screen.getByRole('spinbutton', { name: /rows/i });
const colsInput = screen.getByRole('spinbutton', { name: /cols/i });
await userEvent.clear(rowsInput);
await userEvent.type(rowsInput, '2');
await userEvent.clear(colsInput);
await userEvent.type(colsInput, '3');
// Output should have header + separator + 2 rows = 4 lines for a 3-col table with header
const output = screen.getByTestId('table-output');
expect(output.textContent).toContain('|');
});
it('shows a copy button next to the output', () => {
render(<TableGeneratorDialog />);
expect(screen.getByRole('button', { name: /copy/i })).toBeInTheDocument();
});
});
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { WordExportDialog } from '@/components/modals/WordExportDialog';
import { useFileStore } from '@/stores/file-store';
import { useEditorStore } from '@/stores/editor-store';
import { useSettingsStore } from '@/stores/settings-store';
vi.mock('@/lib/docx-export', () => ({
generateDocx: vi.fn().mockResolvedValue(new Blob([new Uint8Array([1, 2, 3])], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' })),
}));
vi.mock('@/lib/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('@/lib/ipc', () => ({
ipc: {
app: {
showSaveDialog: vi.fn(),
},
file: {
writeBuffer: vi.fn(),
},
},
}));
import { generateDocx } from '@/lib/docx-export';
import { toast } from '@/lib/toast';
import { ipc } from '@/lib/ipc';
describe('WordExportDialog', () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
useSettingsStore.setState(useSettingsStore.getInitialState());
useFileStore.setState({ activeTabId: '/test.md', openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }] } as any);
useEditorStore.setState({ buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }]]) } as any);
(ipc.app.showSaveDialog as any).mockResolvedValue({ ok: true, data: '/out.docx' });
(ipc.file.writeBuffer as any).mockResolvedValue({ ok: true });
});
it('renders with template selector (standard / custom)', () => {
render(<WordExportDialog sourcePath="/test.md" />);
expect(screen.getByText(/export to word/i)).toBeInTheDocument();
expect(screen.getByText(/test\.md/)).toBeInTheDocument();
});
it('submitting calls generateDocx and writes the file', async () => {
render(<WordExportDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
expect(generateDocx).toHaveBeenCalledTimes(1);
expect(ipc.file.writeBuffer).toHaveBeenCalledTimes(1);
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Exported test.md'));
});
it('shows error message on write failure', async () => {
(ipc.file.writeBuffer as any).mockResolvedValue({ ok: false, error: { code: 'E', message: 'write failed' } });
render(<WordExportDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
expect(await screen.findByText(/write failed/i)).toBeInTheDocument();
});
it('switches to custom template path when "Custom" is selected', async () => {
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), docxCustomTemplatePath: '/my.dotx' });
render(<WordExportDialog sourcePath="/test.md" />);
expect(screen.getByText(/\/my\.dotx/)).toBeInTheDocument();
});
});
@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { GitStatusPanel } from '@/components/sidebar/GitStatusPanel';
import { useFileStore } from '@/stores/file-store';
vi.mock('@/lib/ipc', () => ({
ipc: {
file: {
gitStatus: vi.fn(),
},
},
}));
import { ipc } from '@/lib/ipc';
describe('GitStatusPanel', () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
});
it('shows "No folder open" when rootPath is null', () => {
useFileStore.setState({ rootPath: null } as any);
render(<GitStatusPanel />);
expect(screen.getByText(/no folder open/i)).toBeInTheDocument();
});
it('fetches and shows git status', async () => {
(ipc.file.gitStatus as any).mockResolvedValueOnce({
ok: true,
data: [
{ filePath: '/project/a.md', status: 'modified' },
{ filePath: '/project/b.md', status: 'added' },
],
});
useFileStore.setState({ rootPath: '/project' } as any);
render(<GitStatusPanel />);
expect(await screen.findByText('a.md')).toBeInTheDocument();
expect(await screen.findByText('b.md')).toBeInTheDocument();
});
it('shows "Working tree clean" when no changes', async () => {
(ipc.file.gitStatus as any).mockResolvedValueOnce({ ok: true, data: [] });
useFileStore.setState({ rootPath: '/project' } as any);
render(<GitStatusPanel />);
expect(await screen.findByText(/working tree clean/i)).toBeInTheDocument();
});
it('shows error state when gitStatus fails', async () => {
(ipc.file.gitStatus as any).mockResolvedValueOnce({
ok: false,
error: { code: 'IPC_ERROR', message: 'Not a git repository' },
});
useFileStore.setState({ rootPath: '/project' } as any);
render(<GitStatusPanel />);
// The helper text appears in a <p> element distinct from the error heading
expect(await screen.findByText('Not a git repository, or git not installed.')).toBeInTheDocument();
});
it('opens file on click', async () => {
const openFile = vi.fn();
(ipc.file.gitStatus as any).mockResolvedValueOnce({
ok: true,
data: [{ filePath: '/project/a.md', status: 'modified' }],
});
useFileStore.setState({ rootPath: '/project', openFile } as any);
render(<GitStatusPanel />);
const row = await screen.findByTestId('git-status-row');
await userEvent.click(row);
expect(openFile).toHaveBeenCalledWith('/project/a.md');
});
});
+1
View File
@@ -13,6 +13,7 @@ vi.mock('@/lib/ipc', () => ({
pickFile: vi.fn(), pickFile: vi.fn(),
read: vi.fn(), read: vi.fn(),
write: vi.fn(), write: vi.fn(),
gitStatus: vi.fn().mockResolvedValue({ ok: true, data: [] }),
}, },
}, },
})); }));
@@ -0,0 +1,49 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PrintPreview } from '@/components/tools/PrintPreview';
import { useFileStore } from '@/stores/file-store';
import { useEditorStore } from '@/stores/editor-store';
vi.mock('@/lib/ipc', () => ({
ipc: {
print: vi.fn().mockResolvedValue({ ok: true }),
},
}));
import { ipc } from '@/lib/ipc';
describe('PrintPreview', () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
useFileStore.setState({
activeTabId: '/test.md',
openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }],
} as any);
useEditorStore.setState({
buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }]]),
} as any);
});
it('renders the buffer content', () => {
render(<PrintPreview onClose={() => {}} />);
expect(screen.getByRole('heading', { name: /hi/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /print/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
});
it('Print button calls ipc.print', async () => {
const onClose = vi.fn();
render(<PrintPreview onClose={onClose} />);
await userEvent.click(screen.getByRole('button', { name: /print/i }));
expect(ipc.print).toHaveBeenCalledTimes(1);
});
it('Close button calls onClose', async () => {
const onClose = vi.fn();
render(<PrintPreview onClose={onClose} />);
await userEvent.click(screen.getByRole('button', { name: /close/i }));
expect(onClose).toHaveBeenCalledTimes(1);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ReplPanel } from '@/components/tools/ReplPanel';
import { useSettingsStore } from '@/stores/settings-store';
describe('ReplPanel', () => {
beforeEach(() => {
localStorage.clear();
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), replOpen: true });
});
it('renders the textarea and preview when replOpen is true', () => {
render(<ReplPanel />);
expect(screen.getByRole('textbox')).toBeInTheDocument();
expect(screen.getByText(/repl/i)).toBeInTheDocument();
});
it('does not render when replOpen is false', () => {
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), replOpen: false });
const { container } = render(<ReplPanel />);
expect(container.firstChild).toBeNull();
});
it('typing in the textarea updates the preview', async () => {
render(<ReplPanel />);
const ta = screen.getByRole('textbox');
await userEvent.clear(ta);
await userEvent.type(ta, '# Hello');
// Wait for 300ms debounce to fire
await new Promise((r) => setTimeout(r, 400));
expect(screen.getByRole('heading', { name: /hello/i })).toBeInTheDocument();
});
});
+9 -4
View File
@@ -36,6 +36,7 @@ vi.mock('@/lib/ipc', () => ({
pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }), pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }),
pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }), pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }),
onChange: vi.fn(() => () => {}), onChange: vi.fn(() => () => {}),
gitStatus: vi.fn().mockResolvedValue({ ok: true, data: [] }),
}, },
menu: { menu: {
on: vi.fn(() => () => {}), on: vi.fn(() => () => {}),
@@ -54,6 +55,8 @@ describe('Phase 5 integration', () => {
activeTabId: null, activeTabId: null,
}); });
useEditorStore.setState({ buffers: new Map(), activeId: null }); useEditorStore.setState({ buffers: new Map(), activeId: null });
// Also clear the active buffer so breadcrumb symbols won't show stale heading text
useEditorStore.getState().buffers.clear();
useAppStore.setState({ useAppStore.setState({
sidebarVisible: true, sidebarVisible: true,
previewVisible: true, previewVisible: true,
@@ -128,12 +131,14 @@ describe('Phase 5 integration', () => {
], ],
activeTabId: '/a.md', activeTabId: '/a.md',
}); });
// Use a buffer content that won't conflict with tab title in breadcrumb symbols
useEditorStore.setState({ buffers: new Map([['/a.md', { id: '/a.md', path: '/a.md', content: '# Hello', dirty: false }], ['/b.md', { id: '/b.md', path: '/b.md', content: '# World', dirty: false }]]), activeId: '/a.md' });
render(<AppShell />); render(<AppShell />);
const aTab = screen.getByText('a.md').closest('[role="tab"]')!; // Use role="tab" to find tabs specifically, avoiding breadcrumb "a.md" text
const bTab = screen.getByText('b.md').closest('[role="tab"]')!; const tabs = screen.getAllByRole('tab');
expect(aTab).toHaveAttribute('aria-current', 'page'); expect(tabs[0]).toHaveAttribute('aria-current', 'page');
await act(async () => { await act(async () => {
fireEvent.click(bTab); fireEvent.click(tabs[1]);
}); });
expect(useFileStore.getState().activeTabId).toBe('/b.md'); expect(useFileStore.getState().activeTabId).toBe('/b.md');
}); });
+1
View File
@@ -35,6 +35,7 @@ vi.mock('@/lib/ipc', () => ({
pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }), pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }),
pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }), pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }),
onChange: vi.fn(() => () => {}), onChange: vi.fn(() => () => {}),
gitStatus: vi.fn().mockResolvedValue({ ok: true, data: [] }),
}, },
menu: { menu: {
on: vi.fn((channel: string, cb: (...args: unknown[]) => void) => { on: vi.fn((channel: string, cb: (...args: unknown[]) => void) => {
@@ -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);
});
});
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useZenMode } from '@/hooks/use-zen-mode';
import { useAppStore } from '@/stores/app-store';
describe('useZenMode', () => {
beforeEach(() => {
localStorage.clear();
useAppStore.setState({ ...useAppStore.getInitialState(), zenMode: true });
});
it('attaches a keydown listener that exits zen mode on Escape', () => {
renderHook(() => useZenMode());
expect(useAppStore.getState().zenMode).toBe(true);
// Simulate Escape keydown
const event = new KeyboardEvent('keydown', { key: 'Escape' });
window.dispatchEvent(event);
expect(useAppStore.getState().zenMode).toBe(false);
});
it('does nothing if zen mode is already off', () => {
useAppStore.setState({ ...useAppStore.getInitialState(), zenMode: false });
const setZenMode = vi.fn();
useAppStore.setState({ setZenMode });
renderHook(() => useZenMode());
// No Escape dispatched; setZenMode should not be called
expect(setZenMode).not.toHaveBeenCalled();
});
});
+24
View File
@@ -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);
});
});