mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95bbe171b5 | ||
|
|
2d77fa5456 | ||
|
|
fbaff37505 | ||
|
|
1ccde4baa3 | ||
|
|
8822c9a2f9 | ||
|
|
324e5676b8 | ||
|
|
a62511e7ed | ||
|
|
a6c6e3696e | ||
|
|
1b2ab437d1 | ||
|
|
efc7709f66 | ||
|
|
a8f7547c7e | ||
|
|
094b52a2b3 |
@@ -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
|
||||
Generated
+37
-4
@@ -42,11 +42,12 @@
|
||||
"@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",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"figlet": "^1.11.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"html2pdf.js": "^0.14.0",
|
||||
"immer": "^11.1.8",
|
||||
@@ -76,6 +77,7 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/figlet": "^1.7.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^19.2.16",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -5737,6 +5739,13 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "9.0.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz",
|
||||
@@ -9161,9 +9170,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",
|
||||
@@ -10241,6 +10250,30 @@
|
||||
"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": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
|
||||
+3
-1
@@ -51,6 +51,7 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/figlet": "^1.7.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^19.2.16",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -113,11 +114,12 @@
|
||||
"@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",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"figlet": "^1.11.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"html2pdf.js": "^0.14.0",
|
||||
"immer": "^11.1.8",
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { AppShell } from './components/layout/AppShell';
|
||||
import { ModalLayer } from './components/modals/ModalLayer';
|
||||
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';
|
||||
|
||||
function App() {
|
||||
useWelcomeTrigger();
|
||||
const [printOpen, setPrintOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setPrintOpen(true);
|
||||
window.addEventListener('mc:print', handler);
|
||||
return () => window.removeEventListener('mc:print', handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppShell />
|
||||
<ModalLayer />
|
||||
<Toaster />
|
||||
<ReplPanel />
|
||||
{printOpen && <PrintPreview onClose={() => setPrintOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { lightTheme, lightHighlight } from './themes/light';
|
||||
import { useEditorStore } from '@/stores/editor-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { Minimap } from './Minimap';
|
||||
|
||||
interface Props {
|
||||
bufferId: string;
|
||||
@@ -24,6 +26,7 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
|
||||
const { resolvedTheme } = useTheme();
|
||||
const updateContent = useEditorStore((s) => s.updateContent);
|
||||
const setCursor = useEditorStore((s) => s.setCursor);
|
||||
const minimap = useSettingsStore((s) => s.minimap);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
@@ -76,5 +79,10 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
|
||||
});
|
||||
}, [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 { useRestoreLastFolder } from '@/hooks/use-restore-last-folder';
|
||||
import { useRegisterMenuCommands, useBridgeNativeMenu } from '@/lib/commands/register-menu-commands';
|
||||
import { useZenMode } from '@/hooks/use-zen-mode';
|
||||
|
||||
export function AppShell() {
|
||||
useFileShortcuts();
|
||||
useRestoreLastFolder();
|
||||
useRegisterMenuCommands();
|
||||
useBridgeNativeMenu();
|
||||
useZenMode();
|
||||
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 (
|
||||
<div className="flex h-screen flex-col bg-background text-foreground">
|
||||
|
||||
@@ -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() {
|
||||
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 (
|
||||
<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">
|
||||
<span>No file selected</span>
|
||||
<nav
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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 { 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 <WelcomeDialog />;
|
||||
case 'confirm':
|
||||
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 { FileTree } from './FileTree';
|
||||
import { Outline } from './Outline';
|
||||
import { GitStatusPanel } from './GitStatusPanel';
|
||||
|
||||
export function Sidebar() {
|
||||
const tree = useFileStore((s) => s.tree);
|
||||
@@ -49,6 +50,21 @@ export function Sidebar() {
|
||||
</ScrollArea>
|
||||
</CollapsibleContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => ({ '<': '<', '>': '>', '&': '&' }[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>
|
||||
);
|
||||
}
|
||||
@@ -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 { 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': () => {
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 ?? '');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -76,7 +76,15 @@ export const ipc = {
|
||||
}
|
||||
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: {
|
||||
pdf: (opts: PdfOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
|
||||
safeCall('export', 'pdf', opts),
|
||||
@@ -94,6 +102,8 @@ export const ipc = {
|
||||
safeCall('app', 'openExternal', url),
|
||||
showItemInFolder: (path: string): Promise<IpcResult<void | ChannelMissing>> =>
|
||||
safeCall('app', 'showItemInFolder', path),
|
||||
showSaveDialog: (args?: { title?: string; defaultPath?: string }): Promise<IpcResult<string | null | ChannelMissing>> =>
|
||||
safeCall('app', 'showSaveDialog', args),
|
||||
},
|
||||
menu: {
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ vi.mock('@/lib/ipc', () => ({
|
||||
pickFile: vi.fn(),
|
||||
read: 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,7 @@ vi.mock('@/lib/ipc', () => ({
|
||||
pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }),
|
||||
pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }),
|
||||
onChange: vi.fn(() => () => {}),
|
||||
gitStatus: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||
},
|
||||
menu: {
|
||||
on: vi.fn(() => () => {}),
|
||||
@@ -54,6 +55,8 @@ describe('Phase 5 integration', () => {
|
||||
activeTabId: 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({
|
||||
sidebarVisible: true,
|
||||
previewVisible: true,
|
||||
@@ -128,12 +131,14 @@ describe('Phase 5 integration', () => {
|
||||
],
|
||||
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 />);
|
||||
const aTab = screen.getByText('a.md').closest('[role="tab"]')!;
|
||||
const bTab = screen.getByText('b.md').closest('[role="tab"]')!;
|
||||
expect(aTab).toHaveAttribute('aria-current', 'page');
|
||||
// Use role="tab" to find tabs specifically, avoiding breadcrumb "a.md" text
|
||||
const tabs = screen.getAllByRole('tab');
|
||||
expect(tabs[0]).toHaveAttribute('aria-current', 'page');
|
||||
await act(async () => {
|
||||
fireEvent.click(bTab);
|
||||
fireEvent.click(tabs[1]);
|
||||
});
|
||||
expect(useFileStore.getState().activeTabId).toBe('/b.md');
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ vi.mock('@/lib/ipc', () => ({
|
||||
pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }),
|
||||
pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }),
|
||||
onChange: vi.fn(() => () => {}),
|
||||
gitStatus: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||
},
|
||||
menu: {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user