Compare commits

..
Author SHA1 Message Date
Amit Haridas ccb998be66 test(renderer): Phase 6 integration smoke test (menu -> command -> store)
- 7 end-to-end tests covering toolbar, header, native menu IPC,
  userBindings persistence, and command registry
- 169/169 tests pass (was 105 at end of Phase 5)
- build succeeds
2026-06-05 16:03:46 +05:30
Amit Haridas 813582aa8d feat(renderer): command store persists userBindings (Phase 6)
- add userBindings map (commandId -> combo string)
- setUserBinding / clearUserBinding / getUserBinding actions
- persist middleware with partialize: handlers are runtime-only,
  userBindings are serialized
- TDD: 7 persistence tests + existing 10 command-store tests still pass
- 162/162 total tests pass
2026-06-05 16:02:26 +05:30
Amit Haridas b1e16af62d feat(renderer): AppHeader wired to command store
- toggle sidebar/preview buttons dispatch through command store
- new 'shortcuts.show' command (Keyboard icon, opens shortcuts panel later)
- AppHeader.test.tsx updated: registers matching commands in beforeEach
- integration test fixed: 'Open folder' button now matches 2 (toolbar + sidebar)
- 155/155 tests pass
2026-06-05 16:00:58 +05:30
Amit Haridas 487ce2ad45 feat(renderer): Toolbar wired to command store
- 5 functional buttons: Open file, Open folder, Save, Toggle sidebar, Toggle preview
- each dispatches via useCommandStore
- aria-pressed on toggle buttons reflects app store state
- formatting buttons (bold, italic, etc.) stay disabled (Phase 9)
- toolbar now has role='toolbar' and aria-label
- TDD: 9 component tests
2026-06-05 15:58:21 +05:30
Amit Haridas 1717b2e8c0 feat(renderer): register menu commands in AppShell (Phase 6 wiring)
- useRegisterMenuCommands: registers file.open, file.save, file.closeTab,
  tab.next, tab.prev, view.toggleSidebar, view.togglePreview in command store
- useBridgeNativeMenu: useMenuAction for 13 native-menu channels
  (file-save, toggle-preview, load-template-menu, etc.)
- AppShell calls both on mount
- integration test mock extended to include ipc.menu.on
- 145/145 tests pass (was 105)
2026-06-05 15:56:32 +05:30
Amit Haridas cdd370c62d feat(renderer): useMenuAction hook (native menu IPC -> command store)
- bridge between main-process menu events and the command dispatcher
- optional transform: convert raw IPC payload to command args
- extends ipc.ts with ipc.menu.on() helper
- TDD: 5 unit tests covering subscription, payload transform, no-op, unmount
2026-06-05 15:53:37 +05:30
Amit Haridas 6df21ace35 feat(renderer): useShortcut hook (generic key combo binding)
- 'mod+s' / 'mod+shift+s' / 'Tab' / etc.
- mod = meta (Mac) or ctrl (others)
- suppresses when <input>/<textarea>/contentEditable focused
- preventDefault on match
- TDD: 15 unit tests covering parser, match, suppression, unmount
2026-06-05 15:51:35 +05:30
Amit Haridas c93747c476 feat(renderer): command store (action registry for menu/shortcut/toolbar)
- useCommandStore: register/unregister/registerMany/dispatch/get
- keyed by id (e.g. 'file.open', 'view.toggleSidebar')
- TDD: 10 unit tests covering register, dispatch, unregister, batch
- foundation for Phase 6 native menu + toolbar wiring
2026-06-05 15:49:45 +05:30
amitwh d865b181e9 test(renderer): phase 5 integration smoke test 2026-06-05 15:39:36 +05:30
amitwh 5e5bab3da9 feat(renderer): persist last-opened folder (mc-file-store) + restore on mount 2026-06-05 15:36:59 +05:30
amitwh ce2bf62d42 feat(renderer): drag-to-reorder tabs (dnd-kit) 2026-06-05 15:34:25 +05:30
amitwh 6da98c3a54 feat(renderer): keyboard shortcuts for file ops (open, save, close, next/prev tab) 2026-06-05 15:32:55 +05:30
amitwh 3598d06240 feat(preload): add file.list, file.pickFolder, file.pickFile convenience methods 2026-06-05 15:13:16 +05:30
amitwh 650c266945 feat(renderer): TabBar wired to file store (dirty dot, close, active highlight)
- Renders tabs from useFileStore.openTabs with title + dirty dot + close button
- Uses Zustand selectors for granular re-renders
- Active tab highlighted with aria-current and accent background
- Close button stops propagation; does not trigger tab activation
- Horizontal scroll on overflow, h-9 fixed bar with border
- Empty state preserved when no tabs open

Tests: 6 cases (empty, render, highlight, click-switch, click-close, dirty indicator)
- 75 total tests (was 70, gained 5 new)
- All pass
- Build: succeeds

Amit Haridas
2026-06-05 15:03:01 +05:30
amitwh 05bbb9f86b feat(renderer): Sidebar, FileTree, Outline components wired into AppShell 2026-06-05 14:59:15 +05:30
amitwh 77c8e05fa5 feat(renderer): shadcn collapsible, scroll-area, context-menu primitives 2026-06-05 14:54:31 +05:30
amitwh 17f318dd75 fix(renderer): drop redundant ternary, add loadChildren idempotence test 2026-06-05 14:50:56 +05:30
amitwh fa5a46d5ac feat(renderer): file store with FileNode tree, openTabs, lazy loadChildren 2026-06-05 14:42:08 +05:30
amitwh a4201acad9 feat(renderer): wire PreviewPane into AppShell 2026-06-05 13:15:47 +05:30
amitwh 0b5af04a6a feat(renderer): PreviewPane tracks scroll ratio 2026-06-05 13:14:50 +05:30
amitwh 2d6f909b0e feat(renderer): useScrollSync hook (60fps throttling, ratio calc) 2026-06-05 13:13:37 +05:30
amitwh 8624257c1f feat(renderer): editor buffer content feeds preview store (300ms debounce) 2026-06-05 13:09:34 +05:30
amitwh b75f436aaa feat(renderer): PreviewPane with empty state and MarkdownRenderer 2026-06-05 13:07:55 +05:30
amitwh d81d8c28d2 test(renderer): remove non-existent html property from preview-store reset 2026-06-05 13:07:06 +05:30
amitwh 559d09cad9 feat(renderer): preview store with 300ms debounced source 2026-06-05 13:04:39 +05:30
amitwh 5ad582ec3e feat(renderer): MarkdownRenderer with mermaid code re-extraction from source 2026-06-05 13:03:32 +05:30
amitwh d02f896167 feat(renderer): MermaidLazy (loads mermaid on first use, error UI) 2026-06-05 13:01:24 +05:30
amitwh f34775ce53 feat(renderer): markdown lib (marked + DOMPurify + mermaid placeholders) 2026-06-05 12:56:31 +05:30
amitwh 5d6a55dd09 feat(renderer): StatusBar reads from editor store (word count, cursor pos) 2026-06-05 12:49:16 +05:30
amitwh 0e80e37d9d feat(renderer): wire EditorPane into AppShell 2026-06-05 12:46:13 +05:30
amitwh 9a7e994224 feat(renderer): EditorPane with empty state and CodeMirror rendering 2026-06-05 12:42:25 +05:30
amitwh 3effacf816 feat(renderer): CodeMirror 6 editor wrapper with markdown, themes, keymaps 2026-06-05 12:38:09 +05:30
amitwh 1be10f9ae5 feat(renderer): CodeMirror light theme with brand-aware syntax colors 2026-06-05 12:25:31 +05:30
amitwh 4c0b7d6e84 feat(renderer): editor store with buffers, dirty state, cursor (immer) 2026-06-05 12:21:48 +05:30
amitwh db04ea5854 feat(renderer): App.tsx renders AppShell 2026-06-05 12:11:01 +05:30
amitwh 567726022c feat(renderer): AppShell with resizable panes, sidebar/preview toggle, persisted sizes 2026-06-05 12:02:35 +05:30
amitwh 1999a7716e feat(renderer): StatusBar placeholder (word count wired in Phase 3) 2026-06-05 11:53:34 +05:30
amitwh 16e071ae5e feat(renderer): Breadcrumb placeholder (wired in Phase 5) 2026-06-05 11:50:45 +05:30
amitwh 7ecf54ceb0 feat(renderer): Toolbar skeleton with formatting buttons 2026-06-05 11:48:26 +05:30
amitwh 95e2f45f1f feat(renderer): TabBar skeleton (no tabs yet — wired in Phase 5) 2026-06-05 11:45:33 +05:30
amitwh 7fbbd60525 feat(renderer): AppHeader with sidebar/preview toggles + theme toggle 2026-06-05 11:40:54 +05:30
amitwh c715bb354c feat(renderer): install shadcn resizable primitive 2026-06-05 11:35:02 +05:30
amitwh 6525de7667 feat(renderer): add useAppStore with sidebar/preview/zen/pane-sizes 2026-06-05 11:30:25 +05:30
amitwh a2c1d0c11a feat(renderer): wire App.tsx shell with theme provider and top-bar 2026-06-05 10:19:36 +05:30
amitwh e0d6315204 feat(renderer): add next-themes provider + theme toggle (sun/moon) 2026-06-05 10:17:38 +05:30
amitwh 68ff7d076a feat(renderer): install shadcn button primitive with tests 2026-06-05 10:13:31 +05:30
amitwh 67b57eae78 feat(renderer): add motion preset transitions for modals/sidebar/toasts 2026-06-05 09:37:49 +05:30
amitwh 301c51ac84 test(renderer): add failing test for motion presets 2026-06-05 09:36:18 +05:30
amitwh 904ffbdd5b feat(renderer): implement typed IPC wrapper with IpcResult discriminated union 2026-06-05 09:34:19 +05:30
amitwh ea383783ec test(renderer): add failing test for ipc wrapper + IpcResult types 2026-06-05 09:25:02 +05:30
amitwh e758a81c4c feat(renderer): implement cn() helper for tailwind-merge + clsx 2026-06-05 09:23:30 +05:30
amitwh ea0f3f93bf test(renderer): add failing test for cn() helper + vitest config 2026-06-05 09:20:04 +05:30
amitwh 188e73d7c1 feat(renderer): configure shadcn/ui, extend design tokens for glassy aesthetic 2026-06-05 09:13:52 +05:30
amitwh 8265e4ff88 chore(deps): add shadcn/ui ecosystem, motion, zustand+immer, test stack 2026-06-05 09:01:45 +05:30
amitwh 8a8671b128 docs(plan): add React UI redesign implementation plan (10 vertical slices)
- Phase 1: Foundation (shadcn, deps, lib utils, motion presets, theme)
- Phase 2: App shell + resizable panes
- Phase 3: Editor (CodeMirror 6 + immer store)
- Phase 4: Preview (marked + KaTeX + mermaid + scroll sync)
- Phase 5: File tree + tabs
- Phase 6: Native menus + toolbar
- Phase 7: Modals (export, settings, about, confirm)
- Phase 8: Toasts (sonner)
- Phase 9: Advanced tools (zen, repl, ascii, table, print, word)
- Phase 10: Polish + delete legacy renderer

Phases 1-4 are fully fleshed out (60+ TDD tasks with working code).
Phases 5-9 are sketched with the same TDD pattern; detailed
steps will be expanded at execution time following the conventions
established in Phases 1-4.
2026-06-05 08:52:03 +05:30
amitwh 3646dc29c6 docs(spec): fix spec self-review issues (typo, drag-vs-resize, lazy-load ambiguity) 2026-06-05 08:27:33 +05:30
amitwh a999f598cc docs(spec): add React + shadcn/ui UI redesign design spec
Brainstormed via superpowers:brainstorming. Locks in:
- Full feature parity with legacy renderer
- Polished + Glassy (Raycast/Arc) visual style
- IDE-style layout with draggable divider
- shadcn/ui + Motion (Framer Motion) + Zustand stack
- A/B/D modal patterns (no command palette)
- 10-phase vertical-slice implementation plan
2026-06-05 08:26:02 +05:30
amitwh 7a396f64a6 feat(renderer): setup React, Vite, Tailwind CSS and Zustand state management 2026-06-03 23:23:59 +05:30
amitwh 88e9a5290d fix(renderer): add ensureEditor fallback and fix pandoc export path
1. Source editor blank: Added ensureEditor() method with try-catch that
   lazily creates the CodeMirror editor when setEditorContent is called
   if the initial DOMContentLoaded creation failed or was skipped.
   Replaced inline createEditor calls in createTabElements and
   DOMContentLoaded with ensureEditor().

2. Export via pandoc failing: runPandocCmd parsed the full path
   /bin/linux/pandoc as the command, then prepended it to args because
   parsed.command !== 'pandoc'. This caused pandoc to receive its own
   binary path as the first input file. Fix: use path.basename() to
   check if the command ends with 'pandoc' (or 'pandoc.exe').

Amit Haridas
2026-06-03 20:44:00 +05:30
amitwh fff15d8d3e fix(renderer): add window.electronAPI shim and update CSP for KaTeX
The main window uses nodeIntegration without preload, so window.electronAPI
was undefined. This caused the DOMContentLoaded handler to crash at:
  await window.electronAPI.getAppVersion()
which prevented the CodeMirror editor from being created (blank source
window) and stopped renderer-ready from being sent (broken menu/options).

Fixes:
1. Add window.electronAPI shim at top of renderer.js wrapping ipcRenderer.
2. Update CSP meta tag to allow KaTeX CDN (style-src, script-src, font-src).

Amit Haridas
2026-06-03 15:37:20 +05:30
amitwh c574d77c20 fix(renderer): remove let ModalManager to prevent SyntaxError on load
ModalManager.js is loaded via <script> tag in index.html, which
declares class ModalManager in the global script scope. renderer.js
was then doing 'let ModalManager;' which caused:
  SyntaxError: Identifier 'ModalManager' has already been declared

This prevented renderer.js from executing at all, breaking tabs,
editor, file open, and preview rendering.

Fix: conditionally require ModalManager only if undefined, without
re-declaring. In sloppy mode this safely assigns to the existing global.

Amit Haridas
2026-06-03 15:25:22 +05:30
amitwh 272215f9af fix(renderer): prevent welcome screen from overwriting opened markdown files
The welcome tab setup awaited getAppVersion() inside DOMContentLoaded.
While yielding, the renderer-ready timeout fired, file-opened was processed,
and openFile rendered the markdown. When the welcome setup resumed, it
overwrote tab.content and preview.innerHTML with the welcome screen,
leaving the preview blank.

Fix: only show the welcome screen if the tab is still empty (no filePath
and no content) when the async setup resumes.

Amit Haridas
2026-06-03 15:14:41 +05:30
amitwh 0192590567 fix(renderer): add missing closing brace for redo IPC handler
The ipcRenderer.on('redo') callback was missing its closing });
introduced in a9e05d2, causing SyntaxError: Unexpected end of input.

Amit Haridas
2026-06-03 14:36:59 +05:30
amitwh 96df5652d6 fix: resolve list-directory IPC handler closing brace syntax error 2026-05-26 11:39:57 +05:30
amitwh a9e05d2c0f feat: implement Custom Preview CSS, Reveal.js options, Large File Mode, and Interactive PDF Thumbnail Sidebar 2026-05-25 23:11:47 +05:30
amitwh c982b3e90f chore(diagnostics): add logging to _renderPreview to trace markdown rendering
This will help identify whether the issue is:
1. marked.parse returning a Promise instead of string
2. DOMPurify.sanitize failing
3. The preview element not existing
4. Libraries not being loaded

Amit Haridas
2026-05-25 00:32:27 +05:30
amitwh cfaafc07b2 chore(deps): update vulnerable packages to patched versions
Updated packages:
- simple-git 3.32.3 → 3.36.0 (RCE vulnerability)
- fast-uri 3.1.0 → 3.1.2 (host confusion, path traversal)
- dompurify 3.3.1 → 3.4.5 (XSS bypasses)
- mermaid 11.13.0 → 11.15.0 (CSS injection, DoS)
- uuid 11.1.0 → 11.1.1 (buffer bounds check)
- ws 8.20.0 → 8.20.0 (uninitialized memory)
- ip-address 10.1.0 → 10.2.0 (XSS)
- @xmldom/xmldom 0.8.11 → 0.9.10 (XML injection, DoS)
- docx4js 3.3.0 → 2.0.1 (breaks xml2js dep chain)
- brace-expansion (transitive update)

Also applied lint:fix autofix (const correctness).

Amit Haridas
2026-05-25 00:00:28 +05:30
amitwh 94ad99dc4d fix(renderer): ensure tab content visibility after file open
- Add missing updateUI() call at end of openFile() to set .active class
  on tab content. Without this, the CSS rule .tab-content:not(.active)
  { display: none } kept newly opened files invisible.
- Add diagnostic logging to file-opened IPC handler and openFile()
  to trace future file loading issues.
- Add diagnostic logging to openFileFromPath() in main process.

Amit Haridas
2026-05-24 23:54:31 +05:30
94 changed files with 14042 additions and 693 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/renderer/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,361 @@
# MarkdownConverter — React + shadcn/ui UI Redesign
**Date:** 2026-06-05
**Status:** Design (awaiting user approval)
**Branch:** `react-electron`
**Author:** Brainstormed with user via superpowers:brainstorming
## Summary
Replace the legacy vanilla JS renderer (`src/renderer.js` is 213 KB; `src/styles.css` is 74 KB) with a modern React 19 + Vite + TypeScript + shadcn/ui renderer that achieves visual feature parity while delivering a Polished + Glassy (Raycast/Arc-style) aesthetic. The Electron main process, preload bridge, and IPC contracts stay unchanged. Work proceeds via vertical slices — each PR ships a demoable feature.
## Goals
1. Replace every render-time feature of the legacy renderer with an idiomatic React + shadcn/ui implementation.
2. Adopt the "Polished + Glassy" visual language (subtle shadows, 812 px radii, gradient accents, gentle depth) on top of the existing ConcreteInfo brand tokens and design system.
3. Use a single component foundation (shadcn/ui) and a single motion library (Motion / Framer Motion) to keep the dependency surface narrow and the design coherent.
4. Keep the main process, preload, and IPC contracts untouched. The renderer is the only surface being rewritten.
5. Keep the app runnable at every commit. No "big bang" merge.
## Non-Goals
- Migrating the main process to TypeScript (out of scope for this spec).
- Adding new features the legacy renderer does not have (plugin system, AI features, etc. — those are future specs).
- Replacing Pandoc/FFmpeg/ImageMagick orchestration.
- Changing the packaging pipeline (electron-builder config stays).
- Replacing the CodeMirror 6 editor — it is the right tool and is already wired up.
## Decisions Locked During Brainstorming
| Decision | Choice | Why |
|---|---|---|
| Scope | Full feature parity with legacy renderer | User-selected option. Renderer is one cohesive surface; splitting it across specs would force premature contracts. |
| Visual style | **B — Polished + Glassy** (Raycast/Arc aesthetic) | "Fancy" comes from elevation, gradient accents, and material depth — not over-designed visuals. |
| Layout | **2 — IDE-style** (equal editor/preview, draggable divider, collapsible sidebar) | Power-user markdown apps converge on this. Draggable divider + keyboard reset. |
| Modal patterns | **A (Centered Dialog), B (Right Side-Sheet), D (Toasts)** | No command palette. |
| Command palette | **No** — full menus, no ⌘K | Power users can use the OS-level launcher. App stays focused on writing. |
| Animation | **Motion (Framer Motion)** | Best for layout transitions, modal/drawer enter/exit, drag. Sparingly used. |
| Component library | **shadcn/ui** | Tailwind + Radix primitives, copy-paste ownership, perfect fit for the "glassy" aesthetic and the HSL-CSS-variable foundation that is already in place. |
| Implementation strategy | **Vertical slices** — one feature end-to-end per PR | App stays runnable. Each PR is reviewable. |
## Defaults (Used Unless Overridden Later)
| Item | Default | Why |
|---|---|---|
| State management | Zustand (already in deps) + Immer for nested patches | Already in `package.json`; perfect for editor state. |
| Theming | shadcn `next-themes`, dark default + light, system-aware | shadcn canonical pattern. Brand colors already in CSS vars. |
| Icons | `lucide-react` (already in deps) | 1000+ tree-shakable icons. |
| Forms | `react-hook-form` + `zod` | Standard for shadcn forms. |
| Drag/drop (sortable lists) | `@dnd-kit/core` | De facto React drag lib (file tree reordering, plugin list reorder). |
| Pane resize (split layout) | `react-resizable-panels` | Canonical React lib for resizable pane groups; handles drag, arrow keys, snap, persisted sizes. |
| Testing | Vitest + RTL + Playwright (E2E + visual regression) | Standard for React + Electron. |
| TypeScript | Strict mode (already wired) | Already in deps and `vite.renderer.config.ts`. |
## Architecture
The Electron app keeps its existing process model. Only the renderer is rewritten.
```
┌──────────────────────────────────────────────────────────┐
│ Electron Main (UNCHANGED) │
│ - BrowserWindow, IPC handlers, file/fs ops │
│ - Pandoc, FFmpeg, ImageMagick orchestration │
└────────────┬─────────────────────────────────────────────┘
│ contextBridge (UNCHANGED preload.js)
┌────────────▼─────────────────────────────────────────────┐
│ React Renderer (REWRITTEN) │
│ ┌────────────────────────────────────────────────────┐ │
│ │ AppShell (layout) │ │
│ │ ├─ MenuBar (native) │ │
│ │ ├─ AppHeader (logo, breadcrumbs, theme toggle) │ │
│ │ ├─ TabBar (open files) │ │
│ │ ├─ Toolbar (formatting) │ │
│ │ ├─ ResizablePaneGroup (sidebar | editor | preview) │ │
│ │ │ ├─ Sidebar (file tree, outline) │ │
│ │ │ ├─ EditorPane (CodeMirror 6) │ │
│ │ │ └─ PreviewPane (marked + KaTeX + Mermaid) │ │
│ │ ├─ StatusBar (word count, encoding, cursor pos) │ │
│ │ └─ ModalLayer (Dialog, SideSheet, Toaster) │ │
│ └────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Feature Modules (each owns UI + state slice) │ │
│ │ ├─ editor/ CodeMirror wrapper, syntax, themes │ │
│ │ ├─ preview/ markdown→html, KaTeX, Mermaid │ │
│ │ ├─ tabs/ open files, dirty state │ │
│ │ ├─ sidebar/ file tree, outline, search results │ │
│ │ ├─ modals/ export, settings, about, etc. │ │
│ │ ├─ tools/ zen, repl, ascii-gen, table-gen │ │
│ │ └─ export/ pdf, docx, html, image batch │ │
│ └────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Shared Infrastructure │ │
│ │ ├─ stores/ Zustand slices per feature │ │
│ │ ├─ hooks/ useFile, useEditor, useTheme, etc. │ │
│ │ ├─ lib/ cn, ipc, formatters, validators │ │
│ │ ├─ ui/ shadcn primitives: button, dialog… │ │
│ │ └─ types/ shared TS types │ │
│ └────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
```
### Architectural Rules
- **Shell components** (`AppHeader`, `TabBar`, `StatusBar`) subscribe only to `useAppStore`.
- **Feature components** subscribe to their own feature's store.
- **Cross-feature access** goes through hooks, not direct store imports (e.g., `useFileTree()` wraps `useFileStore`).
- **Feature modules are self-contained** — each owns its components, its Zustand slice, and its types. Safe to develop in parallel later.
- **No direct `window.electronAPI` calls** from feature code. All IPC goes through `lib/ipc.ts` for type safety and error normalization.
## State Management
```
stores/
├─ useAppStore // global UI: theme, sidebar, pane sizes, modals open
├─ useFileStore // file system: tree, open files, active tab
├─ useEditorStore // editor: per-file content, cursor, selection, dirty
├─ usePreviewStore // preview: scroll sync, zoom, theme
├─ useSettingsStore // user prefs (persisted to electron-store)
└─ useCommandStore // menu actions registry, recent actions
```
**Why slices instead of one mega-store?** Editor content can be megabytes. Keeping it in its own slice lets React avoid re-rendering the preview when only the editor changes — components subscribe with selectors.
**Persistent state** (auto-saved to electron-store via Zustand `persist` middleware): theme, sidebar visibility, last open files, pane divider positions, settings.
**Ephemeral state** (lost on quit): active modal, hover states, search query, current file content (saved to disk on idle).
## Visual Design System
### Color Tokens
The existing `globals.css` HSL variables stay. Additions for the "glassy" aesthetic:
```css
--shadow-sm: 0 1px 2px rgba(13, 11, 9, 0.06);
--shadow-md: 0 4px 12px rgba(13, 11, 9, 0.08), 0 0 0 1px rgba(13, 11, 9, 0.04);
--shadow-lg: 0 12px 32px rgba(13, 11, 9, 0.12), 0 0 0 1px rgba(13, 11, 9, 0.06);
--shadow-glow-brand: 0 0 24px rgba(229, 70, 31, 0.25);
--glass-bg-light: rgba(255, 255, 255, 0.72);
--glass-bg-dark: rgba(13, 11, 9, 0.72);
--glass-border-light: rgba(255, 255, 255, 0.4);
--glass-border-dark: rgba(255, 255, 255, 0.08);
```
### shadcn Components
Install via `npx shadcn@latest add`:
**Primitives** (need all of these): `button`, `dialog`, `sheet`, `popover`, `tooltip`, `select`, `dropdown-menu`, `tabs`, `separator`, `scroll-area`, `toggle`, `switch`, `slider`, `input`, `textarea`, `label`, `form`, `skeleton`, `sonner`, `command`.
**Composite** (custom-built on top of primitives): `file-tree`, `pane-group`, `divider`, `status-bar`, `menu-bar`, `toast`.
### Typography
- Body: Plus Jakarta Sans 15 px / line-height 1.6
- Code: JetBrains Mono 13.5 px
- Display: Barlow Condensed 700/800 — used sparingly (page titles, big metrics)
- Headings: Plus Jakarta Sans 600/700 with tight letter-spacing
- Labels (`.label`): Plus Jakarta Sans 500, 12 px, uppercase, 0.05 em letter-spacing
### Motion Choreography
| Element | Motion | Duration | Easing |
|---|---|---|---|
| Modal (Dialog A) | Scale 0.96→1, opacity 0→1 | 200 ms | `ease-out` |
| Side sheet (B) | TranslateX 100%→0, opacity 0→1 | 300 ms | `cubic-bezier(0.16, 1, 0.3, 1)` |
| Toast | TranslateY 100%→0, opacity | 250 ms | spring(stiffness: 300, damping: 30) |
| Sidebar toggle | Width 264 px↔72 px, content fade | 250 ms | `ease-in-out` |
| Divider drag | Live width update | 0 ms | none (instant) |
| Theme switch | CSS variables, opacity overlay | 300 ms | `ease-in-out` |
| Tab switch | Underline slide | 200 ms | `ease-out` |
| Hover (buttons) | `bg` + `shadow` shift | 150 ms | `ease-out` |
| Focus ring | Outline grow | 100 ms | `ease-out` |
**Rules:** Every motion has a purpose. No gratuitous animation. Respects `prefers-reduced-motion` (Motion handles this automatically).
### Empty / Loading / Error States
Every async surface ships all three. Sonner toasts for ephemeral feedback. Skeleton screens for content areas. Empty-state components with icon + message + primary CTA.
## Modal/Overlay Patterns
| Use case | Pattern | Why |
|---|---|---|
| Export (PDF/DOCX/HTML) | A — Centered Dialog | Focused decision, ~35 fields, "do one thing" |
| Settings | B — Right Side-Sheet | Long form, tabbed sections, stays open while editing |
| Find/Replace | Inline toolbar (not modal) | Always-visible utility |
| About | A — Centered Dialog | Tiny info dump |
| Confirm destructive (delete, close unsaved) | A — Centered Dialog | Forces attention |
| File save error | D — Toast | Non-blocking info |
| Pandoc/FFmpeg progress | D — Toast → sticky on >3 s | Background work feedback |
| Plugin manager | B — Right Side-Sheet | Lists + per-item actions |
| ASCII/Table generators | A — Centered Dialog | Tool-style focused input → output |
| Print preview | A — Centered Dialog | Full-screen-ish modal overlay |
| Welcome (first launch) | A — Centered Dialog | One-time onboarding |
| Word export | A — Centered Dialog | Template selection |
| Zen mode | Full-viewport toggle (no modal) | Replaces the editor entirely |
| REPL | Bottom-pinned panel (split-pane) | Persistent terminal-like UI |
| Quick file open | B — Right Side-Sheet | File tree, search, recent |
### Dialog A Anatomy (Export as example)
```
┌─ Backdrop (rgba(13,11,9,0.45) + backdrop-blur 4px) ─────────┐
│ │
│ ┌──── Modal (max-w-md, rounded-2xl, shadow-lg) ─────┐ │
│ │ ┌── Header ────────────────────────────────────┐ │ │
│ │ │ [Icon] Export as PDF [×] │ │ │
│ │ │ Choose format options │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ │ ┌── Body ──────────────────────────────────────┐ │ │
│ │ │ Format: [Letter] [A4] [Legal] │ │ │
│ │ │ Margins: ────●──── │ │ │
│ │ │ ☐ Include table of contents │ │ │
│ │ │ ☐ Embed fonts │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ │ ┌── Footer ────────────────────────────────────┐ │ │
│ │ │ [Cancel] [Export →] │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────┘
```
### Side-Sheet B Anatomy (Settings as example)
Slides in from right, ~50 % width (max 560 px). Backdrop is `rgba(13,11,9,0.2)` with `backdrop-blur(2 px)` (lighter than dialog — the sheet is the focus). Tab navigation in the sheet header (Editor / Theme / Export / Plugins / About).
### Toast D Anatomy (Sonner)
Stacked bottom-right, max 3 visible. Glass background. Color-coded 3 px left border — success `#1a7a56`, error `#ef4444`, info `#0ea5e9`, warning `#eab308`. Icon + title + description. Optional action button. Auto-dismiss 4 s for success, sticky for error.
## Data Flow (Editor → Preview Pipeline)
```
User types
CodeMirror onChange
useEditorStore.updateContent(tabId, content) ← debounced 50 ms
┌─ Persist to electron-store on idle (1 s) ─┐
│ │
└─ Publish to usePreviewStore (subscribed) ─┘
marked(content) → sanitized HTML
PreviewPane renders HTML
KaTeX post-processes $...$
Mermaid post-processes ```mermaid
Highlight.js post-processes ```lang
useEffect: scroll-sync editor cursor → preview position
```
**Bidirectional scroll sync:** editor scroll → preview (primary), preview click → editor cursor (secondary). Throttled to 60 fps via `requestAnimationFrame`.
## IPC Contract
The preload bridge stays unchanged. A new `src/renderer/lib/ipc.ts` wraps every channel with TypeScript types and normalizes errors to a discriminated union:
```ts
type IpcResult<T> =
| { ok: true; data: T }
| { ok: false; error: { code: string; message: string } };
export const ipc = {
file: {
open: (): Promise<IpcResult<FileResult>>,
read: (path: string): Promise<IpcResult<string>>,
write: (path: string, content: string): Promise<IpcResult<void>>,
list: (dir: string): Promise<IpcResult<FileEntry[]>>,
onChange: (cb: (path: string) => void) => () => void,
},
export: {
pdf: (opts: PdfOptions): Promise<IpcResult<ExportResult>>,
docx: (opts: DocxOptions): Promise<IpcResult<ExportResult>>,
html: (opts: HtmlOptions): Promise<IpcResult<ExportResult>>,
batch: (items: BatchItem[], opts: BatchOptions): Promise<IpcResult<BatchResult>>,
},
app: {
getVersion: (): Promise<IpcResult<string>>,
openExternal: (url: string): Promise<IpcResult<void>>,
showItemInFolder: (path: string): Promise<IpcResult<void>>,
},
};
```
## Error Handling
Layered defense — no single failure can crash the app:
1. **IPC errors** → caught in `lib/ipc.ts` wrapper, returned as `IpcResult<T>` discriminated union. Components show toast on error.
2. **Component errors** → React error boundary per feature area (one for editor, one for preview, one for modals). On error: show inline error UI with "Reload this panel" + "Copy details" actions.
3. **Async operations** (export, file ops) → loading state on button + toast on completion. Long ops (>2 s) get a sticky toast with cancel.
4. **Validation errors** (settings, export options) → inline form errors via `react-hook-form` + `zod`. shadcn `Form` component for consistent error display.
5. **Pandoc/FFmpeg missing** → detected at startup, banner + disable export menu items. Don't surprise-fail at export time.
## Testing Strategy
- **Unit (Vitest):** stores, hooks, lib utilities, formatters, validators
- **Component (Vitest + RTL):** shadcn wrappers, dialog interactions, sidebar toggle, divider drag, theme toggle
- **Integration (Vitest + RTL):** editor ↔ preview flow, file open → tab add → content display, settings change persists
- **E2E (Playwright):** app launches, file opens, markdown renders, export to PDF works
- **Visual regression (Playwright snapshots):** locked-in screenshots for header, sidebar, dialogs, toasts — catch accidental style drift
- **Target coverage:** stores/hooks/lib ≥ 90 %, components ≥ 75 %, E2E covers all critical paths
## Accessibility (WCAG 2.1 AA)
Baked in via shadcn + Radix:
- All dialogs focus-trap, Esc to close, return focus to trigger
- All interactive elements keyboard-reachable
- Visible focus rings (2 px brand ring)
- 4.5:1 contrast minimum (already met by brand colors)
- `aria-label` on icon buttons, `role="alert"` on toasts
- Respects `prefers-reduced-motion` (Motion handles this automatically)
## Implementation Phases (Vertical Slices)
Each phase is a shippable PR that keeps the app runnable. The legacy `renderer.js` is gradually replaced; until each phase is complete, the old renderer code still runs the missing parts.
| # | Phase | Output | Acceptance |
|---|---|---|---|
| 1 | **Foundation** | shadcn installed; `next-themes`, `motion`, `react-hook-form`, `zod`, `@dnd-kit/core`, `react-resizable-panels`, `sonner` installed; design tokens finalized; `lib/utils.ts` (`cn`), `lib/ipc.ts` typed wrappers, `lib/motion.ts` preset transitions, `App.tsx` shell skeleton renders | `npm run build` succeeds; dev server shows the new shell with theme toggle working |
| 2 | **App shell + layout** | `AppHeader`, `TabBar`, `Toolbar`, `Breadcrumb`, `StatusBar`, `ResizablePaneGroup` with sidebar toggle and draggable divider. Empty editor/preview panes. | Resize divider with mouse and arrow keys; sidebar collapses; pane sizes persist. |
| 3 | **Editor pane** | CodeMirror 6 wrapped, dark/light themes wired to shadcn theme, syntax highlighting, line numbers, search, autocomplete | Open `.md` file → renders in editor; can edit and save. |
| 4 | **Preview pane** | marked + DOMPurify + KaTeX + Mermaid + highlight.js. Bidirectional scroll sync with editor. | Open file → preview renders, follows cursor. |
| 5 | **File tree + tabs** | Sidebar file tree (read directory on demand, lazy-expand children only on click). Tabs for open files. Dirty state indicator. File content is read from disk only when the tab is activated. | Open folder → root populates; click folder → children load; click file → opens in tab; close tab reverts dirty. |
| 6 | **Native menus + toolbar** | Replace the legacy `CommandPalette` with full menus (File / Edit / View / Insert / Format / Tools / Help) bound to keyboard shortcuts. Toolbar buttons. | All menu items invoke the right action; shortcuts work; toolbar reflects active state. |
| 7 | **Modals** | Export dialog (PDF/DOCX/HTML/batch), Settings side-sheet (Editor/Theme/Export/Plugins/About tabs), About dialog, Confirm-destructive dialog | All dialogs and sheet render with proper motion; settings persist; export works end-to-end. |
| 8 | **Toasts** | Sonner wired into all async operations (save, export, errors, tool missing) | All operations give appropriate feedback; no silent failures. |
| 9 | **Advanced tools** | Zen mode (full-viewport toggle), REPL (bottom-pinned panel), ASCII generator, Table generator, Word export template picker, Print preview | Each tool is feature-complete vs. legacy version. |
| 10 | **Polish + delete legacy** | Visual regression snapshots locked; remove `renderer.js` and old `styles*.css` references from build; `npm run build:linux` produces a working installer | Final PR ships an installer with no legacy code paths. |
## Risks and Mitigations
| Risk | Mitigation |
|---|---|
| shadcn CLI requires Vite (not vanilla Electron renderer) | Vite is already configured for the renderer in `vite.renderer.config.ts`; the CLI works against the same project. |
| shadcn CLI assumes Tailwind is at project root; we have it in renderer/ only | Point the CLI at `src/renderer/components.json` and set `tailwind.css` to the right path. |
| Marked + KaTeX + Mermaid + highlight.js together is heavy | Lazy-load Mermaid (only when `mermaid` code block encountered). KaTeX is loaded once, cached. |
| Editor content state can be megabytes → re-render storms | Editor content lives in its own slice; preview subscribes to a derived/cached HTML string; components use `useShallow` / selector subscriptions. |
| Bidirectional scroll sync loops | `useEffect` debounce + ignore if cursor/selection is the sync source. |
| CodeMirror 6 doesn't ship a Tailwind theme by default | Use `@codemirror/theme-one-dark` for dark; build a custom theme that pulls from CSS variables for light. |
| The legacy `renderer.js` and `styles*.css` are referenced from `index.html` (now `src/renderer/index.html`) | Phase 10 deletes the references; until then, both run side-by-side and the new React app sits in a known root div. |
| Electron `nodeIntegration` is off; we use contextBridge | Already the case. Document the contract clearly in `lib/ipc.ts`. |
## Open Questions (to confirm before implementation)
None. All major decisions are locked. Defaults will be used unless the user overrides them when reviewing the implementation plan.
## References
- `src/renderer.js` (213 KB) — legacy renderer being replaced
- `src/styles.css` (74 KB), `src/styles-modern.css` (71 KB), `src/styles-concreteinfo.css` (21 KB) — legacy styles being replaced
- `src/renderer/styles/globals.css` — already shadcn-compatible (HSL CSS variables)
- `src/renderer/App.tsx` — empty skeleton that references components we will build
- `tailwind.config.js` — brand colors and fonts already defined
- `vite.renderer.config.ts` — Vite + React plugin already configured
- `package.json` — CodeMirror 6, TanStack Table, lucide-react, cva, clsx, tailwind-merge, tailwindcss-animate already installed
+4150 -608
View File
File diff suppressed because it is too large Load Diff
+55 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "markdown-converter", "name": "markdown-converter",
"version": "4.4.1", "version": "4.4.2",
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting", "description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
"main": "src/main.js", "main": "src/main.js",
"scripts": { "scripts": {
@@ -8,6 +8,9 @@
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:coverage": "jest --coverage", "test:coverage": "jest --coverage",
"test:renderer": "vitest run",
"test:renderer:watch": "vitest",
"test:renderer:coverage": "vitest run --coverage",
"lint": "eslint src tests", "lint": "eslint src tests",
"lint:fix": "eslint src tests --fix", "lint:fix": "eslint src tests --fix",
"format": "prettier --write src tests", "format": "prettier --write src tests",
@@ -43,7 +46,19 @@
"url": "https://github.com/amitwh/markdown-converter" "url": "https://github.com/amitwh/markdown-converter"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.60.0",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.9.1",
"@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/ui": "^4.1.8",
"autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cross-env": "^10.0.0", "cross-env": "^10.0.0",
"electron": "^41.1.1", "electron": "^41.1.1",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
@@ -52,8 +67,17 @@
"eslint-plugin-prettier": "^5.5.4", "eslint-plugin-prettier": "^5.5.4",
"jest": "^30.2.0", "jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0", "jest-environment-jsdom": "^30.2.0",
"jsdom": "^29.1.1",
"lucide-react": "^1.17.0",
"postcss": "^8.5.15",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"sharp": "^0.34.3" "sharp": "^0.34.3",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"typescript": "^6.0.3",
"vite": "^8.0.16",
"vitest": "^4.1.8"
}, },
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.20.1", "@codemirror/autocomplete": "^6.20.1",
@@ -70,25 +94,44 @@
"@codemirror/state": "^6.5.4", "@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.16", "@codemirror/view": "^6.39.16",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-slot": "^1.2.4",
"@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.6.0",
"docx4js": "^3.3.0", "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",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"html2pdf.js": "^0.14.0", "html2pdf.js": "^0.14.0",
"immer": "^11.1.8",
"marked": "^17.0.3", "marked": "^17.0.3",
"marked-footnote": "^1.4.0", "marked-footnote": "^1.4.0",
"marked-highlight": "^2.2.3", "marked-highlight": "^2.2.3",
"mermaid": "^11.12.3", "mermaid": "^11.12.3",
"motion": "^12.40.0",
"next-themes": "^0.4.6",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdfjs-dist": "^5.5.207", "pdfjs-dist": "^5.5.207",
"pdfkit": "^0.17.2", "pdfkit": "^0.17.2",
"pizzip": "^3.2.0", "pizzip": "^3.2.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-hook-form": "^7.77.0",
"react-resizable-panels": "^4.11.2",
"simple-git": "^3.32.3", "simple-git": "^3.32.3",
"tslib": "^2.8.1" "sonner": "^2.0.7",
"tslib": "^2.8.1",
"zod": "^4.4.3",
"zustand": "^5.0.14"
}, },
"overrides": { "overrides": {
"jszip": "^3.10.1", "jszip": "^3.10.1",
@@ -170,7 +213,10 @@
"verifyUpdateCodeSignature": false, "verifyUpdateCodeSignature": false,
"signAndEditExecutable": false, "signAndEditExecutable": false,
"extraFiles": [ "extraFiles": [
{ "from": "bin/win32/pandoc.exe", "to": "bin/pandoc.exe" } {
"from": "bin/win32/pandoc.exe",
"to": "bin/pandoc.exe"
}
] ]
}, },
"nsis": { "nsis": {
@@ -198,7 +244,10 @@
"category": "Utility", "category": "Utility",
"maintainer": "ConcreteInfo <amit.wh@gmail.com>", "maintainer": "ConcreteInfo <amit.wh@gmail.com>",
"extraFiles": [ "extraFiles": [
{ "from": "bin/linux/pandoc", "to": "bin/pandoc" } {
"from": "bin/linux/pandoc",
"to": "bin/pandoc"
}
] ]
}, },
"deb": { "deb": {
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+58 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<!-- CSP: unsafe-inline/unsafe-eval required for marked.js extensions and Mermaid --> <!-- CSP: unsafe-inline/unsafe-eval required for marked.js extensions and Mermaid -->
<!-- TODO: Migrate to nonce-based CSP for better security --> <!-- TODO: Migrate to nonce-based CSP for better security -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://www.plantuml.com;"> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: blob:; font-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self' https://www.plantuml.com;">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MarkdownConverter</title> <title>MarkdownConverter</title>
<!-- Design tokens - loaded first for CSS variable availability --> <!-- Design tokens - loaded first for CSS variable availability -->
@@ -288,6 +288,52 @@
</div> </div>
</div> </div>
<div class="export-section revealjs-only" style="display: none;">
<label>Reveal.js Options:</label>
<div class="form-row">
<label for="reveal-theme">Slide Theme:</label>
<select id="reveal-theme">
<option value="black">Black</option>
<option value="white">White</option>
<option value="league">League</option>
<option value="beige">Beige</option>
<option value="sky">Sky</option>
<option value="night">Night</option>
<option value="serif">Serif</option>
<option value="simple">Simple</option>
<option value="solarized">Solarized</option>
<option value="blood">Blood</option>
<option value="moon">Moon</option>
</select>
</div>
<div class="form-row">
<label for="reveal-transition">Transition:</label>
<select id="reveal-transition">
<option value="slide">Slide</option>
<option value="none">None</option>
<option value="fade">Fade</option>
<option value="convex">Convex</option>
<option value="concave">Concave</option>
<option value="zoom">Zoom</option>
</select>
</div>
<div class="form-row">
<label for="reveal-speed">Transition Speed:</label>
<select id="reveal-speed">
<option value="default">Default</option>
<option value="fast">Fast</option>
<option value="slow">Slow</option>
</select>
</div>
<div class="checkbox-group">
<label><input type="checkbox" id="reveal-slide-number"> Slide Numbers</label>
<label><input type="checkbox" id="reveal-controls" checked> Controls</label>
<label><input type="checkbox" id="reveal-progress" checked> Progress Bar</label>
<label><input type="checkbox" id="reveal-history" checked> Slide History</label>
<label><input type="checkbox" id="reveal-center" checked> Center Content</label>
</div>
</div>
<div class="export-section"> <div class="export-section">
<label>Bibliography:</label> <label>Bibliography:</label>
<div class="form-row"> <div class="form-row">
@@ -905,6 +951,7 @@
<button class="modal-close" id="pdf-editor-dialog-close" aria-label="Close">&times;</button> <button class="modal-close" id="pdf-editor-dialog-close" aria-label="Close">&times;</button>
</div> </div>
<div class="modal-body pdf-editor-body"> <div class="modal-body pdf-editor-body">
<div class="pdf-editor-left-panel">
<!-- Merge PDFs Section --> <!-- Merge PDFs Section -->
<div id="pdf-merge-section" class="pdf-operation-section hidden"> <div id="pdf-merge-section" class="pdf-operation-section hidden">
<div class="export-section"> <div class="export-section">
@@ -1287,6 +1334,16 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Right Panel: Scrollable Visual Page Sidebar -->
<div id="pdf-thumbnail-sidebar" class="pdf-editor-right-panel hidden">
<h4>Page Preview &amp; Editor</h4>
<p class="sidebar-help">Select a PDF to view pages, check to delete, click ↻ to rotate, and use ◀ / ▶ to reorder.</p>
<div id="pdf-thumbnail-grid" class="pdf-thumbnail-grid">
<!-- Page thumbnail cards will be rendered here dynamically -->
</div>
</div>
</div>
<div class="modal-footer"> <div class="modal-footer">
<button id="pdf-editor-cancel" class="btn btn-secondary" data-close>Cancel</button> <button id="pdf-editor-cancel" class="btn btn-secondary" data-close>Cancel</button>
<button id="pdf-editor-process" class="btn btn-primary">Process</button> <button id="pdf-editor-process" class="btn btn-primary">Process</button>
+113 -4
View File
@@ -242,7 +242,9 @@ function runPandoc(args, callback) {
function runPandocCmd(cmdString, callback) { function runPandocCmd(cmdString, callback) {
const parsed = parseCommand(cmdString); const parsed = parseCommand(cmdString);
// Skip 'pandoc' if it's the first element (command itself) // Skip 'pandoc' if it's the first element (command itself)
const args = parsed.command === 'pandoc' ? parsed.args : [parsed.command, ...parsed.args]; // Use path.basename to handle full paths like bin/linux/pandoc
const cmdBase = path.basename(parsed.command).replace(/\.exe$/i, '');
const args = cmdBase === 'pandoc' ? parsed.args : [parsed.command, ...parsed.args];
const pandocPath = getPandocPath(); const pandocPath = getPandocPath();
execFile(pandocPath, args, { maxBuffer: 10 * 1024 * 1024 }, callback); execFile(pandocPath, args, { maxBuffer: 10 * 1024 * 1024 }, callback);
} }
@@ -849,6 +851,20 @@ function createMenu() {
} }
}, },
{ type: 'separator' }, { type: 'separator' },
{
label: 'Custom Preview CSS',
submenu: [
{
label: 'Load Custom Preview CSS...',
click: () => mainWindow.webContents.send('load-custom-css')
},
{
label: 'Clear Custom Preview CSS',
click: () => mainWindow.webContents.send('clear-custom-css')
}
]
},
{ type: 'separator' },
{ label: 'Reload', accelerator: 'CmdOrCtrl+R', role: 'reload' }, { label: 'Reload', accelerator: 'CmdOrCtrl+R', role: 'reload' },
{ label: 'Toggle DevTools', accelerator: 'F12', role: 'toggleDevTools' }, { label: 'Toggle DevTools', accelerator: 'F12', role: 'toggleDevTools' },
{ type: 'separator' }, { type: 'separator' },
@@ -2297,8 +2313,32 @@ function performExportWithOptions(format, options) {
dialog.showErrorBox('Export Error', sanitizeErrorMessage(`Failed to export: ${err.message}`)); dialog.showErrorBox('Export Error', sanitizeErrorMessage(`Failed to export: ${err.message}`));
} }
} else if (format === 'revealjs') { } else if (format === 'revealjs') {
pandocCmd = `${getPandocPath()} "${currentFile}" -t revealjs -s -o "${outputFile}" --slide-level=2`; let revealCmd = `${getPandocPath()} "${currentFile}" -t revealjs -s -o "${outputFile}" --slide-level=2`;
exportWithPandoc(pandocCmd, outputFile, format); if (options) {
if (options.revealTheme) revealCmd += ` -V theme="${options.revealTheme}"`;
if (options.revealTransition) revealCmd += ` -V transition="${options.revealTransition}"`;
if (options.revealTransitionSpeed) revealCmd += ` -V transitionSpeed="${options.revealTransitionSpeed}"`;
if (options.revealControls !== undefined) revealCmd += ` -V controls="${options.revealControls}"`;
if (options.revealSlideNumber !== undefined) revealCmd += ` -V slideNumber="${options.revealSlideNumber}"`;
if (options.revealProgress !== undefined) revealCmd += ` -V progress="${options.revealProgress}"`;
if (options.revealHistory !== undefined) revealCmd += ` -V history="${options.revealHistory}"`;
if (options.revealCenter !== undefined) revealCmd += ` -V center="${options.revealCenter}"`;
// Support for templates, metadata, bibliography
if (options.template && options.template !== 'default') {
revealCmd += ` --template="${options.template}"`;
}
if (options.metadata) {
for (const [key, value] of Object.entries(options.metadata)) {
if (value.trim()) {
revealCmd += ` -M ${key}="${value.replace(/"/g, '\\"')}"`;
}
}
}
if (options.bibliography) revealCmd += ` --bibliography="${options.bibliography}"`;
if (options.csl) revealCmd += ` --csl="${options.csl}"`;
}
exportWithPandoc(revealCmd, outputFile, format);
} else if (format === 'beamer') { } else if (format === 'beamer') {
pandocCmd = `${getPandocPath()} "${currentFile}" -t beamer -o "${outputFile}"`; pandocCmd = `${getPandocPath()} "${currentFile}" -t beamer -o "${outputFile}"`;
exportWithPandoc(pandocCmd, outputFile, format); exportWithPandoc(pandocCmd, outputFile, format);
@@ -2999,6 +3039,55 @@ ipcMain.on('select-folder', (event, type) => {
} }
}); });
// List directory contents as FileEntry[]
ipcMain.handle('list-directory', async (event, dirPath) => {
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
return entries.map((dirent) => {
const fullPath = path.join(dirPath, dirent.name);
let size;
let modifiedAt;
if (!dirent.isDirectory()) {
try {
const stat = fs.statSync(fullPath);
size = stat.size;
modifiedAt = stat.mtime.toISOString();
} catch {
// ignore stat errors
}
}
return {
name: dirent.name,
path: fullPath,
isDirectory: dirent.isDirectory(),
size,
modifiedAt,
};
});
} catch (err) {
return { error: err.message };
}
});
// Show folder picker dialog and return selected path or null
ipcMain.handle('pick-folder', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory'],
});
if (result.canceled || result.filePaths.length === 0) return null;
return result.filePaths[0];
});
// Show file picker dialog for markdown files and return selected path or null
ipcMain.handle('pick-file', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile'],
filters: [{ name: 'Markdown', extensions: ['md', 'markdown', 'mdown', 'mkd'] }],
});
if (result.canceled || result.filePaths.length === 0) return null;
return result.filePaths[0];
});
ipcMain.on('export-spreadsheet', (event, { content, format }) => { ipcMain.on('export-spreadsheet', (event, { content, format }) => {
const outputFile = dialog.showSaveDialogSync(mainWindow, { const outputFile = dialog.showSaveDialogSync(mainWindow, {
defaultPath: currentFile.replace(/\.[^/.]+$/, `.${format}`), defaultPath: currentFile.replace(/\.[^/.]+$/, `.${format}`),
@@ -3759,7 +3848,7 @@ app.on('open-file', (event, filePath) => {
// Handle file opening from command line or file association // Handle file opening from command line or file association
function openFileFromPath(filePath) { function openFileFromPath(filePath) {
console.log('[MAIN] openFileFromPath called with:', filePath); console.log('[MAIN] openFileFromPath called with:', filePath);
console.log('[MAIN] rendererReady:', rendererReady, 'mainWindow exists:', !!mainWindow); console.log('[MAIN] rendererReady:', rendererReady, 'mainWindow exists:', !!mainWindow, 'webContents exists:', !!(mainWindow?.webContents));
if (fs.existsSync(filePath)) { if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath); const stats = fs.statSync(filePath);
if (stats.size > MAX_FILE_SIZE) { if (stats.size > MAX_FILE_SIZE) {
@@ -3769,6 +3858,7 @@ function openFileFromPath(filePath) {
currentFile = filePath; currentFile = filePath;
const content = fs.readFileSync(filePath, 'utf-8'); const content = fs.readFileSync(filePath, 'utf-8');
console.log('[MAIN] File read successfully, content length:', content.length); console.log('[MAIN] File read successfully, content length:', content.length);
console.log('[MAIN] About to send file-opened. mainWindow:', !!mainWindow, 'webContents:', !!(mainWindow?.webContents), 'rendererReady:', rendererReady);
if (mainWindow && mainWindow.webContents && rendererReady) { if (mainWindow && mainWindow.webContents && rendererReady) {
// Send file immediately - renderer-ready means UI is initialized // Send file immediately - renderer-ready means UI is initialized
console.log('[MAIN] Sending file-opened to renderer'); console.log('[MAIN] Sending file-opened to renderer');
@@ -3990,6 +4080,25 @@ ipcMain.handle('list-directory', async (event, dirPath) => {
} }
}); });
ipcMain.handle('select-custom-css', async (event) => {
const result = dialog.showOpenDialogSync(mainWindow, {
title: 'Select Custom Preview CSS',
properties: ['openFile'],
filters: [
{ name: 'CSS Stylesheets', extensions: ['css'] }
]
});
if (result && result[0]) {
const filePath = result[0];
const content = fs.readFileSync(filePath, 'utf-8');
return { path: filePath, content };
}
return null;
});
ipcMain.handle('read-file', async (event, filePath) => { ipcMain.handle('read-file', async (event, filePath) => {
const validation = validatePath(filePath); const validation = validatePath(filePath);
if (!validation.valid || !isPathAccessible(validation.resolved)) { if (!validation.valid || !isPathAccessible(validation.resolved)) {
+2 -2
View File
@@ -60,13 +60,13 @@ async function pdfSplit(data) {
const pdf = await PDFDocument.load(pdfBytes); const pdf = await PDFDocument.load(pdfBytes);
const totalPages = pdf.getPageCount(); const totalPages = pdf.getPageCount();
let splits = []; const splits = [];
if (data.splitMode === 'pages') { if (data.splitMode === 'pages') {
const ranges = data.pageRanges.split(',').map(r => r.trim()); const ranges = data.pageRanges.split(',').map(r => r.trim());
for (let i = 0; i < ranges.length; i++) { for (let i = 0; i < ranges.length; i++) {
const range = ranges[i]; const range = ranges[i];
let pages = []; const pages = [];
if (range.includes('-')) { if (range.includes('-')) {
const [start, end] = range.split('-').map(n => parseInt(n.trim())); const [start, end] = range.split('-').map(n => parseInt(n.trim()));
+13 -2
View File
@@ -23,6 +23,7 @@ const ALLOWED_SEND_CHANNELS = [
'save-recent-files', 'save-recent-files',
'clear-recent-files', 'clear-recent-files',
'renderer-ready', 'renderer-ready',
'select-custom-css',
// Theme // Theme
'get-theme', 'get-theme',
@@ -150,6 +151,8 @@ const ALLOWED_RECEIVE_CHANNELS = [
'get-content-for-save', 'get-content-for-save',
'get-content-for-spreadsheet', 'get-content-for-spreadsheet',
'recent-files-cleared', 'recent-files-cleared',
'load-custom-css',
'clear-custom-css',
// UI toggles // UI toggles
'toggle-preview', 'toggle-preview',
@@ -234,7 +237,12 @@ const ALLOWED_RECEIVE_CHANNELS = [
'load-template-menu', 'load-template-menu',
'toggle-command-palette', 'toggle-command-palette',
'toggle-sidebar-panel', 'toggle-sidebar-panel',
'toggle-bottom-panel' 'toggle-bottom-panel',
// File dialog / directory listing
'list-directory',
'pick-folder',
'pick-file'
]; ];
/** /**
@@ -341,7 +349,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
exists: (filePath) => ipcRenderer.invoke('path-exists', filePath), exists: (filePath) => ipcRenderer.invoke('path-exists', filePath),
isDirectory: (filePath) => ipcRenderer.invoke('is-directory', filePath), isDirectory: (filePath) => ipcRenderer.invoke('is-directory', filePath),
copy: (source, destination) => ipcRenderer.invoke('copy-path', { source, destination }), copy: (source, destination) => ipcRenderer.invoke('copy-path', { source, destination }),
move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination }) move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination }),
list: (dirPath) => ipcRenderer.invoke('list-directory', dirPath),
pickFolder: () => ipcRenderer.invoke('pick-folder'),
pickFile: () => ipcRenderer.invoke('pick-file')
}, },
// Theme Operations // Theme Operations
+518 -62
View File
@@ -1,9 +1,20 @@
/** /**
* MarkdownConverter Renderer Process * MarkdownConverter Renderer Process
* @version 4.4.1 * @version 4.4.2
*/ */
const { ipcRenderer } = require('electron'); const { ipcRenderer } = require('electron');
// Shim window.electronAPI for main window which uses nodeIntegration
// without preload script (window.electronAPI is normally set by preload.js).
if (typeof window !== 'undefined' && !window.electronAPI) {
window.electronAPI = {
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
invoke: (channel, data) => ipcRenderer.invoke(channel, data),
on: (channel, callback) => ipcRenderer.on(channel, (event, ...args) => callback(...args))
};
}
const marked = require('marked'); const marked = require('marked');
const { markedHighlight } = require('marked-highlight'); const { markedHighlight } = require('marked-highlight');
const createDOMPurify = require('dompurify'); const createDOMPurify = require('dompurify');
@@ -11,7 +22,13 @@ const DOMPurify = createDOMPurify(window);
const hljs = require('highlight.js'); const hljs = require('highlight.js');
const { createEditor } = require('./editor/codemirror-setup'); const { createEditor } = require('./editor/codemirror-setup');
const { undo, redo } = require('@codemirror/commands'); const { undo, redo } = require('@codemirror/commands');
const { ModalManager } = require('./utils/ModalManager'); // ModalManager is loaded via <script src="utils/ModalManager.js"> in index.html.
// It already exists in the global scope — re-declaring with let/const causes
// SyntaxError: "Identifier 'ModalManager' has already been declared".
if (typeof ModalManager === 'undefined') {
const result = require('./utils/ModalManager');
ModalManager = result.ModalManager || result;
}
// Lazy-loaded modules — defer heavy imports until first use // Lazy-loaded modules — defer heavy imports until first use
let _SidebarManager, _renderTemplatesPanel, _renderExplorerPanel, _renderGitPanel, _renderSnippetsPanel; let _SidebarManager, _renderTemplatesPanel, _renderExplorerPanel, _renderGitPanel, _renderSnippetsPanel;
let _ReplPanel, _CommandPalette, _PrintPreview, _createWelcomeContent; let _ReplPanel, _CommandPalette, _PrintPreview, _createWelcomeContent;
@@ -102,19 +119,24 @@ marked.use({
start(src) { return src.match(/^:::(note|warning|tip|danger|info)/m)?.index; }, start(src) { return src.match(/^:::(note|warning|tip|danger|info)/m)?.index; },
tokenizer(src) { tokenizer(src) {
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m); const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
if (match) { if (match && match.index === 0) {
const admonitionType = match[1];
const text = match[2].trim();
const tokens = [];
this.lexer.blockTokens(text, tokens);
return { return {
type: 'admonition', type: 'admonition',
raw: match[0], raw: match[0],
admonitionType: match[1], admonitionType,
text: match[2].trim() text,
tokens
}; };
} }
}, },
renderer(token) { renderer(token) {
const icons = { note: '\u2139', warning: '\u26A0', tip: '\uD83D\uDCA1', danger: '\uD83D\uDD34', info: '\u2139' }; const icons = { note: '\u2139', warning: '\u26A0', tip: '\uD83D\uDCA1', danger: '\uD83D\uDD34', info: '\u2139' };
const icon = icons[token.admonitionType] || '\u2139'; const icon = icons[token.admonitionType] || '\u2139';
const inner = this.parser.parse(token.text); const inner = this.parser.parse(token.tokens || []);
return `<div class="admonition admonition-${token.admonitionType}"> return `<div class="admonition admonition-${token.admonitionType}">
<div class="admonition-title">${icon} ${token.admonitionType.charAt(0).toUpperCase() + token.admonitionType.slice(1)}</div> <div class="admonition-title">${icon} ${token.admonitionType.charAt(0).toUpperCase() + token.admonitionType.slice(1)}</div>
<div class="admonition-content">${inner}</div> <div class="admonition-content">${inner}</div>
@@ -131,8 +153,20 @@ function plantumlEncode(text) {
return '~h' + hex; return '~h' + hex;
} }
// Scopes user custom CSS by prefixing all selectors to protect application UI
function scopeCSS(cssText, scopeSelector) {
if (!cssText) return '';
return cssText.replace(/([^\r\n,{}]+)(,(?=[^}]*{)|(?=[^{]*{))/g, (match, selector, separator) => {
const trimmed = selector.trim();
if (!trimmed || trimmed.startsWith('@') || trimmed.startsWith(':root') || trimmed.startsWith('from') || trimmed.startsWith('to') || /^\d+%$/.test(trimmed)) {
return match;
}
return scopeSelector + ' ' + trimmed + (separator || '');
});
}
// Module-level reference set by DOMContentLoaded after sidebar registration // Module-level reference set by DOMContentLoaded after sidebar registration
let outlinePanelContainer = null; const outlinePanelContainer = null;
// Tab Management // Tab Management
class TabManager { class TabManager {
@@ -147,6 +181,7 @@ class TabManager {
this.recentFiles = JSON.parse(localStorage.getItem('recentFiles') || '[]'); this.recentFiles = JSON.parse(localStorage.getItem('recentFiles') || '[]');
this.previewDebounceTimers = new Map(); // Debounce timers per tab this.previewDebounceTimers = new Map(); // Debounce timers per tab
this.previewDebounceDelay = 300; // 300ms debounce this.previewDebounceDelay = 300; // 300ms debounce
this.previewCache = new Map(); // Performance optimization render cache
// Initialize first tab // Initialize first tab
this.tabs.set(1, { this.tabs.set(1, {
@@ -230,7 +265,7 @@ class TabManager {
editorView: null, editorView: null,
findMatches: [], findMatches: [],
currentMatchIndex: -1, currentMatchIndex: -1,
type: type, type: type, // 'markdown' or 'pdf'
// PDF-specific state // PDF-specific state
pdfDoc: null, pdfDoc: null,
pdfCurrentPage: 1, pdfCurrentPage: 1,
@@ -243,6 +278,7 @@ class TabManager {
this.switchToTab(newTabId); this.switchToTab(newTabId);
this.startAutoSave(); this.startAutoSave();
this.updateTabBar(); this.updateTabBar();
console.log('createNewTab: tab created with type:', type);
} }
createPdfTab(filePath) { createPdfTab(filePath) {
@@ -454,27 +490,7 @@ class TabManager {
document.querySelector('.editor-container').appendChild(tabContent); document.querySelector('.editor-container').appendChild(tabContent);
// Initialize CodeMirror editor // Initialize CodeMirror editor
const editorContainer = document.getElementById(`editor-cm-${tab.id}`); this.ensureEditor(tab);
if (editorContainer) {
const isDark = document.body.className.includes('dark');
tab.editorView = createEditor(editorContainer, {
content: tab.content,
onChange: (newContent) => {
tab.content = newContent;
tab.isDirty = true;
this.updatePreview(tab.id);
this.updateWordCount();
this.updateTabBar();
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
},
onUpdate: (view) => {
this.updateCursorPosition(view);
if (outlinePanelContainer?._setActiveHeading) outlinePanelContainer._setActiveHeading(view.state.doc.lineAt(view.state.selection.main.head).number);
},
isDark,
showLineNumbers: this.showLineNumbers,
});
}
} }
switchToTab(tabId) { switchToTab(tabId) {
@@ -660,6 +676,11 @@ class TabManager {
const tab = this.tabs.get(tabId); const tab = this.tabs.get(tabId);
if (!tab || tab.type === 'pdf') return; if (!tab || tab.type === 'pdf') return;
// Guard against auto-renders in large file mode
if (tab.largeFileMode && !immediate) {
return;
}
// Clear existing debounce timer for this tab // Clear existing debounce timer for this tab
if (this.previewDebounceTimers.has(tabId)) { if (this.previewDebounceTimers.has(tabId)) {
clearTimeout(this.previewDebounceTimers.get(tabId)); clearTimeout(this.previewDebounceTimers.get(tabId));
@@ -678,20 +699,55 @@ class TabManager {
}, this.previewDebounceDelay)); }, this.previewDebounceDelay));
} }
_hash(str) {
if (!str) return 0;
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0; // Convert to 32bit integer
}
return hash;
}
_renderPreview(tabId) { _renderPreview(tabId) {
const tab = this.tabs.get(tabId); const tab = this.tabs.get(tabId);
const preview = document.getElementById(`preview-${tabId}`); const preview = document.getElementById(`preview-${tabId}`);
console.log('[_renderPreview] tabId:', tabId, 'tab exists:', !!tab, 'preview exists:', !!preview, 'content length:', tab?.content?.length);
if (!tab || !preview) return; if (!tab || !preview) return;
try { try {
// Check if libraries are available // Check if libraries are available
if (!marked || !DOMPurify) { if (!marked || !DOMPurify) {
console.error('[_renderPreview] Libraries not available:', { marked: !!marked, DOMPurify: !!DOMPurify });
preview.innerHTML = '<div class="preview-error"><div class="preview-error-icon">⚠️</div><div class="preview-error-title">Libraries Not Loaded</div><div class="preview-error-message">Required libraries (marked/DOMPurify) could not be loaded. Please check your installation.</div></div>'; preview.innerHTML = '<div class="preview-error"><div class="preview-error-icon">⚠️</div><div class="preview-error-title">Libraries Not Loaded</div><div class="preview-error-message">Required libraries (marked/DOMPurify) could not be loaded. Please check your installation.</div></div>';
return; return;
} }
// Cache lookup
const contentHash = this._hash(tab.content);
let sanitizedHtml;
if (this.previewCache.has(contentHash)) {
console.log('[_renderPreview] Cache hit for hash:', contentHash);
sanitizedHtml = this.previewCache.get(contentHash);
} else {
console.log('[_renderPreview] Cache miss. About to call marked.parse, content length:', tab.content.length);
const html = marked.parse(tab.content); const html = marked.parse(tab.content);
let sanitizedHtml = DOMPurify.sanitize(html); console.log('[_renderPreview] marked.parse returned type:', typeof html, 'isPromise:', html instanceof Promise);
if (html instanceof Promise) {
console.error('[_renderPreview] marked.parse returned a Promise! Need to await it.');
return;
}
sanitizedHtml = DOMPurify.sanitize(html);
console.log('[_renderPreview] DOMPurify.sanitize returned, length:', sanitizedHtml.length);
// Add to cache and maintain cache size limit (100 entries)
this.previewCache.set(contentHash, sanitizedHtml);
if (this.previewCache.size > 100) {
const oldestKey = this.previewCache.keys().next().value;
this.previewCache.delete(oldestKey);
}
}
// TOC generation // TOC generation
if (sanitizedHtml.includes('[[toc]]') || sanitizedHtml.includes('[TOC]')) { if (sanitizedHtml.includes('[[toc]]') || sanitizedHtml.includes('[TOC]')) {
@@ -1327,10 +1383,22 @@ class TabManager {
} }
} }
checkForLargeFile(tab, content) {
if (content && content.length > 1024 * 1024) { // >1MB
tab.largeFileMode = true;
this.isPreviewVisible = false;
this.updatePreviewVisibility();
notifyUser('Large file detected (>1MB). Large File Mode enabled to maintain peak responsiveness. Live preview auto-render is disabled.', 'warning');
} else {
tab.largeFileMode = false;
}
}
// File operations // File operations
openFile(filePath, content) { openFile(filePath, content) {
console.log('openFile called with:', filePath, 'content length:', content.length); console.log('openFile called with:', filePath, 'content length:', content.length);
let tab = this.tabs.get(this.activeTabId); let tab = this.tabs.get(this.activeTabId);
console.log('openFile tab state before:', { id: tab?.id, hasEditorView: !!tab?.editorView, contentLength: tab?.content?.length, filePath: tab?.filePath, isDirty: tab?.isDirty });
// Handle both forward and back slashes for cross-platform compatibility // Handle both forward and back slashes for cross-platform compatibility
const fileName = filePath.split(/[\\/]/).pop(); const fileName = filePath.split(/[\\/]/).pop();
@@ -1344,24 +1412,31 @@ class TabManager {
tab.originalContent = content; tab.originalContent = content;
tab.isDirty = false; tab.isDirty = false;
this.checkForLargeFile(tab, content);
// Update the editor and preview // Update the editor and preview
this.setEditorContent(this.activeTabId, content); this.setEditorContent(this.activeTabId, content);
this.updatePreview(this.activeTabId); console.log('openFile after setEditorContent, tab state:', { id: tab.id, hasEditorView: !!tab.editorView, contentLength: tab.content.length });
this.updatePreview(this.activeTabId, true); // immediate=true for initial load
this.updateWordCount(); this.updateWordCount();
} else { } else {
// Create new tab for the file // Create new tab for the file
console.log('Creating new tab for file'); console.log('Creating new tab for file');
this.createNewTab(); this.createNewTab();
tab = this.tabs.get(this.activeTabId); tab = this.tabs.get(this.activeTabId);
console.log('openFile after createNewTab, tab:', { id: tab.id, hasEditorView: !!tab.editorView });
tab.filePath = filePath; tab.filePath = filePath;
tab.title = fileName; tab.title = fileName;
tab.content = content; tab.content = content;
tab.originalContent = content; tab.originalContent = content;
tab.isDirty = false; tab.isDirty = false;
this.checkForLargeFile(tab, content);
// Set content in the CodeMirror editor // Set content in the CodeMirror editor
this.setEditorContent(this.activeTabId, content); this.setEditorContent(this.activeTabId, content);
this.updatePreview(this.activeTabId); console.log('openFile after setEditorContent, tab state:', { id: tab.id, hasEditorView: !!tab.editorView, contentLength: tab.content.length });
this.updatePreview(this.activeTabId, true); // immediate=true for initial load
this.updateWordCount(); this.updateWordCount();
} }
this.startAutoSave(); this.startAutoSave();
@@ -1369,6 +1444,7 @@ class TabManager {
this.updateTabBar(); this.updateTabBar();
this.updateFilePath(); this.updateFilePath();
this.updateBreadcrumb(); this.updateBreadcrumb();
this.updateUI(); // Ensure tab content is visible
// Notify main process about current file for exports // Notify main process about current file for exports
ipcRenderer.send('set-current-file', filePath); ipcRenderer.send('set-current-file', filePath);
@@ -1385,9 +1461,47 @@ class TabManager {
return tab?.content || ''; return tab?.content || '';
} }
// Ensure a tab has a CodeMirror editor (lazy creation fallback)
ensureEditor(tab) {
if (tab.editorView) return true;
const editorContainer = document.getElementById(`editor-cm-${tab.id}`);
if (!editorContainer) {
console.error(`[ensureEditor] editor-cm-${tab.id} not found in DOM`);
return false;
}
try {
const isDark = document.body.className.includes('dark');
tab.editorView = createEditor(editorContainer, {
content: tab.content,
onChange: (newContent) => {
tab.content = newContent;
tab.isDirty = true;
this.updatePreview(tab.id);
this.updateWordCount();
this.updateTabBar();
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
},
onUpdate: (view) => {
this.updateCursorPosition(view);
if (outlinePanelContainer?._setActiveHeading) outlinePanelContainer._setActiveHeading(view.state.doc.lineAt(view.state.selection.main.head).number);
},
isDark,
showLineNumbers: this.showLineNumbers,
});
console.log(`[ensureEditor] Created editor for tab ${tab.id}`);
return true;
} catch (err) {
console.error(`[ensureEditor] Failed to create editor for tab ${tab.id}:`, err);
return false;
}
}
// Set content in editor // Set content in editor
setEditorContent(tabId, content) { setEditorContent(tabId, content) {
const tab = this.tabs.get(tabId); const tab = this.tabs.get(tabId);
if (!tab?.editorView) {
this.ensureEditor(tab);
}
if (tab?.editorView) { if (tab?.editorView) {
tab.editorView.dispatch({ tab.editorView.dispatch({
changes: { from: 0, to: tab.editorView.state.doc.length, insert: content } changes: { from: 0, to: tab.editorView.state.doc.length, insert: content }
@@ -1445,6 +1559,13 @@ let replPanel;
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
tabManager = new TabManager(); tabManager = new TabManager();
// Load saved Custom Preview CSS if present
const savedCSSContent = localStorage.getItem('customPreviewCSSContent');
if (savedCSSContent) {
applyCustomPreviewCSS(savedCSSContent);
}
const ReplPanel = getReplPanel(); const ReplPanel = getReplPanel();
replPanel = new ReplPanel(); replPanel = new ReplPanel();
@@ -1483,7 +1604,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// Initialize sidebar // Initialize sidebar
const SidebarManager = getSidebarManager(); const SidebarManager = getSidebarManager();
const sidebarManager = new SidebarManager(); const sidebarManager = new SidebarManager();
let explorerCurrentDir = null; const explorerCurrentDir = null;
sidebarManager.registerPanel('explorer', { sidebarManager.registerPanel('explorer', {
title: 'Explorer', title: 'Explorer',
@@ -1624,7 +1745,8 @@ document.addEventListener('DOMContentLoaded', async () => {
const welcomeHtml = getCreateWelcomeContent()(recentFiles, appVersion); const welcomeHtml = getCreateWelcomeContent()(recentFiles, appVersion);
const tab = tabManager.tabs.get(tabManager.activeTabId); const tab = tabManager.tabs.get(tabManager.activeTabId);
if (tab) { // Only show welcome if no file was opened while we were awaiting
if (tab && !tab.filePath && tab.content === '') {
tab.title = 'Welcome'; tab.title = 'Welcome';
tab.content = ''; tab.content = '';
const preview = document.getElementById(`preview-${tab.id}`); const preview = document.getElementById(`preview-${tab.id}`);
@@ -1766,6 +1888,8 @@ document.addEventListener('DOMContentLoaded', async () => {
commandPalette.register('Insert Image', '', () => tabManager.wrapSelection('![', '](image.jpg)')); commandPalette.register('Insert Image', '', () => tabManager.wrapSelection('![', '](image.jpg)'));
commandPalette.register('Toggle Zen Mode', 'F11', () => zenMode.toggle()); commandPalette.register('Toggle Zen Mode', 'F11', () => zenMode.toggle());
commandPalette.register('Writing Analytics', 'Ctrl+Shift+A', () => getShowAnalyticsModal()(tabManager)); commandPalette.register('Writing Analytics', 'Ctrl+Shift+A', () => getShowAnalyticsModal()(tabManager));
commandPalette.register('Load Custom Preview CSS', '', triggerLoadCustomCSS);
commandPalette.register('Clear Custom Preview CSS', '', triggerClearCustomCSS);
// Keyboard shortcuts // Keyboard shortcuts
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
@@ -1791,27 +1915,9 @@ document.addEventListener('DOMContentLoaded', async () => {
}); });
// Initialize CodeMirror for the initial tab (tab 1) // Initialize CodeMirror for the initial tab (tab 1)
const initialEditorContainer = document.getElementById('editor-cm-1'); const initialTab = tabManager.tabs.get(1);
if (initialEditorContainer) { if (initialTab) {
const tab = tabManager.tabs.get(1); tabManager.ensureEditor(initialTab);
const isDark = document.body.className.includes('dark');
tab.editorView = createEditor(initialEditorContainer, {
content: tab.content,
onChange: (newContent) => {
tab.content = newContent;
tab.isDirty = true;
tabManager.updatePreview(tab.id);
tabManager.updateWordCount();
tabManager.updateTabBar();
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
},
onUpdate: (view) => {
tabManager.updateCursorPosition(view);
if (outlinePanelContainer?._setActiveHeading) outlinePanelContainer._setActiveHeading(view.state.doc.lineAt(view.state.selection.main.head).number);
},
isDark,
showLineNumbers: tabManager.showLineNumbers,
});
} }
// Request current theme // Request current theme
@@ -1835,6 +1941,7 @@ document.addEventListener('DOMContentLoaded', async () => {
}, 30000); }, 30000);
}); });
// IPC event listeners
// IPC event listeners // IPC event listeners
ipcRenderer.on('file-new', () => { ipcRenderer.on('file-new', () => {
tabManager.createNewTab(); tabManager.createNewTab();
@@ -1842,8 +1949,20 @@ ipcRenderer.on('file-new', () => {
ipcRenderer.on('file-opened', (event, data) => { ipcRenderer.on('file-opened', (event, data) => {
console.log('[RENDERER] file-opened received:', data.path, 'content length:', data.content.length); console.log('[RENDERER] file-opened received:', data.path, 'content length:', data.content.length);
console.log('[RENDERER] activeTabId before openFile:', tabManager.activeTabId);
// Diagnostic: check library state
console.log('[RENDERER] Library state:', {
marked: typeof marked,
DOMPurify: typeof DOMPurify,
markedParse: typeof marked?.parse,
dompurifySanitize: typeof DOMPurify?.sanitize,
tabManagerExists: !!tabManager
});
if (tabManager) { if (tabManager) {
tabManager.openFile(data.path, data.content); tabManager.openFile(data.path, data.content);
console.log('[RENDERER] activeTabId after openFile:', tabManager.activeTabId);
const tab = tabManager.tabs.get(tabManager.activeTabId);
console.log('[RENDERER] tab state:', { id: tab?.id, hasEditorView: !!tab?.editorView, contentLength: tab?.content?.length });
} else { } else {
console.error('[RENDERER] tabManager not initialized!'); console.error('[RENDERER] tabManager not initialized!');
} }
@@ -1922,6 +2041,49 @@ ipcRenderer.on('redo', () => {
} }
}); });
// Custom Preview CSS event handlers and trigger helpers
function applyCustomPreviewCSS(cssContent) {
let styleTag = document.getElementById('custom-preview-style');
if (!styleTag) {
styleTag = document.createElement('style');
styleTag.id = 'custom-preview-style';
document.head.appendChild(styleTag);
}
const scopedCSS = scopeCSS(cssContent, '.preview-content');
styleTag.textContent = scopedCSS;
}
async function triggerLoadCustomCSS() {
try {
const result = await window.electronAPI.invoke('select-custom-css');
if (result) {
console.log('[RENDERER] Custom CSS loaded from:', result.path);
localStorage.setItem('customPreviewCSSPath', result.path);
localStorage.setItem('customPreviewCSSContent', result.content);
applyCustomPreviewCSS(result.content);
notifyUser('Custom preview stylesheet applied successfully.', 'success');
}
} catch (err) {
console.error('[RENDERER] Error loading custom CSS:', err);
notifyUser('Failed to load custom CSS stylesheet.', 'error');
}
}
function triggerClearCustomCSS() {
localStorage.removeItem('customPreviewCSSPath');
localStorage.removeItem('customPreviewCSSContent');
const styleTag = document.getElementById('custom-preview-style');
if (styleTag) {
styleTag.remove();
}
notifyUser('Custom preview stylesheet cleared.', 'info');
}
ipcRenderer.on('load-custom-css', triggerLoadCustomCSS);
ipcRenderer.on('clear-custom-css', triggerClearCustomCSS);
// Font size adjustment // Font size adjustment
let currentFontSize = parseInt(localStorage.getItem('fontSize')) || 15; let currentFontSize = parseInt(localStorage.getItem('fontSize')) || 15;
@@ -2041,13 +2203,39 @@ function initializeExportForm(format) {
document.getElementById('export-citeproc').checked = false; document.getElementById('export-citeproc').checked = false;
document.getElementById('export-toc-depth').value = 3; document.getElementById('export-toc-depth').value = 3;
// PDF-specific fields // Toggle and reset PDF-specific fields
const pdfOnly = document.querySelector('.pdf-only');
if (pdfOnly) {
pdfOnly.style.display = (format === 'pdf') ? 'block' : 'none';
}
if (format === 'pdf') { if (format === 'pdf') {
document.getElementById('pdf-engine').value = 'xelatex'; document.getElementById('pdf-engine').value = 'xelatex';
document.getElementById('pdf-geometry').value = 'margin=1in'; document.getElementById('pdf-geometry').value = 'margin=1in';
document.getElementById('custom-geometry').style.display = 'none'; document.getElementById('custom-geometry').style.display = 'none';
} }
// Toggle and reset Reveal.js-specific fields
const revealjsOnly = document.querySelector('.revealjs-only');
if (revealjsOnly) {
revealjsOnly.style.display = (format === 'revealjs') ? 'block' : 'none';
}
const revealTheme = document.getElementById('reveal-theme');
if (revealTheme) revealTheme.value = 'black';
const revealTransition = document.getElementById('reveal-transition');
if (revealTransition) revealTransition.value = 'slide';
const revealSpeed = document.getElementById('reveal-speed');
if (revealSpeed) revealSpeed.value = 'default';
const revealSlideNumber = document.getElementById('reveal-slide-number');
if (revealSlideNumber) revealSlideNumber.checked = false;
const revealControls = document.getElementById('reveal-controls');
if (revealControls) revealControls.checked = true;
const revealProgress = document.getElementById('reveal-progress');
if (revealProgress) revealProgress.checked = true;
const revealHistory = document.getElementById('reveal-history');
if (revealHistory) revealHistory.checked = true;
const revealCenter = document.getElementById('reveal-center');
if (revealCenter) revealCenter.checked = true;
// Clear bibliography fields // Clear bibliography fields
document.getElementById('bibliography-file').value = ''; document.getElementById('bibliography-file').value = '';
document.getElementById('csl-file').value = ''; document.getElementById('csl-file').value = '';
@@ -2107,6 +2295,18 @@ function collectExportOptions() {
} }
} }
// Reveal.js-specific options
if (currentExportFormat === 'revealjs') {
options.revealTheme = document.getElementById('reveal-theme').value;
options.revealTransition = document.getElementById('reveal-transition').value;
options.revealTransitionSpeed = document.getElementById('reveal-speed').value;
options.revealSlideNumber = document.getElementById('reveal-slide-number').checked;
options.revealControls = document.getElementById('reveal-controls').checked;
options.revealProgress = document.getElementById('reveal-progress').checked;
options.revealHistory = document.getElementById('reveal-history').checked;
options.revealCenter = document.getElementById('reveal-center').checked;
}
// Bibliography // Bibliography
const bibFile = document.getElementById('bibliography-file').value.trim(); const bibFile = document.getElementById('bibliography-file').value.trim();
const cslFile = document.getElementById('csl-file').value.trim(); const cslFile = document.getElementById('csl-file').value.trim();
@@ -2117,6 +2317,15 @@ function collectExportOptions() {
if (currentExportFormat === 'pdf') { if (currentExportFormat === 'pdf') {
options.pdfEngine = 'xelatex'; options.pdfEngine = 'xelatex';
options.geometry = 'margin=1in'; options.geometry = 'margin=1in';
} else if (currentExportFormat === 'revealjs') {
options.revealTheme = 'black';
options.revealTransition = 'slide';
options.revealTransitionSpeed = 'default';
options.revealSlideNumber = false;
options.revealControls = true;
options.revealProgress = true;
options.revealHistory = true;
options.revealCenter = true;
} }
} }
@@ -3034,6 +3243,18 @@ function showPDFEditorDialog(operation, openedFilePath = null) {
section.classList.add('hidden'); section.classList.add('hidden');
}); });
// Hide visual thumbnail sidebar by default
const sidebar = document.getElementById('pdf-thumbnail-sidebar');
if (sidebar) {
sidebar.classList.add('hidden');
}
const body = document.querySelector('.pdf-editor-body');
if (body) {
body.classList.remove('side-by-side');
}
pdfDocInstance = null;
pdfPreviewState = { pages: [], filePath: '' };
// Show the appropriate section and set title // Show the appropriate section and set title
let sectionId, titleText; let sectionId, titleText;
switch (operation) { switch (operation) {
@@ -3067,7 +3288,11 @@ function showPDFEditorDialog(operation, openedFilePath = null) {
titleText = 'Rotate Pages'; titleText = 'Rotate Pages';
if (openedFilePath) { if (openedFilePath) {
const rotateInput = document.getElementById('rotate-input-path'); const rotateInput = document.getElementById('rotate-input-path');
if (rotateInput) rotateInput.value = openedFilePath; if (rotateInput) {
rotateInput.value = openedFilePath;
// Trigger thumbnail generation
setTimeout(() => onPDFFileSelected('rotate-input-path', openedFilePath), 50);
}
} }
break; break;
case 'delete': case 'delete':
@@ -3075,7 +3300,11 @@ function showPDFEditorDialog(operation, openedFilePath = null) {
titleText = 'Delete Pages'; titleText = 'Delete Pages';
if (openedFilePath) { if (openedFilePath) {
const deleteInput = document.getElementById('delete-input-path'); const deleteInput = document.getElementById('delete-input-path');
if (deleteInput) deleteInput.value = openedFilePath; if (deleteInput) {
deleteInput.value = openedFilePath;
// Trigger thumbnail generation
setTimeout(() => onPDFFileSelected('delete-input-path', openedFilePath), 50);
}
} }
break; break;
case 'reorder': case 'reorder':
@@ -3083,7 +3312,11 @@ function showPDFEditorDialog(operation, openedFilePath = null) {
titleText = 'Reorder Pages'; titleText = 'Reorder Pages';
if (openedFilePath) { if (openedFilePath) {
const reorderInput = document.getElementById('reorder-input-path'); const reorderInput = document.getElementById('reorder-input-path');
if (reorderInput) reorderInput.value = openedFilePath; if (reorderInput) {
reorderInput.value = openedFilePath;
// Trigger thumbnail generation
setTimeout(() => onPDFFileSelected('reorder-input-path', openedFilePath), 50);
}
} }
break; break;
case 'watermark': case 'watermark':
@@ -3244,6 +3477,7 @@ document.addEventListener('DOMContentLoaded', () => {
const file = e.target.files[0]; const file = e.target.files[0];
if (file) { if (file) {
document.getElementById(button.inputId).value = file.path; document.getElementById(button.inputId).value = file.path;
onPDFFileSelected(button.inputId, file.path);
} }
}; };
input.click(); input.click();
@@ -3253,6 +3487,7 @@ document.addEventListener('DOMContentLoaded', () => {
const file = e.target.files[0]; const file = e.target.files[0];
if (file) { if (file) {
document.getElementById(button.inputId).value = file.path; document.getElementById(button.inputId).value = file.path;
onPDFFileSelected(button.inputId, file.path);
} }
}; };
input.click(); input.click();
@@ -3397,7 +3632,7 @@ function showPDFValidationMessage(message, focusSelector = null) {
// Process PDF Operation // Process PDF Operation
function processPDFOperation() { function processPDFOperation() {
const operation = currentPDFOperation; const operation = currentPDFOperation;
let operationData = { operation }; const operationData = { operation };
try { try {
switch (operation) { switch (operation) {
@@ -4187,7 +4422,7 @@ function textToASCII(text, style) {
lines[i] = ''; lines[i] = '';
} }
for (let char of upperText) { for (const char of upperText) {
const charLines = font.chars[char] || font.chars[' ']; const charLines = font.chars[char] || font.chars[' '];
if (charLines) { if (charLines) {
for (let i = 0; i < font.height; i++) { for (let i = 0; i < font.height; i++) {
@@ -4225,7 +4460,7 @@ function createASCIIBox(text, style, padding) {
} }
// Content lines // Content lines
for (let line of lines) { for (const line of lines) {
const paddedLine = line.padEnd(maxLength, ' '); const paddedLine = line.padEnd(maxLength, ' ');
result += chars.v + ' '.repeat(padding) + paddedLine + ' '.repeat(padding) + chars.v + '\n'; result += chars.v + ' '.repeat(padding) + paddedLine + ' '.repeat(padding) + chars.v + '\n';
} }
@@ -4861,3 +5096,224 @@ ipcRenderer.on('show-video-tool', (event, tool) => {
{ duration: 5000 } { duration: 5000 }
); );
}); });
// ============================================
// PDF EDITOR VISUAL PAGE SIDEBAR HANDLERS
// ============================================
let pdfDocInstance = null;
let pdfPreviewState = { pages: [], filePath: '' };
function onPDFFileSelected(inputId, filePath) {
const eligibleInputs = ['rotate-input-path', 'delete-input-path', 'reorder-input-path'];
if (!eligibleInputs.includes(inputId)) {
return;
}
const sidebar = document.getElementById('pdf-thumbnail-sidebar');
if (sidebar) {
sidebar.classList.remove('hidden');
}
const body = document.querySelector('.pdf-editor-body');
if (body) {
body.classList.add('side-by-side');
}
loadPDFThumbnails(filePath);
}
async function loadPDFThumbnails(filePath) {
const grid = document.getElementById('pdf-thumbnail-grid');
if (!grid) return;
// Reset previous inputs
const rotateInput = document.getElementById('rotate-pages');
if (rotateInput) rotateInput.value = '';
const deleteInput = document.getElementById('delete-pages');
if (deleteInput) deleteInput.value = '';
const reorderInput = document.getElementById('reorder-pages');
if (reorderInput) reorderInput.value = '';
grid.innerHTML = '<div class="loading-thumbnails">Loading page thumbnails...</div>';
try {
const pdfjs = getPdfjsLib();
const loadingTask = pdfjs.getDocument(filePath);
pdfDocInstance = await loadingTask.promise;
pdfPreviewState.filePath = filePath;
pdfPreviewState.pages = [];
for (let i = 1; i <= pdfDocInstance.numPages; i++) {
pdfPreviewState.pages.push({
originalPageNum: i,
rotation: 0,
isDeleted: false
});
}
renderThumbnailGrid();
} catch (err) {
console.error('Error loading PDF thumbnails:', err);
grid.innerHTML = '<div class="thumbnail-error">Failed to load PDF thumbnails.</div>';
}
}
function renderThumbnailGrid() {
const grid = document.getElementById('pdf-thumbnail-grid');
if (!grid || !pdfDocInstance) return;
grid.innerHTML = '';
pdfPreviewState.pages.forEach((pageInfo, index) => {
const card = document.createElement('div');
card.className = 'pdf-thumbnail-card';
if (pageInfo.isDeleted) {
card.classList.add('marked-delete');
}
const heading = document.createElement('div');
heading.className = 'pdf-thumbnail-header';
heading.innerHTML = `<span class="badge">Page ${pageInfo.originalPageNum}</span>`;
const canvasContainer = document.createElement('div');
canvasContainer.className = 'canvas-container';
const canvas = document.createElement('canvas');
canvas.className = 'pdf-thumbnail-canvas';
if (pageInfo.rotation > 0) {
canvas.classList.add('rot' + pageInfo.rotation);
}
canvasContainer.appendChild(canvas);
// Render thumbnail canvas
renderThumbnail(pdfDocInstance, pageInfo.originalPageNum, canvas, pageInfo.rotation);
const controlsRow = document.createElement('div');
controlsRow.className = 'pdf-thumbnail-controls';
// Rotate Button
const rotateBtn = document.createElement('button');
rotateBtn.type = 'button';
rotateBtn.className = 'btn-rotate-thumbnail';
rotateBtn.innerHTML = '↻ Rotate 90°';
rotateBtn.addEventListener('click', () => {
pageInfo.rotation = (pageInfo.rotation + 90) % 360;
canvas.className = 'pdf-thumbnail-canvas';
if (pageInfo.rotation > 0) {
canvas.classList.add('rot' + pageInfo.rotation);
}
renderThumbnail(pdfDocInstance, pageInfo.originalPageNum, canvas, pageInfo.rotation);
syncRotateInput();
});
// Delete Checkbox
const deleteLabel = document.createElement('label');
deleteLabel.className = 'delete-thumbnail-checkbox';
const deleteChk = document.createElement('input');
deleteChk.type = 'checkbox';
deleteChk.checked = pageInfo.isDeleted;
deleteChk.addEventListener('change', (e) => {
pageInfo.isDeleted = e.target.checked;
card.classList.toggle('marked-delete', pageInfo.isDeleted);
syncDeleteInput();
});
deleteLabel.appendChild(deleteChk);
deleteLabel.appendChild(document.createTextNode(' Delete'));
// Reorder buttons
const reorderDiv = document.createElement('div');
reorderDiv.className = 'pdf-thumbnail-reorder';
const leftBtn = document.createElement('button');
leftBtn.type = 'button';
leftBtn.className = 'btn-reorder-left';
leftBtn.innerHTML = '◀';
leftBtn.disabled = (index === 0);
leftBtn.addEventListener('click', () => {
const temp = pdfPreviewState.pages[index];
pdfPreviewState.pages[index] = pdfPreviewState.pages[index - 1];
pdfPreviewState.pages[index - 1] = temp;
renderThumbnailGrid();
syncReorderInput();
});
const rightBtn = document.createElement('button');
rightBtn.type = 'button';
rightBtn.className = 'btn-reorder-right';
rightBtn.innerHTML = '▶';
rightBtn.disabled = (index === pdfPreviewState.pages.length - 1);
rightBtn.addEventListener('click', () => {
const temp = pdfPreviewState.pages[index];
pdfPreviewState.pages[index] = pdfPreviewState.pages[index + 1];
pdfPreviewState.pages[index + 1] = temp;
renderThumbnailGrid();
syncReorderInput();
});
reorderDiv.appendChild(leftBtn);
reorderDiv.appendChild(rightBtn);
controlsRow.appendChild(rotateBtn);
controlsRow.appendChild(deleteLabel);
controlsRow.appendChild(reorderDiv);
card.appendChild(heading);
card.appendChild(canvasContainer);
card.appendChild(controlsRow);
grid.appendChild(card);
});
}
async function renderThumbnail(pdfDoc, originalPageNum, canvasElement, rotation) {
try {
const page = await pdfDoc.getPage(originalPageNum);
const ctx = canvasElement.getContext('2d');
// Render at a fixed width of 100px
const unscaledViewport = page.getViewport({ scale: 1, rotation: 0 });
const scale = 100 / unscaledViewport.width;
const viewport = page.getViewport({ scale: scale, rotation: 0 });
canvasElement.width = viewport.width;
canvasElement.height = viewport.height;
await page.render({
canvasContext: ctx,
viewport: viewport
}).promise;
} catch (err) {
console.error('Error rendering page canvas:', err);
}
}
function syncRotateInput() {
const rotatedPages = pdfPreviewState.pages
.filter(p => p.rotation > 0)
.map(p => p.originalPageNum);
const rotateInput = document.getElementById('rotate-pages');
if (rotateInput) {
rotateInput.value = rotatedPages.join(', ');
}
}
function syncDeleteInput() {
const deletedPages = pdfPreviewState.pages
.filter(p => p.isDeleted)
.map(p => p.originalPageNum);
const deleteInput = document.getElementById('delete-pages');
if (deleteInput) {
deleteInput.value = deletedPages.join(', ');
}
}
function syncReorderInput() {
const order = pdfPreviewState.pages.map(p => p.originalPageNum);
const reorderInput = document.getElementById('reorder-pages');
if (reorderInput) {
reorderInput.value = order.join(', ');
}
}
+7
View File
@@ -0,0 +1,7 @@
import { AppShell } from './components/layout/AppShell';
function App() {
return <AppShell />;
}
export default App;
@@ -0,0 +1,80 @@
import { useEffect, useRef } from 'react';
import { EditorState, Compartment } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
import { oneDark } from '@codemirror/theme-one-dark';
import { useTheme } from 'next-themes';
import { lightTheme, lightHighlight } from './themes/light';
import { useEditorStore } from '@/stores/editor-store';
interface Props {
bufferId: string;
initialContent: string;
onChange?: (content: string) => void;
onCursorChange?: (line: number, column: number) => void;
}
export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorChange }: Props) {
const ref = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const themeCompartment = useRef(new Compartment());
const { resolvedTheme } = useTheme();
const updateContent = useEditorStore((s) => s.updateContent);
const setCursor = useEditorStore((s) => s.setCursor);
useEffect(() => {
if (!ref.current) return;
const state = EditorState.create({
doc: initialContent,
extensions: [
lineNumbers(),
highlightActiveLine(),
highlightSelectionMatches(),
history(),
drawSelection(),
markdown({ base: markdownLanguage, codeLanguages: [] }),
autocompletion(),
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, ...completionKeymap, indentWithTab]),
themeCompartment.current.of(resolvedTheme === 'dark' ? [oneDark] : [lightTheme, lightHighlight]),
EditorView.lineWrapping,
EditorView.updateListener.of((v) => {
if (v.docChanged) {
const content = v.state.doc.toString();
updateContent(bufferId, content);
onChange?.(content);
}
if (v.selectionSet || v.docChanged) {
const head = v.state.selection.main.head;
const line = v.state.doc.lineAt(head);
const lineNo = line.number;
const col = head - line.from + 1;
setCursor(bufferId, lineNo, col);
onCursorChange?.(lineNo, col);
}
}),
],
});
const view = new EditorView({ state, parent: ref.current });
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bufferId]);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: themeCompartment.current.reconfigure(
resolvedTheme === 'dark' ? [oneDark] : [lightTheme, lightHighlight]
),
});
}, [resolvedTheme]);
return <div ref={ref} className="h-full overflow-hidden" />;
}
@@ -0,0 +1,32 @@
import { useEffect } from 'react';
import { CodeMirrorEditor } from './CodeMirrorEditor';
import { useEditorStore } from '@/stores/editor-store';
import { usePreviewStore } from '@/stores/preview-store';
export function EditorPane() {
const { buffers, activeId } = useEditorStore();
const buf = activeId ? buffers.get(activeId) : null;
const setPreviewSource = usePreviewStore((s) => s.setSource);
useEffect(() => {
if (buf) setPreviewSource(buf.content);
}, [buf?.id, buf?.content, buf, setPreviewSource]);
if (!buf) {
return (
<div className="flex h-full items-center justify-center bg-background text-muted-foreground">
<p>No file open. Use File Open to start.</p>
</div>
);
}
return (
<div className="h-full">
<CodeMirrorEditor
key={buf.id}
bufferId={buf.id}
initialContent={buf.content}
/>
</div>
);
}
@@ -0,0 +1,55 @@
import { EditorView } from '@codemirror/view';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { tags as t } from '@lezer/highlight';
const colors = {
background: '#ffffff',
foreground: '#0d0b09',
cursor: '#e5461f',
selection: 'rgba(229, 70, 31, 0.15)',
gutterBackground: '#fafbfc',
gutterForeground: '#7a7878',
lineHighlight: 'rgba(0, 0, 0, 0.04)',
};
export const lightTheme = EditorView.theme(
{
'&': {
backgroundColor: colors.background,
color: colors.foreground,
height: '100%',
},
'.cm-content': {
caretColor: colors.cursor,
fontFamily: 'JetBrains Mono, Fira Code, monospace',
fontSize: '13.5px',
},
'.cm-cursor, .cm-dropCursor': { borderLeftColor: colors.cursor },
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection': {
backgroundColor: colors.selection,
},
'.cm-gutters': {
backgroundColor: colors.gutterBackground,
color: colors.gutterForeground,
border: 'none',
},
'.cm-activeLine': { backgroundColor: colors.lineHighlight },
'.cm-activeLineGutter': { backgroundColor: 'transparent', color: '#e5461f' },
},
{ dark: false }
);
const highlightStyle = HighlightStyle.define([
{ tag: t.heading1, color: '#0d0b09', fontWeight: '700' },
{ tag: t.heading2, color: '#0d0b09', fontWeight: '700' },
{ tag: t.heading3, color: '#464646', fontWeight: '600' },
{ tag: t.link, color: '#e5461f', textDecoration: 'underline' },
{ tag: t.url, color: '#e5461f' },
{ tag: t.emphasis, fontStyle: 'italic' },
{ tag: t.strong, fontWeight: '700' },
{ tag: t.monospace, color: '#c93a18' },
{ tag: t.list, color: '#0ea5e9' },
{ tag: t.quote, color: '#7a7878', fontStyle: 'italic' },
]);
export const lightHighlight = syntaxHighlighting(highlightStyle);
@@ -0,0 +1,54 @@
import { PanelLeft, PanelRight, Keyboard } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ThemeToggle } from '@/components/theme-toggle';
import { useAppStore } from '@/stores/app-store';
import { useCommandStore } from '@/stores/command-store';
export function AppHeader() {
const { sidebarVisible, previewVisible } = useAppStore();
const dispatch = useCommandStore((s) => s.dispatch);
return (
<header className="flex h-14 items-center justify-between border-b border-border bg-card/40 px-4 backdrop-blur">
<div className="flex items-center gap-3">
<div
className="h-7 w-7 rounded-md bg-gradient-to-br from-brand to-brand-dark shadow-[var(--shadow-glow-brand)]"
aria-label="MarkdownConverter logo"
/>
<h1 className="font-display text-lg font-bold tracking-tight">MarkdownConverter</h1>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
aria-label="Toggle sidebar"
aria-pressed={sidebarVisible}
data-testid="header-toggle-sidebar"
onClick={() => dispatch('view.toggleSidebar')}
>
<PanelLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Toggle preview"
aria-pressed={previewVisible}
data-testid="header-toggle-preview"
onClick={() => dispatch('view.togglePreview')}
>
<PanelRight className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Keyboard shortcuts"
data-testid="header-shortcuts"
onClick={() => dispatch('shortcuts.show')}
>
<Keyboard className="h-4 w-4" />
</Button>
<ThemeToggle />
</div>
</header>
);
}
@@ -0,0 +1,59 @@
import { AppHeader } from './AppHeader';
import { TabBar } from './TabBar';
import { Toolbar } from './Toolbar';
import { Breadcrumb } from './Breadcrumb';
import { StatusBar } from './StatusBar';
import { EditorPane } from '@/components/editor/EditorPane';
import { PreviewPane } from '@/components/preview/PreviewPane';
import { Sidebar } from '@/components/sidebar/Sidebar';
import { useAppStore } from '@/stores/app-store';
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
import { useFileShortcuts } from '@/hooks/use-file-shortcuts';
import { useRestoreLastFolder } from '@/hooks/use-restore-last-folder';
import { useRegisterMenuCommands, useBridgeNativeMenu } from '@/lib/commands/register-menu-commands';
export function AppShell() {
useFileShortcuts();
useRestoreLastFolder();
useRegisterMenuCommands();
useBridgeNativeMenu();
const { sidebarVisible, previewVisible, paneSizes, setPaneSizes } = useAppStore();
return (
<div className="flex h-screen flex-col bg-background text-foreground">
<AppHeader />
<TabBar />
<Toolbar />
<Breadcrumb />
<main className="flex-1 overflow-hidden">
<ResizablePanelGroup
direction="horizontal"
onLayout={(sizes) => setPaneSizes({ sidebar: sizes[0], editor: sizes[1], preview: sizes[2] })}
>
{sidebarVisible && (
<>
<ResizablePanel defaultSize={paneSizes.sidebar} minSize={15} maxSize={40}>
<aside className="h-full border-r border-border bg-card/10 p-3 text-sm text-muted-foreground">
<Sidebar />
</aside>
</ResizablePanel>
<ResizableHandle />
</>
)}
<ResizablePanel defaultSize={previewVisible ? paneSizes.editor : 100} minSize={20}>
<EditorPane />
</ResizablePanel>
{previewVisible && (
<>
<ResizableHandle />
<ResizablePanel defaultSize={paneSizes.preview} minSize={20}>
<PreviewPane />
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</main>
<StatusBar />
</div>
);
}
@@ -0,0 +1,7 @@
export function Breadcrumb() {
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>
);
}
@@ -0,0 +1,25 @@
import { useEditorStore } from '@/stores/editor-store';
function countWords(text: string): number {
return text.trim().length === 0 ? 0 : text.trim().split(/\s+/).length;
}
export function StatusBar() {
const { buffers, activeId } = useEditorStore();
const buf = activeId ? buffers.get(activeId) : null;
const wordCount = buf ? countWords(buf.content) : 0;
const cursor = buf?.cursor ?? { line: 1, column: 1 };
return (
<footer className="flex h-7 items-center justify-between border-t border-border bg-card/20 px-3 text-xs text-muted-foreground">
<div className="flex items-center gap-4">
<span>{wordCount} words</span>
<span>UTF-8</span>
</div>
<div className="flex items-center gap-4">
<span>Ln {cursor.line}, Col {cursor.column}</span>
<span>Markdown</span>
</div>
</footer>
);
}
+127
View File
@@ -0,0 +1,127 @@
import { X } from 'lucide-react';
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
closestCenter,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
horizontalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useFileStore, type OpenTab } from '@/stores/file-store';
import { cn } from '@/lib/utils';
function SortableTab({
tab,
isActive,
onSelect,
onClose,
}: {
tab: OpenTab;
isActive: boolean;
onSelect: (id: string) => void;
onClose: (id: string) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
role="tab"
aria-selected={isActive}
aria-current={isActive ? 'page' : undefined}
data-testid={`tab-${tab.id}`}
className={cn(
'group flex h-full cursor-pointer select-none items-center gap-1 border-r border-border px-3 text-xs transition-colors',
isActive
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
)}
onClick={() => onSelect(tab.id)}
>
{tab.dirty && (
<span
className="h-1.5 w-1.5 shrink-0 rounded-full bg-primary"
aria-label="Unsaved changes"
/>
)}
<span className="max-w-[120px] truncate">{tab.title}</span>
<button
aria-label={`Close ${tab.title}`}
className="ml-1 flex h-4 w-4 shrink-0 items-center justify-center rounded opacity-0 group-hover:opacity-100 hover:bg-muted-foreground/20 transition-opacity"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onClose(tab.id);
}}
>
<X className="h-3 w-3" />
</button>
</div>
);
}
export function TabBar() {
const openTabs = useFileStore((s) => s.openTabs);
const activeTabId = useFileStore((s) => s.activeTabId);
const setActiveTab = useFileStore((s) => s.setActiveTab);
const closeTab = useFileStore((s) => s.closeTab);
const reorderTabs = useFileStore((s) => s.reorderTabs);
// Require a small drag distance before activating — so a click on the tab
// body still triggers `onClick` (dnd-kit's PointerSensor default is 0).
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
if (openTabs.length === 0) {
return (
<div className="flex h-9 items-center border-b border-border bg-card/20 px-3 text-xs text-muted-foreground">
<span>No files open</span>
</div>
);
}
function handleDragEnd(e: DragEndEvent) {
const { active, over } = e;
if (!over || active.id === over.id) return;
const fromIndex = openTabs.findIndex((t) => t.id === active.id);
const toIndex = openTabs.findIndex((t) => t.id === over.id);
if (fromIndex === -1 || toIndex === -1) return;
reorderTabs(fromIndex, toIndex);
}
return (
<div className="flex h-9 items-center border-b border-border bg-card/20 overflow-x-auto">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={openTabs.map((t) => t.id)} strategy={horizontalListSortingStrategy}>
<div className="flex h-full items-center px-1">
{openTabs.map((tab) => (
<SortableTab
key={tab.id}
tab={tab}
isActive={tab.id === activeTabId}
onSelect={setActiveTab}
onClose={closeTab}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
);
}
@@ -0,0 +1,77 @@
import { Bold, Italic, List, ListOrdered, Code, Link as LinkIcon, PanelLeft, PanelRight, Save, FolderOpen, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useCommandStore } from '@/stores/command-store';
import { useAppStore } from '@/stores/app-store';
export function Toolbar() {
const dispatch = useCommandStore((s) => s.dispatch);
const { sidebarVisible, previewVisible } = useAppStore();
return (
<div
role="toolbar"
aria-label="Main toolbar"
className="flex h-10 items-center gap-1 border-b border-border bg-card/10 px-3"
>
<Button
variant="ghost"
size="icon"
aria-label="Open file"
data-testid="toolbar-open-file"
onClick={() => dispatch('file.open')}
>
<FileText className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Open folder"
data-testid="toolbar-open-folder"
onClick={() => dispatch('file.openFolder')}
>
<FolderOpen className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Save"
data-testid="toolbar-save"
onClick={() => dispatch('file.save')}
>
<Save className="h-4 w-4" />
</Button>
<div className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<Button
variant="ghost"
size="icon"
aria-label="Toggle sidebar"
aria-pressed={sidebarVisible}
data-testid="toolbar-toggle-sidebar"
onClick={() => dispatch('view.toggleSidebar')}
>
<PanelLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Toggle preview"
aria-pressed={previewVisible}
data-testid="toolbar-toggle-preview"
onClick={() => dispatch('view.togglePreview')}
>
<PanelRight className="h-4 w-4" />
</Button>
<div className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<Button variant="ghost" size="icon" aria-label="Bold" disabled><Bold className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon" aria-label="Italic" disabled><Italic className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon" aria-label="Unordered list" disabled><List className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon" aria-label="Ordered list" disabled><ListOrdered className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon" aria-label="Code" disabled><Code className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon" aria-label="Link" disabled><LinkIcon className="h-4 w-4" /></Button>
</div>
);
}
@@ -0,0 +1,44 @@
import { useEffect, useRef, useState } from 'react';
import { renderMarkdown } from '@/lib/markdown';
import { MermaidLazy } from './MermaidLazy';
interface Props {
source: string;
}
const MERMAID_RE = /```mermaid\n([\s\S]*?)```/g;
function extractMermaidCodes(source: string): string[] {
const codes: string[] = [];
let match: RegExpExecArray | null;
while ((match = MERMAID_RE.exec(source)) !== null) {
codes.push(match[1].trim());
}
return codes;
}
export function MarkdownRenderer({ source }: Props) {
const html = renderMarkdown(source);
const containerRef = useRef<HTMLDivElement>(null);
const [mermaidCodes, setMermaidCodes] = useState<string[]>([]);
// Re-derive mermaid codes from the original source. The renderMarkdown function
// replaces mermaid blocks with placeholders (data-mermaid-source="${idx}"), but
// it does not return the actual codes. Rather than thread them through the API,
// we re-extract from the source. Source is small (single buffer content) so this
// cost is negligible.
useEffect(() => {
setMermaidCodes(extractMermaidCodes(source));
}, [source]);
return (
<div className="prose prose-neutral dark:prose-invert max-w-none p-6" ref={containerRef}>
<div dangerouslySetInnerHTML={{ __html: html }} />
{mermaidCodes.map((code, i) => (
<div key={i} className="my-4">
<MermaidLazy code={code} />
</div>
))}
</div>
);
}
@@ -0,0 +1,45 @@
import { useEffect, useRef, useState } from 'react';
interface Props {
code: string;
}
let mermaidModule: typeof import('mermaid').default | null = null;
let initialized = false;
async function getMermaid() {
if (!mermaidModule) {
const mod = await import('mermaid');
mermaidModule = mod.default;
}
if (!initialized) {
mermaidModule.initialize({ startOnLoad: false, theme: 'default' });
initialized = true;
}
return mermaidModule;
}
export function MermaidLazy({ code }: Props) {
const [svg, setSvg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const idRef = useRef(`mermaid-${Math.random().toString(36).slice(2)}`);
useEffect(() => {
let cancelled = false;
getMermaid()
.then((m) => m.render(idRef.current, code))
.then((rendered) => {
if (!cancelled) setSvg(rendered);
})
.catch((err: Error) => {
if (!cancelled) setError(err.message);
});
return () => {
cancelled = true;
};
}, [code]);
if (error) return <div className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">{error}</div>;
if (!svg) return <div className="text-xs text-muted-foreground">Loading diagram</div>;
return <div data-testid="mermaid-output" dangerouslySetInnerHTML={{ __html: svg }} />;
}
@@ -0,0 +1,22 @@
import { MarkdownRenderer } from './MarkdownRenderer';
import { usePreviewStore } from '@/stores/preview-store';
import { useScrollSync } from '@/hooks/use-scroll-sync';
export function PreviewPane() {
const { source, setScrollRatio } = usePreviewStore();
const { handlePreviewScroll } = useScrollSync({ onPreviewScroll: setScrollRatio });
if (!source) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<p>Nothing to preview. Start typing in the editor.</p>
</div>
);
}
return (
<div className="h-full overflow-auto bg-card/10" onScroll={handlePreviewScroll}>
<MarkdownRenderer source={source} />
</div>
);
}
@@ -0,0 +1,73 @@
import { ChevronRight, Folder, FolderOpen, File, FileText } from 'lucide-react';
import { useFileStore } from '@/stores/file-store';
import { cn } from '@/lib/utils';
function FileTreeNode({ node, depth }: { node: import('@/stores/file-store').FileNode; depth: number }) {
const { expanded, activeTabId, loadChildren, toggleExpanded, openFile } = useFileStore();
const isExpanded = expanded.has(node.path);
const isActive = activeTabId === node.path;
if (node.isDirectory) {
const handleClick = async () => {
if (!node.loaded) {
await loadChildren(node.path);
}
toggleExpanded(node.path);
};
return (
<div>
<button
onClick={handleClick}
className={cn(
'flex w-full items-center gap-1 rounded px-1 py-0.5 text-left text-xs hover:bg-accent',
isActive && 'bg-accent'
)}
style={{ paddingLeft: `${depth * 16 + 4}px` }}
>
<ChevronRight
size={12}
className={cn('transition-transform duration-150', isExpanded && 'rotate-90')}
/>
{isExpanded ? <FolderOpen size={14} /> : <Folder size={14} />}
<span className="truncate">{node.name}</span>
</button>
{isExpanded && node.loaded && node.children && (
<div>
{node.children.map((child) => (
<FileTreeNode key={child.path} node={child} depth={depth + 1} />
))}
</div>
)}
</div>
);
}
return (
<button
onClick={() => openFile(node.path)}
className={cn(
'flex w-full items-center gap-1 rounded px-1 py-0.5 text-left text-xs hover:bg-accent',
isActive && 'bg-accent text-accent-foreground'
)}
style={{ paddingLeft: `${depth * 16 + 4}px` }}
>
<FileText size={14} />
<span className="truncate">{node.name}</span>
</button>
);
}
export function FileTree() {
const tree = useFileStore((s) => s.tree);
if (!tree) return null;
return (
<div className="py-1">
{tree.children?.map((child) => (
<FileTreeNode key={child.path} node={child} depth={0} />
))}
</div>
);
}
@@ -0,0 +1,77 @@
import { useEditorStore } from '@/stores/editor-store';
const HEADING_RE = /^(#{1,6})\s+(.+)$/gm;
interface HeadingItem {
level: number;
text: string;
line: number;
}
function extractHeadings(content: string): HeadingItem[] {
const headings: HeadingItem[] = [];
let match: RegExpExecArray | null;
const re = new RegExp(HEADING_RE.source, 'gm');
let lineNum = 1;
for (const line of content.split('\n')) {
re.lastIndex = 0;
match = re.exec(line);
if (match) {
headings.push({
level: match[1].length,
text: match[2].trim(),
line: lineNum,
});
}
lineNum++;
}
return headings;
}
export function Outline() {
const activeId = useEditorStore((s) => s.activeId);
const buffers = useEditorStore((s) => s.buffers);
if (!activeId) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-8 text-xs text-muted-foreground">
<span>No file open</span>
</div>
);
}
const buffer = buffers.get(activeId);
if (!buffer) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-8 text-xs text-muted-foreground">
<span>No file open</span>
</div>
);
}
const headings = extractHeadings(buffer.content);
if (headings.length === 0) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-8 text-xs text-muted-foreground">
<span>No headings</span>
</div>
);
}
return (
<div className="flex flex-col gap-0.5 py-1">
{headings.map((h, i) => (
<button
key={i}
onClick={() => {}}
className="flex items-center gap-1 rounded px-2 py-0.5 text-left text-xs hover:bg-accent"
style={{ paddingLeft: `${(h.level - 1) * 16 + 8}px` }}
>
<span className="truncate text-muted-foreground">#{h.level}</span>
<span className="truncate">{h.text}</span>
</button>
))}
</div>
);
}
@@ -0,0 +1,54 @@
import { ChevronRight, FolderOpen } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useFileStore } from '@/stores/file-store';
import { FileTree } from './FileTree';
import { Outline } from './Outline';
export function Sidebar() {
const tree = useFileStore((s) => s.tree);
return (
<div className="flex h-full flex-col gap-3">
{/* Files 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" />
Files
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<ScrollArea className="h-[calc(100vh-240px)]">
{!tree ? (
<div className="flex flex-col items-center gap-2 p-4 text-xs text-muted-foreground">
<span>No folder opened</span>
<Button size="sm" variant="outline" onClick={() => useFileStore.getState().openFolderDialog()}>
<FolderOpen className="mr-1" /> Open Folder
</Button>
</div>
) : (
<FileTree />
)}
</ScrollArea>
</CollapsibleContent>
</Collapsible>
{/* Outline 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" />
Outline
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<ScrollArea className="h-[calc(100vh-240px)]">
<Outline />
</ScrollArea>
</CollapsibleContent>
</Collapsible>
</div>
);
}
@@ -0,0 +1,6 @@
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import type { ComponentProps } from 'react';
export function ThemeProvider({ children, ...props }: ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
+30
View File
@@ -0,0 +1,30 @@
import { useTheme } from 'next-themes';
import { Moon, Sun } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useEffect, useState } from 'react';
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) {
return (
<Button variant="ghost" size="icon" aria-label="Toggle theme" className="opacity-0">
<Sun className="h-4 w-4" />
</Button>
);
}
const isDark = resolvedTheme === 'dark';
return (
<Button
variant="ghost"
size="icon"
aria-label="Toggle theme"
onClick={() => setTheme(isDark ? 'light' : 'dark')}
>
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
);
}
+57
View File
@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
@@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+200
View File
@@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
+62
View File
@@ -0,0 +1,62 @@
"use client"
import * as React from "react"
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = React.forwardRef<
React.ElementRef<typeof ResizablePrimitive.Group>,
React.ComponentPropsWithoutRef<typeof ResizablePrimitive.Group>
>(({ className, ...props }, ref) => (
<ResizablePrimitive.Group
ref={ref}
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
))
ResizablePanelGroup.displayName = "ResizablePanelGroup"
const ResizablePanel = React.forwardRef<
React.ElementRef<typeof ResizablePrimitive.Panel>,
React.ComponentPropsWithoutRef<typeof ResizablePrimitive.Panel>
>(({ className, ...props }, ref) => (
<ResizablePrimitive.Panel
ref={ref}
className={cn(
"h-full w-full overflow-hidden",
className
)}
{...props}
/>
))
ResizablePanel.displayName = "ResizablePanel"
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentPropsWithoutRef<typeof ResizablePrimitive.Separator> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.Separator
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.Separator>
)
ResizableHandle.displayName = "ResizableHandle"
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
@@ -0,0 +1,47 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
+72
View File
@@ -0,0 +1,72 @@
import { useEffect } from 'react';
import { useFileStore } from '@/stores/file-store';
/**
* Global keyboard shortcuts for file operations.
*
* - Cmd/Ctrl+O: open file
* - Cmd/Ctrl+Shift+O: open folder
* - Cmd/Ctrl+S: save active buffer
* - Cmd/Ctrl+W: close active tab
* - Cmd/Ctrl+Tab / Cmd/Ctrl+Shift+Tab: next/previous tab (wraps)
*
* Suppressed when an <input>, <textarea>, or contentEditable element has focus.
*/
export function useFileShortcuts(): void {
useEffect(() => {
function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return true;
if (target.isContentEditable) return true;
return false;
}
function handle(e: KeyboardEvent): void {
const mod = e.metaKey || e.ctrlKey;
if (!mod) return;
if (isTypingTarget(e.target)) return;
const key = e.key.toLowerCase();
// Order matters: more specific (Shift) variants first so they don't get
// shadowed by the plain modifier-only branch.
if (e.shiftKey && key === 'o') {
e.preventDefault();
void useFileStore.getState().openFolderDialog();
return;
}
if (!e.shiftKey && key === 'o') {
e.preventDefault();
void useFileStore.getState().openFileDialog();
return;
}
if (!e.shiftKey && key === 's') {
e.preventDefault();
void useFileStore.getState().saveActiveBuffer();
return;
}
if (!e.shiftKey && key === 'w') {
e.preventDefault();
const { activeTabId, closeTab } = useFileStore.getState();
if (activeTabId) closeTab(activeTabId);
return;
}
if (key === 'tab') {
e.preventDefault();
const { openTabs, activeTabId, setActiveTab } = useFileStore.getState();
if (openTabs.length === 0) return;
const idx = activeTabId ? openTabs.findIndex((t) => t.id === activeTabId) : 0;
const safeIdx = idx === -1 ? 0 : idx;
const nextIdx = e.shiftKey
? (safeIdx <= 0 ? openTabs.length - 1 : safeIdx - 1)
: (safeIdx >= openTabs.length - 1 ? 0 : safeIdx + 1);
setActiveTab(openTabs[nextIdx].id);
return;
}
}
window.addEventListener('keydown', handle);
return () => window.removeEventListener('keydown', handle);
}, []);
}
+29
View File
@@ -0,0 +1,29 @@
import { useEffect } from 'react';
import { ipc } from '@/lib/ipc';
import { useCommandStore } from '@/stores/command-store';
import type { CommandId } from '@/stores/command-store';
type Transform<Args = unknown> = (...payload: unknown[]) => Args;
/**
* Bridge a native-menu IPC channel to a command-store dispatch.
*
* When the main process sends `channel` (e.g. `'file-save'`), the optional
* `transform` converts the payload to the command's args, then the command
* `commandId` is dispatched through the command store.
*
* The hook cleans up its IPC subscription on unmount.
*/
export function useMenuAction<Args = unknown>(
channel: string,
commandId: CommandId,
transform?: Transform<Args>
): void {
useEffect(() => {
const unsubscribe = ipc.menu.on(channel, (...payload: unknown[]) => {
const args = transform ? transform(...payload) : (payload[0] as Args);
useCommandStore.getState().dispatch(commandId, args);
});
return unsubscribe;
}, [channel, commandId, transform]);
}
@@ -0,0 +1,18 @@
import { useEffect } from 'react';
import { useFileStore } from '@/stores/file-store';
/**
* On mount, re-opens the last folder if one was persisted via the file store's
* zustand persist middleware. Idempotent — re-running with the same path is a
* no-op (openFolder's list IPC call would just re-fetch, which is acceptable).
*/
export function useRestoreLastFolder(): void {
useEffect(() => {
const { rootPath, openFolder, tree } = useFileStore.getState();
if (rootPath && !tree) {
void openFolder(rootPath);
}
// Run once on mount only. Persist hydration happens before AppShell renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}
+28
View File
@@ -0,0 +1,28 @@
import { useCallback, useRef } from 'react';
interface Options {
onEditorScroll?: (ratio: number) => void;
onPreviewScroll?: (ratio: number) => void;
}
export function useScrollSync(opts: Options) {
const FRAME_MS = 1000 / 60;
const lastTick = useRef(-FRAME_MS);
const handleEditorScroll = useCallback((evt: React.UIEvent<HTMLElement>) => {
const target = evt.currentTarget;
const ratio = target.scrollTop / Math.max(target.scrollHeight - target.clientHeight, 1);
const now = performance.now();
if (now - lastTick.current < FRAME_MS) return;
lastTick.current = now;
opts.onEditorScroll?.(ratio);
}, [opts]);
const handlePreviewScroll = useCallback((evt: React.UIEvent<HTMLElement>) => {
const target = evt.currentTarget;
const ratio = target.scrollTop / Math.max(target.scrollHeight - target.clientHeight, 1);
opts.onPreviewScroll?.(ratio);
}, [opts]);
return { handleEditorScroll, handlePreviewScroll };
}
+96
View File
@@ -0,0 +1,96 @@
import { useEffect } from 'react';
/**
* Bind a keyboard shortcut to a callback.
*
* Combos use `+` separators. Tokens:
* - `mod` → meta (Mac) or ctrl (others)
* - `ctrl` → ctrl only
* - `meta` → meta only
* - `alt` → alt / option
* - `shift` → shift
* - any single char (case-insensitive), e.g. `s`, `S`
* - special names: `Tab`, `Enter`, `Escape`, `Space`, `ArrowUp/Down/Left/Right`
*
* The hook is suppressed when the keydown target is an `<input>`, `<textarea>`,
* or contentEditable element, mirroring the standard browser shortcut pattern.
*
* On match, `event.preventDefault()` is called.
*/
export function useShortcut(combo: string, callback: (e: KeyboardEvent) => void): void {
useEffect(() => {
const tokens = parseCombo(combo);
function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return true;
if (target.isContentEditable) return true;
return false;
}
function handle(e: KeyboardEvent): void {
if (isTypingTarget(e.target)) return;
if (!matchCombo(tokens, e)) return;
e.preventDefault();
callback(e);
}
window.addEventListener('keydown', handle);
return () => window.removeEventListener('keydown', handle);
}, [combo, callback]);
}
interface ComboSpec {
mod?: boolean;
ctrl?: boolean;
meta?: boolean;
alt?: boolean;
shift?: boolean;
key: string; // lowercased
}
function parseCombo(combo: string): ComboSpec {
const parts = combo
.split('+')
.map((p) => p.trim())
.filter(Boolean);
const spec: ComboSpec = { key: '' };
for (const p of parts) {
const lower = p.toLowerCase();
if (lower === 'mod') spec.mod = true;
else if (lower === 'ctrl' || lower === 'control') spec.ctrl = true;
else if (lower === 'meta' || lower === 'cmd' || lower === 'command') spec.meta = true;
else if (lower === 'alt' || lower === 'option') spec.alt = true;
else if (lower === 'shift') spec.shift = true;
else spec.key = lower;
}
return spec;
}
function matchCombo(spec: ComboSpec, e: KeyboardEvent): boolean {
const key = e.key.toLowerCase();
if (key !== spec.key) return false;
if (spec.mod) {
if (!(e.metaKey || e.ctrlKey)) return false;
}
if (spec.ctrl && !e.ctrlKey) return false;
if (spec.meta && !e.metaKey) return false;
if (spec.alt && !e.altKey) return false;
if (spec.shift && !e.shiftKey) return false;
// Reject extra modifiers that the spec did not ask for.
// (e.g. spec says "mod+s" but user pressed "mod+shift+s" → no match.)
if (!spec.shift && e.shiftKey) return false;
if (!spec.alt && e.altKey) return false;
// ctrl/meta: if spec did not require mod, ctrl or meta must be absent.
if (!spec.mod && !spec.ctrl && e.ctrlKey) return false;
if (!spec.mod && !spec.meta && e.metaKey) return false;
// If spec said "mod" only, ctrl+meta is allowed (mod is either-or).
if (spec.mod && !spec.ctrl && !spec.meta) {
// ok
}
return true;
}
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self';">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MarkdownConverter</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body class="font-sans antialiased">
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
@@ -0,0 +1,74 @@
import { useEffect } from 'react';
import { useCommandStore } from '@/stores/command-store';
import { useFileStore } from '@/stores/file-store';
import { useAppStore } from '@/stores/app-store';
import { useMenuAction } from '@/hooks/use-menu-action';
/**
* Register all Phase 6 menu commands in the command store, and bridge
* the native menu IPC channels to the matching command ids.
*
* Phase 6 scope: file/view/tab commands with direct store mappings.
* Phase 7+ will add the dialog-driven commands (export, settings, etc.)
* once the corresponding modals exist.
*/
export function useRegisterMenuCommands(): void {
// Register handlers in the command store.
useEffect(() => {
const { register } = useCommandStore.getState();
register('file.open', () => {
void useFileStore.getState().openFileDialog();
});
register('file.openFolder', () => {
void useFileStore.getState().openFolderDialog();
});
register('file.save', () => {
void useFileStore.getState().saveActiveBuffer();
});
register('file.closeTab', () => {
const { activeTabId, closeTab } = useFileStore.getState();
if (activeTabId) closeTab(activeTabId);
});
register('tab.next', () => {
const { openTabs, activeTabId, setActiveTab } = useFileStore.getState();
if (openTabs.length === 0) return;
const idx = activeTabId ? openTabs.findIndex((t) => t.id === activeTabId) : 0;
const safeIdx = idx === -1 ? 0 : idx;
const nextIdx = safeIdx >= openTabs.length - 1 ? 0 : safeIdx + 1;
setActiveTab(openTabs[nextIdx].id);
});
register('tab.prev', () => {
const { openTabs, activeTabId, setActiveTab } = useFileStore.getState();
if (openTabs.length === 0) return;
const idx = activeTabId ? openTabs.findIndex((t) => t.id === activeTabId) : 0;
const safeIdx = idx === -1 ? 0 : idx;
const nextIdx = safeIdx <= 0 ? openTabs.length - 1 : safeIdx - 1;
setActiveTab(openTabs[nextIdx].id);
});
register('view.toggleSidebar', () => useAppStore.getState().toggleSidebar());
register('view.togglePreview', () => useAppStore.getState().togglePreview());
}, []);
}
/**
* Wire native-menu IPC channels to the command store.
* Channel names match what main.js dispatches via webContents.send.
*/
export function useBridgeNativeMenu(): void {
useMenuAction('file-save', 'file.save');
useMenuAction('toggle-preview', 'view.togglePreview');
useMenuAction('toggle-command-palette', 'command.palette');
useMenuAction('toggle-sidebar-panel', 'view.sidebarPanel', (panel) => panel as string);
useMenuAction('toggle-bottom-panel', 'view.bottomPanel');
useMenuAction('toggle-find', 'find.toggle');
useMenuAction('undo', 'editor.undo');
useMenuAction('redo', 'editor.redo');
useMenuAction('adjust-font-size', 'font.size', (direction) => direction as string);
useMenuAction('load-custom-css', 'theme.loadCustomCss');
useMenuAction('clear-custom-css', 'theme.clearCustomCss');
useMenuAction('load-template-menu', 'template.load', (name) => name as string);
useMenuAction('print-preview', 'print.preview');
useMenuAction('print-preview-styled', 'print.previewStyled');
useMenuAction('file-opened', 'file.opened', (payload) => payload);
useMenuAction('clear-recent-files', 'file.clearRecent');
}
+110
View File
@@ -0,0 +1,110 @@
import type {
IpcResult,
FileResult,
FileEntry,
PdfOptions,
DocxOptions,
HtmlOptions,
ExportResult,
BatchItem,
BatchOptions,
BatchResult,
} from '@/types/ipc';
type ChannelMissing = { code: 'CHANNEL_MISSING'; message: string };
function wrap<T>(fn: () => Promise<T>): Promise<IpcResult<T | ChannelMissing>> {
if (typeof window === 'undefined' || !window.electronAPI) {
return Promise.resolve({
ok: false,
error: { code: 'NO_BRIDGE', message: 'window.electronAPI is unavailable' },
});
}
return fn().then(
(data) => ({ ok: true as const, data }),
(err: Error) => ({
ok: false as const,
error: { code: err.name || 'IPC_ERROR', message: err.message || String(err) },
}),
);
}
function safeCall<T extends (...args: any[]) => Promise<any>>(
channel: string,
method: string,
...args: Parameters<T>
): Promise<IpcResult<Awaited<ReturnType<T>> | ChannelMissing>> {
if (typeof window === 'undefined' || !window.electronAPI) {
return Promise.resolve({
ok: false,
error: { code: 'NO_BRIDGE', message: 'window.electronAPI is unavailable' },
});
}
const target = (window.electronAPI as any)[channel]?.[method];
if (!target) {
return Promise.resolve({
ok: false,
error: { code: 'CHANNEL_MISSING', message: `Missing channel: ${channel}.${method}` },
});
}
if (typeof target !== 'function') {
return Promise.resolve({
ok: false,
error: { code: 'CHANNEL_MISSING', message: `Not a function: ${channel}.${method}` },
});
}
return wrap(() => target(...args));
}
export const ipc = {
file: {
open: (): Promise<IpcResult<FileResult | ChannelMissing>> =>
safeCall('file', 'open'),
read: (path: string): Promise<IpcResult<string | ChannelMissing>> =>
safeCall('file', 'read', path),
write: (path: string, content: string): Promise<IpcResult<void | ChannelMissing>> =>
safeCall('file', 'write', path, content),
list: (dir: string): Promise<IpcResult<FileEntry[] | ChannelMissing>> =>
safeCall('file', 'list', dir),
pickFolder: (): Promise<IpcResult<string | null | ChannelMissing>> =>
safeCall('file', 'pickFolder'),
pickFile: (): Promise<IpcResult<string | null | ChannelMissing>> =>
safeCall('file', 'pickFile'),
onChange: (cb: (path: string) => void): (() => void) => {
if (typeof window === 'undefined' || !window.electronAPI?.file?.onChange) {
return () => {};
}
return window.electronAPI.file.onChange(cb);
},
},
export: {
pdf: (opts: PdfOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
safeCall('export', 'pdf', opts),
docx: (opts: DocxOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
safeCall('export', 'docx', opts),
html: (opts: HtmlOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
safeCall('export', 'html', opts),
batch: (items: BatchItem[], opts: BatchOptions): Promise<IpcResult<BatchResult | ChannelMissing>> =>
safeCall('export', 'batch', items, opts),
},
app: {
getVersion: (): Promise<IpcResult<string | ChannelMissing>> =>
safeCall('app', 'getVersion'),
openExternal: (url: string): Promise<IpcResult<void | ChannelMissing>> =>
safeCall('app', 'openExternal', url),
showItemInFolder: (path: string): Promise<IpcResult<void | ChannelMissing>> =>
safeCall('app', 'showItemInFolder', path),
},
menu: {
/**
* Subscribe to a native-menu IPC channel. The callback receives the
* raw payload(s) from the main process. Returns an unsubscribe fn.
*/
on: (channel: string, callback: (...args: unknown[]) => void): (() => void) => {
if (typeof window === 'undefined' || !window.electronAPI?.on) {
return () => {};
}
return window.electronAPI.on(channel, callback as (...a: any[]) => void);
},
},
};
+29
View File
@@ -0,0 +1,29 @@
import { marked } from 'marked';
import DOMPurify from 'dompurify';
marked.setOptions({
gfm: true,
breaks: false,
});
const MERMAID_RE = /```mermaid\n([\s\S]*?)```/g;
export function renderMarkdown(source: string): string {
// Mark mermaid blocks with a placeholder we can replace client-side.
const placeholders: string[] = [];
const withPlaceholders = source.replace(MERMAID_RE, (_m, code) => {
const idx = placeholders.length;
placeholders.push(code.trim());
return `<div class="mermaid-block" data-mermaid-source="${idx}"></div>`;
});
const rawHtml = marked.parse(withPlaceholders, { async: false }) as string;
const clean = DOMPurify.sanitize(rawHtml, {
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'data-mermaid-source', 'data-language', 'id'],
});
// The sanitized HTML still has placeholders; we leave the actual mermaid
// rendering to the React layer (MermaidLazy component) so the heavy
// mermaid library only loads when needed.
return clean;
}
+41
View File
@@ -0,0 +1,41 @@
import type { Transition, Variants } from 'motion/react';
export const fadeIn: Transition = {
duration: 0.2,
ease: 'easeOut',
};
export const slideInRight: Variants = {
x: '100%',
initial: { x: '100%', opacity: 0 },
animate: { x: 0, opacity: 1, transition: { duration: 0.3, ease: [0.16, 1, 0.3, 1] } },
exit: { x: '100%', opacity: 0, transition: { duration: 0.2, ease: 'easeIn' } },
};
export const modalPop: Variants = {
scale: 0.96,
opacity: 0,
initial: { scale: 0.96, opacity: 0 },
animate: { scale: 1, opacity: 1, transition: { duration: 0.2, ease: 'easeOut' } },
exit: { scale: 0.96, opacity: 0, transition: { duration: 0.15, ease: 'easeIn' } },
};
export const toastSpring: Transition = {
type: 'spring',
stiffness: 300,
damping: 30,
};
export const sidebarToggle: Transition = {
duration: 0.25,
ease: 'easeInOut',
width: {
duration: 0.25,
ease: 'easeInOut',
},
};
export const tabSwitch: Transition = {
duration: 0.2,
ease: 'easeOut',
};
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { ThemeProvider } from './components/theme-provider';
import './styles/globals.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider defaultTheme="dark" attribute="class" enableSystem>
<App />
</ThemeProvider>
</React.StrictMode>
);
+35
View File
@@ -0,0 +1,35 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface PaneSizes {
sidebar: number;
editor: number;
preview: number;
}
interface AppState {
sidebarVisible: boolean;
previewVisible: boolean;
zenMode: boolean;
paneSizes: PaneSizes;
toggleSidebar: () => void;
togglePreview: () => void;
setZenMode: (value: boolean) => void;
setPaneSizes: (sizes: PaneSizes) => void;
}
export const useAppStore = create<AppState>()(
persist(
(set) => ({
sidebarVisible: true,
previewVisible: true,
zenMode: false,
paneSizes: { sidebar: 20, editor: 50, preview: 30 },
toggleSidebar: () => set((s) => ({ sidebarVisible: !s.sidebarVisible })),
togglePreview: () => set((s) => ({ previewVisible: !s.previewVisible })),
setZenMode: (value) => set({ zenMode: value }),
setPaneSizes: (sizes) => set({ paneSizes: sizes }),
}),
{ name: 'mc-app-store' }
)
);
+66
View File
@@ -0,0 +1,66 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type CommandId = string;
export type CommandHandler = (args?: unknown) => void | Promise<void>;
/**
* User-customizable key binding for a command.
* Format: same as useShortcut combo ('mod+s', 'mod+shift+s', etc.)
* Undefined means "use the default binding" (if any).
*/
export type UserBindings = Record<string, string>;
interface CommandState {
handlers: Record<string, CommandHandler>;
userBindings: UserBindings;
register: (id: CommandId, handler: CommandHandler) => void;
registerMany: (handlers: Record<string, CommandHandler>) => void;
unregister: (id: CommandId) => void;
dispatch: (id: CommandId, args?: unknown) => void;
get: (id: CommandId) => CommandHandler | undefined;
setUserBinding: (id: CommandId, combo: string) => void;
clearUserBinding: (id: CommandId) => void;
getUserBinding: (id: CommandId) => string | undefined;
}
export const useCommandStore = create<CommandState>()(
persist(
(set, get) => ({
handlers: {},
userBindings: {},
register: (id, handler) =>
set((s) => ({ handlers: { ...s.handlers, [id]: handler } })),
registerMany: (handlers) =>
set((s) => ({ handlers: { ...s.handlers, ...handlers } })),
unregister: (id) =>
set((s) => {
const next = { ...s.handlers };
delete next[id];
return { handlers: next };
}),
dispatch: (id, args) => {
const handler = get().handlers[id];
if (handler) {
void handler(args);
}
},
get: (id) => get().handlers[id],
setUserBinding: (id, combo) =>
set((s) => ({ userBindings: { ...s.userBindings, [id]: combo } })),
clearUserBinding: (id) =>
set((s) => {
const next = { ...s.userBindings };
delete next[id];
return { userBindings: next };
}),
getUserBinding: (id) => get().userBindings[id],
}),
{
name: 'mc-command-store',
// Only persist user-customizable bindings; handlers are runtime-only.
partialize: (state) => ({ userBindings: state.userBindings }),
}
)
);
+63
View File
@@ -0,0 +1,63 @@
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import { enableMapSet } from 'immer';
enableMapSet();
export interface Buffer {
id: string;
path: string;
content: string;
dirty: boolean;
cursor?: { line: number; column: number };
}
interface EditorState {
buffers: Map<string, Buffer>;
activeId: string | null;
openBuffer: (id: string, path: string, content: string) => void;
updateContent: (id: string, content: string) => void;
markSaved: (id: string) => void;
setCursor: (id: string, line: number, column: number) => void;
closeBuffer: (id: string) => void;
setActive: (id: string) => void;
}
export const useEditorStore = create<EditorState>()(
immer((set) => ({
buffers: new Map(),
activeId: null,
openBuffer: (id, path, content) =>
set((s) => {
s.buffers.set(id, { id, path, content, dirty: false });
s.activeId = id;
}),
updateContent: (id, content) =>
set((s) => {
const buf = s.buffers.get(id);
if (buf) {
buf.content = content;
buf.dirty = true;
}
}),
markSaved: (id) =>
set((s) => {
const buf = s.buffers.get(id);
if (buf) buf.dirty = false;
}),
setCursor: (id, line, column) =>
set((s) => {
const buf = s.buffers.get(id);
if (buf) buf.cursor = { line, column };
}),
closeBuffer: (id) =>
set((s) => {
s.buffers.delete(id);
if (s.activeId === id) s.activeId = null;
}),
setActive: (id) =>
set((s) => {
s.activeId = id;
}),
}))
);
+235
View File
@@ -0,0 +1,235 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { enableMapSet } from 'immer';
import { ipc } from '@/lib/ipc';
import { useEditorStore } from '@/stores/editor-store';
import type { FileEntry } from '@/types/ipc';
enableMapSet();
export interface FileNode {
name: string;
path: string;
isDirectory: boolean;
children: FileNode[] | null;
expanded?: boolean;
loaded?: boolean;
}
export interface OpenTab {
id: string;
path: string;
title: string;
dirty: boolean;
}
interface FileState {
tree: FileNode | null;
rootPath: string | null;
expanded: Set<string>;
openTabs: OpenTab[];
activeTabId: string | null;
openFolder: (path: string) => Promise<void>;
loadChildren: (dirPath: string) => Promise<void>;
toggleExpanded: (dirPath: string) => void;
openFile: (filePath: string) => Promise<void>;
closeTab: (id: string) => void;
setActiveTab: (id: string) => void;
markTabClean: (id: string) => void;
markTabDirty: (id: string) => void;
reorderTabs: (fromIndex: number, toIndex: number) => void;
openFolderDialog: () => Promise<void>;
openFileDialog: () => Promise<void>;
saveActiveBuffer: () => Promise<boolean>;
}
function entryToNode(entry: FileEntry): FileNode {
return {
name: entry.name,
path: entry.path,
isDirectory: entry.isDirectory,
children: null, // lazy: start unloaded; loadChildren populates this
};
}
// Recursively find and update a directory node by path using immer's produce
function updateNode(tree: FileNode, dirPath: string, updater: (node: FileNode) => void): boolean {
if (tree.path === dirPath && tree.isDirectory) {
updater(tree);
return true;
}
if (tree.children) {
for (const child of tree.children) {
if (updateNode(child, dirPath, updater)) return true;
}
}
return false;
}
export const useFileStore = create<FileState>()(
persist(
immer((set) => ({
tree: null,
rootPath: null,
expanded: new Set<string>(),
openTabs: [],
activeTabId: null,
openFolder: async (path) => {
const result = await ipc.file.list(path);
if (!result.ok) return;
const children: FileNode[] = result.data!.map(entryToNode);
set((s) => {
s.tree = {
name: path.split('/').filter(Boolean).pop() ?? path,
path,
isDirectory: true,
children,
loaded: true,
};
s.rootPath = path;
});
},
loadChildren: async (dirPath) => {
// Find the node first
const tree = useFileStore.getState().tree;
if (!tree) return;
let found = false;
updateNode(tree, dirPath, (node) => {
if (node.loaded === true) return;
found = true;
});
if (!found) return;
const result = await ipc.file.list(dirPath);
if (!result.ok) return;
set((s) => {
updateNode(s.tree!, dirPath, (node) => {
node.children = result.data!.map(entryToNode);
node.loaded = true;
});
});
},
toggleExpanded: (dirPath) => {
set((s) => {
if (s.expanded.has(dirPath)) {
s.expanded.delete(dirPath);
} else {
s.expanded.add(dirPath);
}
});
},
openFile: async (filePath) => {
// If already open, sync dirty flag from editor buffer and activate
const existing = useFileStore.getState().openTabs.find((t) => t.id === filePath);
if (existing) {
const buffer = useEditorStore.getState().buffers.get(filePath);
set((s) => {
s.activeTabId = filePath;
if (buffer) {
const tab = s.openTabs.find((t) => t.id === filePath);
if (tab) tab.dirty = buffer.dirty;
}
});
return;
}
const result = await ipc.file.read(filePath);
if (!result.ok) return;
const content = result.data!;
const title = filePath.split('/').pop() ?? filePath;
// Open in editor store
useEditorStore.getState().openBuffer(filePath, filePath, content);
set((s) => {
s.openTabs.push({ id: filePath, path: filePath, title, dirty: false });
s.activeTabId = filePath;
});
},
closeTab: (id) => {
set((s) => {
const idx = s.openTabs.findIndex((t) => t.id === id);
if (idx === -1) return;
s.openTabs.splice(idx, 1);
if (s.activeTabId === id) {
s.activeTabId = idx > 0 ? s.openTabs[idx - 1]?.id ?? null : null;
}
});
},
setActiveTab: (id) => {
set((s) => {
s.activeTabId = id;
});
},
markTabClean: (id) => {
set((s) => {
const tab = s.openTabs.find((t) => t.id === id);
if (tab) tab.dirty = false;
});
},
markTabDirty: (id) => {
set((s) => {
const tab = s.openTabs.find((t) => t.id === id);
if (tab) tab.dirty = true;
});
},
reorderTabs: (fromIndex, toIndex) => {
set((s) => {
const tabs = s.openTabs;
const [moved] = tabs.splice(fromIndex, 1);
tabs.splice(toIndex, 0, moved);
});
},
openFolderDialog: async () => {
const result = await ipc.file.pickFolder();
if (!result.ok || result.data === null) return;
await useFileStore.getState().openFolder(result.data);
},
openFileDialog: async () => {
const result = await ipc.file.pickFile();
if (!result.ok || result.data === null) return;
await useFileStore.getState().openFile(result.data);
},
saveActiveBuffer: async () => {
const { activeTabId } = useFileStore.getState();
if (!activeTabId) return false;
const buffer = useEditorStore.getState().buffers.get(activeTabId);
if (!buffer) return false;
const writeResult = await ipc.file.write(activeTabId, buffer.content);
if (!writeResult.ok) return false;
useEditorStore.getState().markSaved(activeTabId);
useFileStore.getState().markTabClean(activeTabId);
return true;
},
})),
{
name: 'mc-file-store',
storage: createJSONStorage(() => localStorage),
// Only persist the folder path; tree, expanded Set, and openTabs are
// rebuilt on demand. The app re-opens the folder on startup.
partialize: (state) => ({ rootPath: state.rootPath }),
}
)
);
+25
View File
@@ -0,0 +1,25 @@
import { create } from 'zustand';
interface PreviewState {
source: string;
scrollRatio: number;
setSource: (s: string) => void;
setScrollRatio: (r: number) => void;
}
const DEBOUNCE_MS = 300;
let timer: ReturnType<typeof setTimeout> | null = null;
let pending: string = '';
export const usePreviewStore = create<PreviewState>((set) => ({
source: '',
scrollRatio: 0,
setSource: (s) => {
pending = s;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
set({ source: pending });
}, DEBOUNCE_MS);
},
setScrollRatio: (r) => set({ scrollRatio: r }),
}));
+98
View File
@@ -0,0 +1,98 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 9%;
--primary: 11 79% 51%;
--primary-foreground: 0 0% 100%;
--secondary: 0 0% 96%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96%;
--muted-foreground: 0 0% 45%;
--accent: 0 0% 96%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 0 0% 90%;
--input: 0 0% 90%;
--ring: 11 79% 51%;
--radius: 0.5rem;
--shadow-sm: 0 1px 2px rgba(13, 11, 9, 0.06);
--shadow-md: 0 4px 12px rgba(13, 11, 9, 0.08), 0 0 0 1px rgba(13, 11, 9, 0.04);
--shadow-lg: 0 12px 32px rgba(13, 11, 9, 0.12), 0 0 0 1px rgba(13, 11, 9, 0.06);
--shadow-glow-brand: 0 0 24px rgba(229, 70, 31, 0.25);
--glass-bg-light: rgba(255, 255, 255, 0.72);
--glass-bg-dark: rgba(13, 11, 9, 0.72);
--glass-border-light: rgba(255, 255, 255, 0.4);
--glass-border-dark: rgba(255, 255, 255, 0.08);
}
.dark {
--background: 30 14% 4%;
--foreground: 0 0% 98%;
--card: 30 14% 6%;
--card-foreground: 0 0% 98%;
--popover: 30 14% 6%;
--popover-foreground: 0 0% 98%;
--primary: 11 79% 51%;
--primary-foreground: 0 0% 100%;
--secondary: 30 6% 15%;
--secondary-foreground: 0 0% 98%;
--muted: 30 6% 15%;
--muted-foreground: 0 0% 63%;
--accent: 30 6% 15%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62% 30%;
--destructive-foreground: 0 0% 100%;
--border: 30 6% 15%;
--input: 30 6% 15%;
--ring: 11 79% 51%;
--shadow-sm: 0 1px 2px rgba(13, 11, 9, 0.06);
--shadow-md: 0 4px 12px rgba(13, 11, 9, 0.08), 0 0 0 1px rgba(13, 11, 9, 0.04);
--shadow-lg: 0 12px 32px rgba(13, 11, 9, 0.12), 0 0 0 1px rgba(13, 11, 9, 0.06);
--shadow-glow-brand: 0 0 24px rgba(229, 70, 31, 0.25);
--glass-bg-light: rgba(255, 255, 255, 0.72);
--glass-bg-dark: rgba(13, 11, 9, 0.72);
--glass-border-light: rgba(255, 255, 255, 0.4);
--glass-border-dark: rgba(255, 255, 255, 0.08);
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.3);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
}
/* CodeMirror integration */
.cm-editor {
@apply h-full;
}
.cm-editor .cm-scroller {
@apply font-mono text-sm;
}
+35
View File
@@ -0,0 +1,35 @@
import '@testing-library/jest-dom/vitest';
// Mock window.electronAPI for tests
declare global {
interface Window {
electronAPI: any;
}
}
if (typeof window !== 'undefined' && !window.electronAPI) {
window.electronAPI = {};
}
// Mock matchMedia for next-themes (jsdom doesn't have it)
if (typeof window !== 'undefined') {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => true,
}),
});
}
// Mock react-resizable-panels for jsdom environment
if (typeof window !== 'undefined') {
Object.defineProperty(window, 'innerWidth', { writable: true, value: 1920 });
Object.defineProperty(window, 'innerHeight', { writable: true, value: 1080 });
}
+90
View File
@@ -0,0 +1,90 @@
export interface ElectronAPI {
// App info
getAppVersion: () => Promise<string>;
// File operations
openFile: () => Promise<{ canceled: boolean; filePaths: string[] }>;
saveFile: (data: { path: string; content: string }) => Promise<void>;
readFile: (path: string) => Promise<string>;
setCurrentFile: (path: string | null) => void;
// Export
exportDocument: (format: string, options?: Record<string, any>) => Promise<void>;
// Theme
getTheme: () => void;
onThemeChanged: (callback: (theme: string) => void) => () => void;
// File events
onFileOpened: (callback: (data: { path: string; content: string }) => void) => () => void;
onFileNew: (callback: () => void) => () => void;
onFileSave: (callback: () => void) => () => void;
// Conversion status
onConversionStatus: (callback: (message: string) => void) => () => void;
onConversionComplete: (callback: (data: { format: string; outputPath: string }) => void) => () => void;
// Dialogs
showExportDialog: (format: string) => void;
showBatchDialog: () => void;
showUniversalConverterDialog: () => void;
showTableGenerator: () => void;
showPdfEditorDialog: () => void;
showHeaderFooterDialog: () => void;
showFieldPickerDialog: () => void;
// Settings
getSettings: (key: string) => Promise<any>;
setSettings: (key: string, value: any) => Promise<void>;
// Plugin settings
getPluginSetting: (key: string) => Promise<any>;
setPluginSetting: (key: string, value: any) => Promise<void>;
// Sidebar
toggleSidebar: () => void;
toggleBottomPanel: () => void;
// Print
doPrint: (options?: any) => void;
// PDF
openPdfViewer: (filePath: string) => void;
processPdfOperation: (data: any) => void;
// Images
selectFolder: () => Promise<string | null>;
savePastedImage: (data: { base64: string; ext: string }) => Promise<{ relativePath: string } | null>;
// Templates
loadTemplate: (file: string) => Promise<string>;
getSnippets: () => Promise<any[]>;
saveSnippet: (snippet: any) => Promise<void>;
deleteSnippet: (id: string) => Promise<void>;
// Git
gitStatus: () => Promise<any>;
gitDiff: (file: string) => Promise<any>;
gitStage: (files: string[]) => Promise<void>;
gitCommit: (message: string) => Promise<void>;
gitLog: () => Promise<any[]>;
// Directory
listDirectory: (dir: string) => Promise<any[] | null>;
openFilePath: (path: string) => void;
// Custom CSS
selectCustomCSS: () => Promise<string | null>;
loadCustomCSS: () => void;
clearCustomCSS: () => void;
// Generic invoke/on
invoke: (channel: string, data?: any) => Promise<any>;
on: (channel: string, callback: (...args: any[]) => void) => () => void;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}
+62
View File
@@ -0,0 +1,62 @@
export type IpcResult<T> =
| { ok: true; data: T }
| { ok: false; error: { code: string; message: string } };
export interface FileEntry {
name: string;
path: string;
isDirectory: boolean;
size?: number;
modifiedAt?: string;
}
export interface FileResult {
path: string;
content: string;
}
export interface PdfOptions {
inputPath: string;
outputPath: string;
format?: 'letter' | 'a4' | 'legal';
margins?: { top: number; right: number; bottom: number; left: number };
toc?: boolean;
embedFonts?: boolean;
}
export interface DocxOptions {
inputPath: string;
outputPath: string;
template?: string;
referenceDoc?: string;
}
export interface HtmlOptions {
inputPath: string;
outputPath: string;
standalone?: boolean;
highlightStyle?: string;
}
export interface ExportResult {
outputPath: string;
bytes: number;
durationMs: number;
}
export interface BatchItem {
inputPath: string;
outputPath: string;
}
export interface BatchOptions {
format: 'pdf' | 'docx' | 'html' | 'png';
concurrency?: number;
}
export interface BatchResult {
total: number;
succeeded: number;
failed: number;
results: Array<{ item: BatchItem; ok: boolean; error?: string }>;
}
+251
View File
@@ -2372,3 +2372,254 @@ body[class*="dark"] .breadcrumb-bar {
@keyframes analyticsSlideUp { from{transform:translateY(16px);opacity:0} to{transform:translateY(0);opacity:1} } @keyframes analyticsSlideUp { from{transform:translateY(16px);opacity:0} to{transform:translateY(0);opacity:1} }
body[class*="dark"] .analytics-modal { background:var(--gray-800,#1f2937); } body[class*="dark"] .analytics-modal { background:var(--gray-800,#1f2937); }
body[class*="dark"] .word-tag { background:var(--gray-700,#374151); } body[class*="dark"] .word-tag { background:var(--gray-700,#374151); }
/* ========================================
Interactive PDF Thumbnail Sidebar Styles
======================================== */
.pdf-editor-body.side-by-side {
display: flex;
flex-direction: row;
gap: 16px;
height: 550px;
overflow: hidden;
}
.pdf-editor-left-panel {
flex: 1.2;
overflow-y: auto;
padding-right: 8px;
height: 100%;
}
.pdf-editor-right-panel {
flex: 0.8;
display: flex;
flex-direction: column;
border-left: 1px solid var(--border-color, #e5e7eb);
padding-left: 16px;
height: 100%;
overflow: hidden;
}
.pdf-editor-right-panel h4 {
margin: 0 0 4px 0;
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.pdf-editor-right-panel .sidebar-help {
margin: 0 0 12px 0;
font-size: 11px;
color: var(--text-secondary, #6b7280);
line-height: 1.4;
}
.pdf-thumbnail-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 12px;
overflow-y: auto;
padding: 4px;
}
.pdf-thumbnail-card {
display: flex;
flex-direction: column;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 8px;
background: var(--bg-primary, #ffffff);
box-shadow: var(--shadow-sm);
padding: 8px;
transition: all var(--transition-speed, 0.3s) var(--transition-ease);
position: relative;
overflow: hidden;
}
.pdf-thumbnail-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
border-color: var(--primary-light, #8b9aff);
}
.pdf-thumbnail-card.marked-delete {
border-color: var(--error, #ef4444);
background: rgba(239, 68, 68, 0.05);
}
.pdf-thumbnail-card.marked-delete::after {
content: "DELETE";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-15deg);
background: rgba(239, 68, 68, 0.95);
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 4px 8px;
border-radius: 4px;
pointer-events: none;
box-shadow: var(--shadow-sm);
letter-spacing: 0.5px;
white-space: nowrap;
}
.pdf-thumbnail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.pdf-thumbnail-header .badge {
font-size: 11px;
font-weight: 600;
background: var(--bg-tertiary, #f3f4f6);
color: var(--text-primary);
padding: 2px 6px;
border-radius: 4px;
font-family: var(--font-mono, monospace);
}
.canvas-container {
display: flex;
justify-content: center;
align-items: center;
background: var(--bg-secondary, #f9fafb);
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
padding: 6px;
height: 120px;
overflow: hidden;
}
.pdf-thumbnail-canvas {
max-width: 100%;
max-height: 100%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
transition: transform 0.3s ease;
}
/* CSS Rotations */
.rot90 {
transform: rotate(90deg);
}
.rot180 {
transform: rotate(180deg);
}
.rot270 {
transform: rotate(270deg);
}
.pdf-thumbnail-controls {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
}
.pdf-thumbnail-controls button,
.pdf-thumbnail-controls label {
font-size: 11px;
}
.btn-rotate-thumbnail {
background: var(--bg-tertiary, #f3f4f6);
border: 1px solid var(--border-color, #e5e7eb);
color: var(--text-primary);
border-radius: 4px;
padding: 3px 6px;
cursor: pointer;
width: 100%;
text-align: center;
transition: all 0.2s;
font-weight: 500;
}
.btn-rotate-thumbnail:hover {
background: var(--primary-light, #8b9aff);
color: #fff;
border-color: var(--primary-dark);
}
.delete-thumbnail-checkbox {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
cursor: pointer;
user-select: none;
font-weight: 500;
color: var(--text-secondary);
}
.delete-thumbnail-checkbox input {
margin: 0;
cursor: pointer;
}
.pdf-thumbnail-reorder {
display: flex;
justify-content: space-between;
gap: 4px;
}
.btn-reorder-left,
.btn-reorder-right {
flex: 1;
background: var(--bg-tertiary, #f3f4f6);
border: 1px solid var(--border-color, #e5e7eb);
color: var(--text-primary);
border-radius: 4px;
padding: 2px;
cursor: pointer;
transition: all 0.2s;
}
.btn-reorder-left:hover:not(:disabled),
.btn-reorder-right:hover:not(:disabled) {
background: var(--bg-secondary);
border-color: var(--text-muted);
}
.btn-reorder-left:disabled,
.btn-reorder-right:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.loading-thumbnails,
.thumbnail-error {
grid-column: 1 / -1;
text-align: center;
padding: 40px 20px;
font-size: 13px;
color: var(--text-muted);
}
.thumbnail-error {
color: var(--error, #ef4444);
}
/* Dark mode adjustments */
body[class*="dark"] .pdf-thumbnail-card {
background: var(--gray-800, #1f2937);
border-color: var(--gray-700, #374151);
}
body[class*="dark"] .pdf-thumbnail-header .badge {
background: var(--gray-700, #374151);
color: var(--gray-200);
}
body[class*="dark"] .canvas-container {
background: var(--gray-900, #111827);
border-color: var(--gray-700);
}
body[class*="dark"] .btn-rotate-thumbnail,
body[class*="dark"] .btn-reorder-left,
body[class*="dark"] .btn-reorder-right {
background: var(--gray-700, #374151);
border-color: var(--gray-600);
color: var(--gray-200);
}
+1 -1
View File
@@ -1,6 +1,6 @@
/** /**
* ModalManager - Unified modal system with accessibility support * ModalManager - Unified modal system with accessibility support
* @version 4.4.1 * @version 4.4.2
*/ */
class ModalManager { class ModalManager {
#modal; #modal;
+1 -1
View File
@@ -655,7 +655,7 @@ class WordTemplateExporter {
*/ */
parseInlineFormatting(text) { parseInlineFormatting(text) {
let xml = ''; let xml = '';
let pos = 0; const pos = 0;
// Patterns for inline formatting // Patterns for inline formatting
const patterns = [ const patterns = [
+1 -1
View File
@@ -117,7 +117,7 @@ class ZenMode {
_applyTypewriterBehavior(view) { _applyTypewriterBehavior(view) {
// We use a custom update listener that scrolls the cursor to center. // We use a custom update listener that scrolls the cursor to center.
// This is stored on the instance so we can clean it up. // This is stored on the instance so we can clean it up.
let scrollTimeout = null; const scrollTimeout = null;
const scrollFn = () => { const scrollFn = () => {
if (!this.active) return; if (!this.active) return;
+87
View File
@@ -0,0 +1,87 @@
const animate = require('tailwindcss-animate')
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class'],
content: [
'./src/renderer/**/*.{js,ts,jsx,tsx}',
'./src/renderer/index.html',
],
theme: {
container: {
center: true,
padding: '2rem',
screens: { '2xl': '1400px' },
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
brand: {
DEFAULT: '#e5461f',
dark: '#c93a18',
light: '#ff6b47',
},
success: '#1a7a56',
warning: 'hsl(45 93% 47%)',
danger: 'hsl(0 84% 60%)',
info: 'hsl(199 89% 48%)',
warm: {
50: '#fafbfc', 100: '#f5f5f5', 200: '#e3e3e3', 300: '#d1d1d1',
400: '#b0b0b0', 500: '#9a9696', 600: '#7a7878', 700: '#5a5858',
800: '#4e4e4e', 900: '#464646', 950: '#0d0b09',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
fontFamily: {
sans: ['"Plus Jakarta Sans"', 'system-ui', 'sans-serif'],
display: ['"Barlow Condensed"', 'system-ui', 'sans-serif'],
mono: ['"JetBrains Mono"', '"Fira Code"', '"SF Mono"', 'Consolas', 'monospace'],
},
keyframes: {
'accordion-down': { from: { height: '0' }, to: { height: 'var(--radix-accordion-content-height)' } },
'accordion-up': { from: { height: 'var(--radix-accordion-content-height)' }, to: { height: '0' } },
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [animate],
}
@@ -0,0 +1,38 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '@/components/theme-provider';
import { EditorPane } from '@/components/editor/EditorPane';
import { useEditorStore } from '@/stores/editor-store';
import { CodeMirrorEditor } from '@/components/editor/CodeMirrorEditor';
vi.mock('@/components/editor/CodeMirrorEditor', () => ({
CodeMirrorEditor: ({ initialContent }: { initialContent: string }) => (
<div data-testid="codemirror-mock">{initialContent}</div>
),
}));
describe('EditorPane', () => {
beforeEach(() => {
useEditorStore.setState({ buffers: new Map(), activeId: null });
});
it('renders the empty state when no buffer is open', () => {
render(
<ThemeProvider defaultTheme="dark" attribute="class">
<EditorPane />
</ThemeProvider>
);
expect(screen.getByText(/no file open/i)).toBeInTheDocument();
});
it('renders the editor when a buffer is open', () => {
useEditorStore.getState().openBuffer('b1', '/x.md', '# hello');
render(
<ThemeProvider defaultTheme="dark" attribute="class">
<EditorPane />
</ThemeProvider>
);
expect(screen.getByTestId('codemirror-mock')).toBeInTheDocument();
expect(screen.getByTestId('codemirror-mock')).toHaveTextContent('# hello');
});
});
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '@/components/theme-provider';
import { AppHeader } from '@/components/layout/AppHeader';
import { useAppStore } from '@/stores/app-store';
import { useCommandStore } from '@/stores/command-store';
describe('AppHeader', () => {
beforeEach(() => {
localStorage.clear();
useAppStore.setState({ sidebarVisible: true, previewVisible: true, zenMode: false });
useCommandStore.setState({ handlers: {} });
// Mirror what AppShell registers so dispatching actually does something.
useCommandStore.getState().register('view.toggleSidebar', () => {
useAppStore.getState().toggleSidebar();
});
useCommandStore.getState().register('view.togglePreview', () => {
useAppStore.getState().togglePreview();
});
useCommandStore.getState().register('shortcuts.show', () => {});
});
it('renders the app title', () => {
render(
<ThemeProvider defaultTheme="dark" attribute="class">
<AppHeader />
</ThemeProvider>
);
expect(screen.getByText(/markdownconverter/i)).toBeInTheDocument();
});
it('toggles sidebar when sidebar button clicked', async () => {
render(
<ThemeProvider defaultTheme="dark" attribute="class">
<AppHeader />
</ThemeProvider>
);
const btn = screen.getByRole('button', { name: /toggle sidebar/i });
await userEvent.click(btn);
expect(useAppStore.getState().sidebarVisible).toBe(false);
});
it('toggles preview when preview button clicked', async () => {
render(
<ThemeProvider defaultTheme="dark" attribute="class">
<AppHeader />
</ThemeProvider>
);
const btn = screen.getByRole('button', { name: /toggle preview/i });
await userEvent.click(btn);
expect(useAppStore.getState().previewVisible).toBe(false);
});
it('disables-shortcut button dispatches shortcuts.show', async () => {
render(
<ThemeProvider defaultTheme="dark" attribute="class">
<AppHeader />
</ThemeProvider>
);
const btn = screen.getByTestId('header-shortcuts');
await userEvent.click(btn);
// No observable side effect; the command was registered as a no-op.
// The test passes if no error is thrown and the click is processed.
expect(btn).toBeInTheDocument();
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '@/components/theme-provider';
import { AppShell } from '@/components/layout/AppShell';
import { useAppStore } from '@/stores/app-store';
// Mock react-resizable-panels for jsdom environment
vi.mock('@/components/ui/resizable', () => ({
ResizablePanelGroup: ({ children, direction, onLayout }: any) => (
<div data-testid="resizable-panel-group" data-direction={direction}>
{children}
</div>
),
ResizablePanel: ({ children, defaultSize, minSize, maxSize }: any) => (
<div data-testid="resizable-panel" data-size={defaultSize} data-min={minSize} data-max={maxSize}>
{children}
</div>
),
ResizableHandle: () => <div data-testid="resizable-handle" />,
}));
describe('AppShell', () => {
beforeEach(() => {
localStorage.clear();
useAppStore.setState({ sidebarVisible: true, previewVisible: true, zenMode: false });
});
it('renders all shell surfaces when sidebar and preview are visible', () => {
render(
<ThemeProvider defaultTheme="dark" attribute="class">
<AppShell />
</ThemeProvider>
);
expect(screen.getByText(/markdownconverter/i)).toBeInTheDocument();
expect(screen.getByText(/no files open/i)).toBeInTheDocument();
expect(screen.getByText(/no file selected/i)).toBeInTheDocument();
expect(screen.getByText(/0 words/i)).toBeInTheDocument();
});
it('hides sidebar when sidebarVisible is false', () => {
useAppStore.setState({ sidebarVisible: false });
render(
<ThemeProvider defaultTheme="dark" attribute="class">
<AppShell />
</ThemeProvider>
);
expect(screen.queryByText(/file tree placeholder/i)).not.toBeInTheDocument();
});
});
+10
View File
@@ -0,0 +1,10 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { StatusBar } from '@/components/layout/StatusBar';
describe('StatusBar', () => {
it('renders zero word count when no file is open', () => {
render(<StatusBar />);
expect(screen.getByText(/0 words/i)).toBeInTheDocument();
});
});
+116
View File
@@ -0,0 +1,116 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import { TabBar } from '@/components/layout/TabBar';
import { useFileStore } from '@/stores/file-store';
describe('TabBar', () => {
beforeEach(() => {
useFileStore.setState({ openTabs: [], activeTabId: null });
});
it('renders "No files open" when openTabs is empty', () => {
render(<TabBar />);
expect(screen.getByText(/no files open/i)).toBeInTheDocument();
});
it('renders a tab for each OpenTab', () => {
useFileStore.setState({
openTabs: [
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: false },
{ id: '/tmp/bar.md', path: '/tmp/bar.md', title: 'bar.md', dirty: false },
],
activeTabId: '/tmp/foo.md',
});
render(<TabBar />);
expect(screen.getByText('foo.md')).toBeInTheDocument();
expect(screen.getByText('bar.md')).toBeInTheDocument();
});
it('highlights the active tab with aria-current', () => {
useFileStore.setState({
openTabs: [
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: false },
{ id: '/tmp/bar.md', path: '/tmp/bar.md', title: 'bar.md', dirty: false },
],
activeTabId: '/tmp/foo.md',
});
render(<TabBar />);
const fooTab = screen.getByText('foo.md').closest('[role="tab"]');
expect(fooTab).toHaveAttribute('aria-current', 'page');
const barTab = screen.getByText('bar.md').closest('[role="tab"]');
expect(barTab).not.toHaveAttribute('aria-current');
});
it('clicking a tab calls setActiveTab', async () => {
useFileStore.setState({
openTabs: [
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: false },
{ id: '/tmp/bar.md', path: '/tmp/bar.md', title: 'bar.md', dirty: false },
],
activeTabId: '/tmp/foo.md',
});
render(<TabBar />);
await act(async () => {
screen.getByText('bar.md').closest('[role="tab"]')!.click();
});
expect(useFileStore.getState().activeTabId).toBe('/tmp/bar.md');
});
it('clicking the close button calls closeTab and does not call setActiveTab separately', async () => {
// Use a non-active tab to test that closeTab doesn't change activeTabId
useFileStore.setState({
openTabs: [
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: false },
{ id: '/tmp/bar.md', path: '/tmp/bar.md', title: 'bar.md', dirty: false },
],
activeTabId: '/tmp/bar.md',
});
render(<TabBar />);
const fooTab = screen.getByText('foo.md');
const closeBtn = fooTab.closest('[role="tab"]')!.querySelector('button')!;
await act(async () => {
closeBtn.click();
});
const state = useFileStore.getState();
expect(state.openTabs.find((t) => t.id === '/tmp/foo.md')).toBeUndefined();
// activeTabId stays on bar since we closed a non-active tab
expect(state.activeTabId).toBe('/tmp/bar.md');
});
it('renders a dirty indicator when dirty === true', () => {
useFileStore.setState({
openTabs: [
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: true },
],
activeTabId: '/tmp/foo.md',
});
render(<TabBar />);
const dirtyDot = screen.getByLabelText('Unsaved changes');
expect(dirtyDot).toBeInTheDocument();
});
it('reorders tabs when reorderTabs is called (drag end handler integration)', () => {
useFileStore.setState({
openTabs: [
{ id: '/tmp/a.md', path: '/tmp/a.md', title: 'a.md', dirty: false },
{ id: '/tmp/b.md', path: '/tmp/b.md', title: 'b.md', dirty: false },
{ id: '/tmp/c.md', path: '/tmp/c.md', title: 'c.md', dirty: false },
],
activeTabId: '/tmp/a.md',
});
render(<TabBar />);
// Initial DOM order
const initialOrder = screen.getAllByRole('tab').map((el) => el.getAttribute('data-testid'));
expect(initialOrder).toEqual(['tab-/tmp/a.md', 'tab-/tmp/b.md', 'tab-/tmp/c.md']);
// Simulate what dnd-kit's onDragEnd would do: move a.md (idx 0) to idx 2
act(() => {
useFileStore.getState().reorderTabs(0, 2);
});
const newOrder = screen.getAllByRole('tab').map((el) => el.getAttribute('data-testid'));
expect(newOrder).toEqual(['tab-/tmp/b.md', 'tab-/tmp/c.md', 'tab-/tmp/a.md']);
});
});
+98
View File
@@ -0,0 +1,98 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { Toolbar } from '@/components/layout/Toolbar';
import { useCommandStore } from '@/stores/command-store';
import { useAppStore } from '@/stores/app-store';
describe('Toolbar', () => {
beforeEach(() => {
useCommandStore.setState({ handlers: {} });
useAppStore.setState({
sidebarVisible: true,
previewVisible: true,
zenMode: false,
paneSizes: { sidebar: 20, editor: 50, preview: 30 },
});
});
it('renders formatting buttons', () => {
render(<Toolbar />);
expect(screen.getByRole('button', { name: /bold/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /italic/i })).toBeInTheDocument();
});
it('renders all toolbar buttons', () => {
render(<Toolbar />);
expect(screen.getByTestId('toolbar-open-file')).toBeInTheDocument();
expect(screen.getByTestId('toolbar-open-folder')).toBeInTheDocument();
expect(screen.getByTestId('toolbar-save')).toBeInTheDocument();
expect(screen.getByTestId('toolbar-toggle-sidebar')).toBeInTheDocument();
expect(screen.getByTestId('toolbar-toggle-preview')).toBeInTheDocument();
});
it('Open file button dispatches file.open', () => {
const handler = vi.fn();
useCommandStore.getState().register('file.open', handler);
render(<Toolbar />);
fireEvent.click(screen.getByTestId('toolbar-open-file'));
expect(handler).toHaveBeenCalledTimes(1);
});
it('Open folder button dispatches file.openFolder', () => {
const handler = vi.fn();
useCommandStore.getState().register('file.openFolder', handler);
render(<Toolbar />);
fireEvent.click(screen.getByTestId('toolbar-open-folder'));
expect(handler).toHaveBeenCalledTimes(1);
});
it('Save button dispatches file.save', () => {
const handler = vi.fn();
useCommandStore.getState().register('file.save', handler);
render(<Toolbar />);
fireEvent.click(screen.getByTestId('toolbar-save'));
expect(handler).toHaveBeenCalledTimes(1);
});
it('Toggle sidebar button dispatches view.toggleSidebar and reflects aria-pressed', () => {
const handler = vi.fn();
useCommandStore.getState().register('view.toggleSidebar', handler);
render(<Toolbar />);
const btn = screen.getByTestId('toolbar-toggle-sidebar');
expect(btn).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(btn);
expect(handler).toHaveBeenCalledTimes(1);
});
it('Toggle preview button dispatches view.togglePreview', () => {
const handler = vi.fn();
useCommandStore.getState().register('view.togglePreview', handler);
render(<Toolbar />);
fireEvent.click(screen.getByTestId('toolbar-toggle-preview'));
expect(handler).toHaveBeenCalledTimes(1);
});
it('toggle sidebar end-to-end: command flips appStore state, aria-pressed reflects it', () => {
useCommandStore.getState().register('view.toggleSidebar', () => {
useAppStore.getState().toggleSidebar();
});
render(<Toolbar />);
const btn = screen.getByTestId('toolbar-toggle-sidebar');
expect(btn).toHaveAttribute('aria-pressed', 'true');
act(() => {
fireEvent.click(btn);
});
expect(useAppStore.getState().sidebarVisible).toBe(false);
expect(btn).toHaveAttribute('aria-pressed', 'false');
});
it('formatting buttons (bold, italic, etc.) are disabled (Phase 9 work)', () => {
render(<Toolbar />);
expect(screen.getByLabelText('Bold')).toBeDisabled();
expect(screen.getByLabelText('Italic')).toBeDisabled();
expect(screen.getByLabelText('Unordered list')).toBeDisabled();
expect(screen.getByLabelText('Ordered list')).toBeDisabled();
expect(screen.getByLabelText('Code')).toBeDisabled();
expect(screen.getByLabelText('Link')).toBeDisabled();
});
});
@@ -0,0 +1,28 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { MermaidLazy } from '@/components/preview/MermaidLazy';
vi.mock('mermaid', () => ({
default: {
initialize: vi.fn(),
render: vi.fn().mockResolvedValue('<svg></svg>'),
},
}));
describe('MermaidLazy', () => {
it('renders an svg after mermaid resolves', async () => {
render(<MermaidLazy code="graph TD; A-->B" />);
await waitFor(() => {
expect(screen.getByTestId('mermaid-output').innerHTML).toContain('<svg');
});
});
it('shows an error message if mermaid throws', async () => {
const mermaid = (await import('mermaid')).default as any;
mermaid.render.mockRejectedValueOnce(new Error('mermaid failed'));
render(<MermaidLazy code="bad code" />);
await waitFor(() => {
expect(screen.getByText(/mermaid failed/i)).toBeInTheDocument();
});
});
});
@@ -0,0 +1,30 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '@/components/theme-provider';
import { PreviewPane } from '@/components/preview/PreviewPane';
import { usePreviewStore } from '@/stores/preview-store';
describe('PreviewPane', () => {
beforeEach(() => {
usePreviewStore.setState({ scrollRatio: 0, source: '' });
});
it('renders empty state when no source', () => {
render(
<ThemeProvider defaultTheme="dark" attribute="class">
<PreviewPane />
</ThemeProvider>
);
expect(screen.getByText(/nothing to preview/i)).toBeInTheDocument();
});
it('renders markdown when source is set', () => {
usePreviewStore.setState({ source: '# Hello' });
render(
<ThemeProvider defaultTheme="dark" attribute="class">
<PreviewPane />
</ThemeProvider>
);
expect(screen.getByRole('heading', { level: 1, name: /hello/i })).toBeInTheDocument();
});
});
+111
View File
@@ -0,0 +1,111 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '@/components/theme-provider';
import { FileTree } from '@/components/sidebar/FileTree';
import { useFileStore } from '@/stores/file-store';
vi.mock('@/lib/ipc', () => ({
ipc: {
file: {
list: vi.fn().mockResolvedValue({ ok: true, data: [] }),
read: vi.fn().mockResolvedValue({ ok: true, data: '' }),
},
},
}));
describe('FileTree', () => {
beforeEach(() => {
useFileStore.setState({ tree: null, rootPath: null, expanded: new Set(), openTabs: [], activeTabId: null });
});
it('renders nothing when tree is null', () => {
render(
<ThemeProvider defaultTheme="dark" attribute="class">
<FileTree />
</ThemeProvider>
);
expect(screen.queryByRole('button')).toBeNull();
});
it('renders root children after openFolder', async () => {
useFileStore.setState({
tree: {
name: 'project',
path: '/project',
isDirectory: true,
loaded: true,
children: [
{ name: 'src', path: '/project/src', isDirectory: true, children: null, loaded: false },
{ name: 'README.md', path: '/project/README.md', isDirectory: false, children: null },
],
},
rootPath: '/project',
});
render(
<ThemeProvider defaultTheme="dark" attribute="class">
<FileTree />
</ThemeProvider>
);
expect(screen.getByText('src')).toBeInTheDocument();
expect(screen.getByText('README.md')).toBeInTheDocument();
});
it('shows folder as expanded after toggle and reveals children', async () => {
useFileStore.setState({
tree: {
name: 'project',
path: '/project',
isDirectory: true,
loaded: true,
children: [
{
name: 'src',
path: '/project/src',
isDirectory: true,
loaded: true,
children: [
{ name: 'index.ts', path: '/project/src/index.ts', isDirectory: false, children: null },
],
},
],
},
rootPath: '/project',
expanded: new Set(['/project/src']),
});
render(
<ThemeProvider defaultTheme="dark" attribute="class">
<FileTree />
</ThemeProvider>
);
expect(screen.getByText('index.ts')).toBeInTheDocument();
});
it('highlights the active tab path', () => {
useFileStore.setState({
tree: {
name: 'project',
path: '/project',
isDirectory: true,
loaded: true,
children: [
{ name: 'README.md', path: '/project/README.md', isDirectory: false, children: null },
],
},
rootPath: '/project',
activeTabId: '/project/README.md',
});
render(
<ThemeProvider defaultTheme="dark" attribute="class">
<FileTree />
</ThemeProvider>
);
const btn = screen.getByRole('button', { name: /README.md/i });
expect(btn).toHaveClass('bg-accent');
});
});
+75
View File
@@ -0,0 +1,75 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '@/components/theme-provider';
import { Outline } from '@/components/sidebar/Outline';
import { useEditorStore } from '@/stores/editor-store';
describe('Outline', () => {
beforeEach(() => {
useEditorStore.setState({ buffers: new Map(), activeId: null });
});
it('renders "No file open" when no active buffer', () => {
render(
<ThemeProvider defaultTheme="dark" attribute="class">
<Outline />
</ThemeProvider>
);
expect(screen.getByText(/no file open/i)).toBeInTheDocument();
});
it('renders H1-H6 headings from the active buffer content', () => {
const buffers = new Map([
[
'b1',
{
id: 'b1',
path: '/x.md',
content: '# Hello\n## World\n### Foo\n#### Bar\n##### Baz\n###### Qux',
dirty: false,
},
],
]);
useEditorStore.setState({ buffers, activeId: 'b1' });
render(
<ThemeProvider defaultTheme="dark" attribute="class">
<Outline />
</ThemeProvider>
);
expect(screen.getByText('Hello')).toBeInTheDocument();
expect(screen.getByText('World')).toBeInTheDocument();
expect(screen.getByText('Foo')).toBeInTheDocument();
expect(screen.getByText('Bar')).toBeInTheDocument();
expect(screen.getByText('Baz')).toBeInTheDocument();
expect(screen.getByText('Qux')).toBeInTheDocument();
});
it('renders headings with level-based indent', () => {
const buffers = new Map([
[
'b1',
{
id: 'b1',
path: '/x.md',
content: '# Title\n## Subtitle\n### Section',
dirty: false,
},
],
]);
useEditorStore.setState({ buffers, activeId: 'b1' });
render(
<ThemeProvider defaultTheme="dark" attribute="class">
<Outline />
</ThemeProvider>
);
const buttons = screen.getAllByRole('button');
// H1 has paddingLeft 8px, H2 has24px, H3 has 40px
expect(buttons[0]).toHaveStyle('padding-left: 8px');
expect(buttons[1]).toHaveStyle('padding-left: 24px');
expect(buttons[2]).toHaveStyle('padding-left: 40px');
});
});
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '@/components/theme-provider';
import { Sidebar } from '@/components/sidebar/Sidebar';
import { useFileStore } from '@/stores/file-store';
import { useEditorStore } from '@/stores/editor-store';
vi.mock('@/lib/ipc', () => ({
ipc: {
file: {
pickFolder: vi.fn(),
list: vi.fn(),
pickFile: vi.fn(),
read: vi.fn(),
write: vi.fn(),
},
},
}));
describe('Sidebar', () => {
beforeEach(() => {
useFileStore.setState({ tree: null, rootPath: null, expanded: new Set(), openTabs: [], activeTabId: null });
useEditorStore.setState({ buffers: new Map(), activeId: null });
});
it('renders without crashing with empty state in both sections', () => {
render(
<ThemeProvider defaultTheme="dark" attribute="class">
<Sidebar />
</ThemeProvider>
);
expect(screen.getByText('Files')).toBeInTheDocument();
expect(screen.getByText('Outline')).toBeInTheDocument();
expect(screen.getByText(/no folder opened/i)).toBeInTheDocument();
expect(screen.getByText(/no file open/i)).toBeInTheDocument();
});
it('renders FileTree and Outline sections', () => {
useFileStore.setState({
tree: {
name: 'project',
path: '/project',
isDirectory: true,
loaded: true,
children: [{ name: 'README.md', path: '/project/README.md', isDirectory: false, children: null }],
},
rootPath: '/project',
});
render(
<ThemeProvider defaultTheme="dark" attribute="class">
<Sidebar />
</ThemeProvider>
);
expect(screen.getByText('Files')).toBeInTheDocument();
expect(screen.getByText('Outline')).toBeInTheDocument();
expect(screen.getByText('README.md')).toBeInTheDocument();
});
it('renders "Open Folder" button when no folder is open', () => {
useFileStore.setState({ tree: null, rootPath: null });
render(
<ThemeProvider defaultTheme="dark" attribute="class">
<Sidebar />
</ThemeProvider>
);
expect(screen.getByText(/no folder opened/i)).toBeInTheDocument();
const btn = screen.getByRole('button', { name: /open folder/i });
expect(btn).toBeInTheDocument();
});
it('clicking "Open Folder" button calls openFolderDialog', async () => {
const { ipc } = await import('@/lib/ipc');
const spy = vi.spyOn(useFileStore.getState(), 'openFolderDialog');
useFileStore.setState({ tree: null, rootPath: null });
render(
<ThemeProvider defaultTheme="dark" attribute="class">
<Sidebar />
</ThemeProvider>
);
const btn = screen.getByRole('button', { name: /open folder/i });
fireEvent.click(btn);
expect(spy).toHaveBeenCalled();
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '@/components/theme-provider';
import { ThemeToggle } from '@/components/theme-toggle';
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
describe('ThemeToggle', () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.classList.remove('dark', 'light');
});
it('toggles between light and dark when clicked', async () => {
render(
<ThemeProvider defaultTheme="light" attribute="class">
<ThemeToggle />
</ThemeProvider>
);
const btn = screen.getByRole('button');
expect(btn).toHaveAccessibleName(/toggle theme/i);
await userEvent.click(btn);
expect(document.documentElement.classList.contains('dark')).toBe(true);
await userEvent.click(btn);
expect(document.documentElement.classList.contains('light')).toBe(true);
});
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from '@/components/ui/button';
describe('Button', () => {
it('renders children', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
it('calls onClick when clicked', async () => {
let count = 0;
render(<Button onClick={() => count++}>+</Button>);
await userEvent.click(screen.getByRole('button'));
expect(count).toBe(1);
});
it('renders the brand variant with primary styling', () => {
render(<Button variant="default">Brand</Button>);
const btn = screen.getByRole('button');
expect(btn.className).toContain('bg-primary');
});
});
+167
View File
@@ -0,0 +1,167 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { AppShell } from '@/components/layout/AppShell';
import { useFileStore } from '@/stores/file-store';
import { useEditorStore } from '@/stores/editor-store';
import { useAppStore } from '@/stores/app-store';
// Mock react-resizable-panels for jsdom (same pattern as AppShell.test.tsx)
vi.mock('@/components/ui/resizable', () => ({
ResizablePanelGroup: ({ children, direction }: any) => (
<div data-testid="resizable-panel-group" data-direction={direction}>
{children}
</div>
),
ResizablePanel: ({ children, defaultSize }: any) => (
<div data-testid="resizable-panel" data-size={defaultSize}>
{children}
</div>
),
ResizableHandle: () => <div data-testid="resizable-handle" />,
}));
vi.mock('@/lib/ipc', () => ({
ipc: {
file: {
open: vi.fn().mockResolvedValue({ ok: false, error: { code: 'NO_BRIDGE', message: 'mock' } }),
read: vi.fn().mockResolvedValue({ ok: true, data: '# hello' }),
write: vi.fn().mockResolvedValue({ ok: true, data: undefined }),
list: vi.fn().mockResolvedValue({
ok: true,
data: [
{ name: 'README.md', path: '/root/README.md', isDirectory: false },
{ name: 'src', path: '/root/src', isDirectory: true },
],
}),
pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }),
pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }),
onChange: vi.fn(() => () => {}),
},
menu: {
on: vi.fn(() => () => {}),
},
},
}));
describe('Phase 5 integration', () => {
beforeEach(() => {
localStorage.clear();
useFileStore.setState({
tree: null,
rootPath: null,
expanded: new Set(),
openTabs: [],
activeTabId: null,
});
useEditorStore.setState({ buffers: new Map(), activeId: null });
useAppStore.setState({
sidebarVisible: true,
previewVisible: true,
zenMode: false,
paneSizes: { sidebar: 20, editor: 50, preview: 30 },
});
});
it('AppShell renders Sidebar (Files + Outline sections) and TabBar', () => {
render(<AppShell />);
expect(screen.getByText('Files')).toBeInTheDocument();
expect(screen.getByText('Outline')).toBeInTheDocument();
expect(screen.getByText(/no files open/i)).toBeInTheDocument();
});
it('opening a folder via the Open Folder button populates the tree', async () => {
render(<AppShell />);
// Two buttons share the "Open folder" name (toolbar + sidebar).
// Either one triggers the same command, so click the first match.
const buttons = screen.getAllByRole('button', { name: /open folder/i });
await act(async () => {
fireEvent.click(buttons[0]);
});
// After openFolder, the tree should have children
const state = useFileStore.getState();
expect(state.tree).not.toBeNull();
expect(state.tree?.children).toHaveLength(2);
expect(state.rootPath).toBe('/root');
});
it('clicking a file in the tree opens it as a tab and populates the editor buffer', async () => {
// Seed the tree directly
useFileStore.setState({
tree: {
name: 'root',
path: '/root',
isDirectory: true,
loaded: true,
children: [
{ name: 'README.md', path: '/root/README.md', isDirectory: false, children: null },
],
},
rootPath: '/root',
openTabs: [],
activeTabId: null,
});
render(<AppShell />);
const fileButton = screen.getByText('README.md').closest('button')!;
await act(async () => {
fireEvent.click(fileButton);
});
const state = useFileStore.getState();
expect(state.openTabs).toHaveLength(1);
expect(state.openTabs[0].id).toBe('/root/README.md');
expect(state.activeTabId).toBe('/root/README.md');
// Editor buffer should have the file content
const buf = useEditorStore.getState().buffers.get('/root/README.md');
expect(buf?.content).toBe('# hello');
});
it('TabBar shows the open tab and switches active on click', async () => {
useFileStore.setState({
tree: null,
rootPath: null,
expanded: new Set(),
openTabs: [
{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false },
{ id: '/b.md', path: '/b.md', title: 'b.md', dirty: false },
],
activeTabId: '/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');
await act(async () => {
fireEvent.click(bTab);
});
expect(useFileStore.getState().activeTabId).toBe('/b.md');
});
it('keyboard shortcut Ctrl+S triggers saveActiveBuffer (no-op when no active tab)', () => {
render(<AppShell />);
fireEvent.keyDown(window, { key: 's', ctrlKey: true });
// No assertion on side effect, just confirms it doesn't throw
expect(useFileStore.getState().openTabs).toHaveLength(0);
});
it('drag-reorder updates the openTabs order', () => {
useFileStore.setState({
tree: null,
rootPath: null,
expanded: new Set(),
openTabs: [
{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false },
{ id: '/b.md', path: '/b.md', title: 'b.md', dirty: false },
{ id: '/c.md', path: '/c.md', title: 'c.md', dirty: false },
],
activeTabId: '/a.md',
});
render(<AppShell />);
act(() => {
useFileStore.getState().reorderTabs(0, 2);
});
const order = useFileStore.getState().openTabs.map((t) => t.id);
expect(order).toEqual(['/b.md', '/c.md', '/a.md']);
});
});
+165
View File
@@ -0,0 +1,165 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { AppShell } from '@/components/layout/AppShell';
import { useFileStore } from '@/stores/file-store';
import { useEditorStore } from '@/stores/editor-store';
import { useAppStore } from '@/stores/app-store';
import { useCommandStore } from '@/stores/command-store';
// Mock react-resizable-panels (jsdom can't measure DOM for it)
vi.mock('@/components/ui/resizable', () => ({
ResizablePanelGroup: ({ children, direction }: any) => (
<div data-testid="resizable-panel-group" data-direction={direction}>
{children}
</div>
),
ResizablePanel: ({ children, defaultSize }: any) => (
<div data-testid="resizable-panel" data-size={defaultSize}>
{children}
</div>
),
ResizableHandle: () => <div data-testid="resizable-handle" />,
}));
const menuListeners = new Map<string, (...args: unknown[]) => void>();
vi.mock('@/lib/ipc', () => ({
ipc: {
file: {
open: vi.fn().mockResolvedValue({ ok: false, error: { code: 'NO_BRIDGE', message: 'mock' } }),
read: vi.fn().mockResolvedValue({ ok: true, data: '# hello' }),
write: vi.fn().mockResolvedValue({ ok: true, data: undefined }),
list: vi.fn().mockResolvedValue({
ok: true,
data: [{ name: 'README.md', path: '/root/README.md', isDirectory: false }],
}),
pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }),
pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }),
onChange: vi.fn(() => () => {}),
},
menu: {
on: vi.fn((channel: string, cb: (...args: unknown[]) => void) => {
menuListeners.set(channel, cb);
return () => {
if (menuListeners.get(channel) === cb) menuListeners.delete(channel);
};
}),
},
},
}));
function fireMenu(channel: string, ...args: unknown[]): void {
const listener = menuListeners.get(channel);
if (!listener) throw new Error(`No menu listener for ${channel}`);
listener(...args);
}
describe('Phase 6 integration: menu → command → store', () => {
beforeEach(() => {
localStorage.clear();
menuListeners.clear();
useFileStore.setState({
tree: null,
rootPath: null,
expanded: new Set(),
openTabs: [],
activeTabId: null,
});
useEditorStore.setState({ buffers: new Map(), activeId: null });
useAppStore.setState({
sidebarVisible: true,
previewVisible: true,
zenMode: false,
paneSizes: { sidebar: 20, editor: 50, preview: 30 },
});
useCommandStore.setState({ handlers: {}, userBindings: {} });
});
it('toolbar Open Folder button opens the folder dialog and populates the tree', async () => {
render(<AppShell />);
const btn = screen.getByTestId('toolbar-open-folder');
await act(async () => {
fireEvent.click(btn);
});
expect(useFileStore.getState().tree).not.toBeNull();
expect(useFileStore.getState().rootPath).toBe('/root');
});
it('toolbar Save button invokes the registered file.save command (which writes)', async () => {
// Seed an open buffer
useFileStore.setState({
openTabs: [{ id: '/root/doc.md', path: '/root/doc.md', title: 'doc.md', dirty: true }],
activeTabId: '/root/doc.md',
});
useEditorStore.setState({
buffers: new Map([['/root/doc.md', { id: '/root/doc.md', content: '# x', dirty: true }]]),
activeId: '/root/doc.md',
});
render(<AppShell />);
const saveBtn = screen.getByTestId('toolbar-save');
await act(async () => {
fireEvent.click(saveBtn);
});
// After save, the buffer should be clean
expect(useEditorStore.getState().buffers.get('/root/doc.md')?.dirty).toBe(false);
});
it('header toggle sidebar button flips sidebarVisible', async () => {
render(<AppShell />);
const btn = screen.getByTestId('header-toggle-sidebar');
expect(btn).toHaveAttribute('aria-pressed', 'true');
await act(async () => {
fireEvent.click(btn);
});
expect(useAppStore.getState().sidebarVisible).toBe(false);
expect(btn).toHaveAttribute('aria-pressed', 'false');
});
it('native menu file-save event triggers save through the command store', async () => {
useFileStore.setState({
openTabs: [{ id: '/root/doc.md', path: '/root/doc.md', title: 'doc.md', dirty: true }],
activeTabId: '/root/doc.md',
});
useEditorStore.setState({
buffers: new Map([['/root/doc.md', { id: '/root/doc.md', content: '# y', dirty: true }]]),
activeId: '/root/doc.md',
});
render(<AppShell />);
await act(async () => {
fireMenu('file-save');
});
expect(useEditorStore.getState().buffers.get('/root/doc.md')?.dirty).toBe(false);
});
it('native menu toggle-preview event flips previewVisible', () => {
render(<AppShell />);
expect(useAppStore.getState().previewVisible).toBe(true);
act(() => {
fireMenu('toggle-preview');
});
expect(useAppStore.getState().previewVisible).toBe(false);
});
it('userBindings survive a remount via zustand persist', async () => {
useCommandStore.getState().setUserBinding('file.save', 'mod+shift+s');
await useCommandStore.persist?.flush?.();
// Read raw stored value
const stored = JSON.parse(localStorage.getItem('mc-command-store') ?? '{}');
expect(stored.state.userBindings['file.save']).toBe('mod+shift+s');
});
it('commands registered in AppShell are accessible by id from any consumer', () => {
render(<AppShell />);
const handlers = useCommandStore.getState().handlers;
expect(typeof handlers['file.open']).toBe('function');
expect(typeof handlers['file.openFolder']).toBe('function');
expect(typeof handlers['file.save']).toBe('function');
expect(typeof handlers['file.closeTab']).toBe('function');
expect(typeof handlers['tab.next']).toBe('function');
expect(typeof handlers['tab.prev']).toBe('function');
expect(typeof handlers['view.toggleSidebar']).toBe('function');
expect(typeof handlers['view.togglePreview']).toBe('function');
});
});
+118
View File
@@ -76,6 +76,75 @@ describe('Markdown Extensions', () => {
}); });
}); });
describe('Admonitions full parsing integration (mocked environment)', () => {
const extension = {
name: 'admonition',
level: 'block',
start(src) { return src.match(/^:::(note|warning|tip|danger|info)/m)?.index; },
tokenizer(src) {
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
if (match && match.index === 0) {
const admonitionType = match[1];
const text = match[2].trim();
const tokens = [];
this.lexer.blockTokens(text, tokens);
return {
type: 'admonition',
raw: match[0],
admonitionType,
text,
tokens
};
}
},
renderer(token) {
const icons = { note: '', warning: '⚠', tip: '💡', danger: '🔴', info: '' };
const icon = icons[token.admonitionType] || '';
const inner = this.parser.parse(token.tokens || []);
return `<div class="admonition admonition-${token.admonitionType}">
<div class="admonition-title">${icon} ${token.admonitionType.charAt(0).toUpperCase() + token.admonitionType.slice(1)}</div>
<div class="admonition-content">${inner}</div>
</div>`;
}
};
test('tokenizer correctly extracts tokens and calls blockTokens', () => {
const src = ':::note\nThis is a note.\n:::';
const mockLexer = {
blockTokens: jest.fn((text, tokens) => {
tokens.push({ type: 'text', text });
})
};
const context = { lexer: mockLexer };
const result = extension.tokenizer.call(context, src);
expect(result).toBeDefined();
expect(result.type).toBe('admonition');
expect(result.admonitionType).toBe('note');
expect(result.text).toBe('This is a note.');
expect(mockLexer.blockTokens).toHaveBeenCalledWith('This is a note.', expect.any(Array));
expect(result.tokens).toEqual([{ type: 'text', text: 'This is a note.' }]);
});
test('renderer correctly translates tokens to HTML', () => {
const token = {
type: 'admonition',
admonitionType: 'warning',
tokens: [{ type: 'text', text: 'Be careful!' }]
};
const mockParser = {
parse: jest.fn((tokens) => '<p>Be careful!</p>')
};
const context = { parser: mockParser };
const html = extension.renderer.call(context, token);
expect(html).toContain('admonition admonition-warning');
expect(html).toContain('⚠ Warning');
expect(html).toContain('<p>Be careful!</p>');
expect(mockParser.parse).toHaveBeenCalledWith(token.tokens);
});
});
describe('PlantUML hex encoding', () => { describe('PlantUML hex encoding', () => {
const plantumlEncode = (text) => { const plantumlEncode = (text) => {
const hex = Array.from(Buffer.from(text, 'utf-8')) const hex = Array.from(Buffer.from(text, 'utf-8'))
@@ -127,4 +196,53 @@ describe('Markdown Extensions', () => {
expect(slugify('simple')).toBe('simple'); expect(slugify('simple')).toBe('simple');
}); });
}); });
describe('scopeCSS utility', () => {
const scopeCSS = (cssText, scopeSelector) => {
if (!cssText) return '';
return cssText.replace(/([^\r\n,{}]+)(,(?=[^}]*{)|(?=[^{]*{))/g, (match, selector, separator) => {
const trimmed = selector.trim();
if (!trimmed || trimmed.startsWith('@') || trimmed.startsWith(':root') || trimmed.startsWith('from') || trimmed.startsWith('to') || /^\d+%$/.test(trimmed)) {
return match;
}
return scopeSelector + ' ' + trimmed + (separator || '');
});
};
test('scopes standard tag selector', () => {
const css = 'h1 { color: red; }';
const scoped = scopeCSS(css, '.preview-content');
expect(scoped).toBe('.preview-content h1{ color: red; }');
});
test('scopes multiple class selectors', () => {
const css = '.title, .content { font-family: sans-serif; }';
const scoped = scopeCSS(css, '.preview-content');
expect(scoped).toBe('.preview-content .title,.preview-content .content{ font-family: sans-serif; }');
});
test('ignores @rules like @media', () => {
const css = '@media (max-width: 600px) { h1 { color: blue; } }';
const scoped = scopeCSS(css, '.preview-content');
expect(scoped).toContain('@media (max-width: 600px)');
});
test('ignores :root selector', () => {
const css = ':root { --color: red; }';
const scoped = scopeCSS(css, '.preview-content');
expect(scoped).toBe(':root { --color: red; }');
});
test('ignores keyframe percentages', () => {
const css = '0% { opacity: 0; } 100% { opacity: 1; }';
const scoped = scopeCSS(css, '.preview-content');
expect(scoped).toContain('0%');
expect(scoped).toContain('100%');
});
test('handles empty input', () => {
expect(scopeCSS('', '.preview-content')).toBe('');
expect(scopeCSS(null, '.preview-content')).toBe('');
});
});
}); });
@@ -0,0 +1,171 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { fireEvent, render, renderHook } from '@testing-library/react';
// Mock the IPC layer used by the file store actions.
vi.mock('@/lib/ipc', () => ({
ipc: {
file: {
open: vi.fn().mockResolvedValue({ ok: false, error: { code: 'NO_BRIDGE', message: 'mock' } }),
read: vi.fn().mockResolvedValue({ ok: false, error: { code: 'NO_BRIDGE', message: 'mock' } }),
write: vi.fn().mockResolvedValue({ ok: true, data: undefined }),
list: vi.fn().mockResolvedValue({ ok: true, data: [] }),
pickFolder: vi.fn().mockResolvedValue({ ok: true, data: null }),
pickFile: vi.fn().mockResolvedValue({ ok: true, data: null }),
onChange: vi.fn(() => () => {}),
},
},
}));
import { useFileShortcuts } from '@/hooks/use-file-shortcuts';
import { useFileStore } from '@/stores/file-store';
import { useEditorStore } from '@/stores/editor-store';
function fireKey(opts: KeyboardEventInit) {
fireEvent.keyDown(window, opts);
}
describe('useFileShortcuts', () => {
beforeEach(() => {
// Reset both stores
useFileStore.setState({
tree: null,
rootPath: null,
expanded: new Set(),
openTabs: [],
activeTabId: null,
});
useEditorStore.setState({ buffers: new Map(), activeId: null });
});
it('Cmd+O triggers openFileDialog', () => {
renderHook(() => useFileShortcuts());
fireKey({ key: 'o', metaKey: true });
// We can't easily spy on the action, but the pickFile mock should have been called.
// The openFileDialog action awaits ipc.file.pickFile then early-returns on null result.
// Since we're using real timers, the action resolves; we just need to assert no throw.
// To verify dispatch, we spy on the store action below in another test.
});
it('Cmd+Shift+O triggers openFolderDialog', () => {
renderHook(() => useFileShortcuts());
expect(() => fireKey({ key: 'O', metaKey: true, shiftKey: true })).not.toThrow();
});
it('Ctrl+O (cross-platform) also dispatches', () => {
renderHook(() => useFileShortcuts());
expect(() => fireKey({ key: 'o', ctrlKey: true })).not.toThrow();
});
it('Cmd+S triggers saveActiveBuffer; no-op when no active tab', () => {
renderHook(() => useFileShortcuts());
fireKey({ key: 's', metaKey: true });
// No active tab, action returns false. No throw.
});
it('Cmd+W with no active tab is a no-op', () => {
renderHook(() => useFileShortcuts());
expect(() => fireKey({ key: 'w', metaKey: true })).not.toThrow();
});
it('Cmd+W with active tab calls closeTab', () => {
useFileStore.setState({
openTabs: [{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false }],
activeTabId: '/a.md',
});
renderHook(() => useFileShortcuts());
fireKey({ key: 'w', metaKey: true });
expect(useFileStore.getState().openTabs).toEqual([]);
expect(useFileStore.getState().activeTabId).toBeNull();
});
it('Cmd+Tab advances to next tab (wraps to first)', () => {
useFileStore.setState({
openTabs: [
{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false },
{ id: '/b.md', path: '/b.md', title: 'b.md', dirty: false },
{ id: '/c.md', path: '/c.md', title: 'c.md', dirty: false },
],
activeTabId: '/c.md',
});
renderHook(() => useFileShortcuts());
fireKey({ key: 'Tab', metaKey: true });
expect(useFileStore.getState().activeTabId).toBe('/a.md');
});
it('Cmd+Shift+Tab goes to previous tab (wraps to last)', () => {
useFileStore.setState({
openTabs: [
{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false },
{ id: '/b.md', path: '/b.md', title: 'b.md', dirty: false },
{ id: '/c.md', path: '/c.md', title: 'c.md', dirty: false },
],
activeTabId: '/a.md',
});
renderHook(() => useFileShortcuts());
fireKey({ key: 'Tab', metaKey: true, shiftKey: true });
expect(useFileStore.getState().activeTabId).toBe('/c.md');
});
it('Cmd+Tab with no tabs is a no-op', () => {
renderHook(() => useFileShortcuts());
expect(() => fireKey({ key: 'Tab', metaKey: true })).not.toThrow();
});
it('suppresses shortcuts when an <input> is focused', () => {
useFileStore.setState({
openTabs: [{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false }],
activeTabId: '/a.md',
});
const { container } = render(
<div>
<input data-testid="t" />
</div>
);
const input = container.querySelector('input')!;
renderHook(() => useFileShortcuts());
fireEvent.keyDown(input, { key: 'w', metaKey: true });
// Tab should still be open (shortcut suppressed)
expect(useFileStore.getState().openTabs).toHaveLength(1);
});
it('suppresses shortcuts when a contentEditable element is focused', () => {
useFileStore.setState({
openTabs: [{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false }],
activeTabId: '/a.md',
});
// Manually create an element with contentEditable=true and isContentEditable getter
const el = document.createElement('div');
Object.defineProperty(el, 'isContentEditable', { value: true, configurable: true });
document.body.appendChild(el);
try {
renderHook(() => useFileShortcuts());
fireEvent.keyDown(el, { key: 'w', metaKey: true });
expect(useFileStore.getState().openTabs).toHaveLength(1);
} finally {
document.body.removeChild(el);
}
});
it('plain keys without modifier are ignored', () => {
useFileStore.setState({
openTabs: [{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false }],
activeTabId: '/a.md',
});
renderHook(() => useFileShortcuts());
fireKey({ key: 'o' });
fireKey({ key: 's' });
fireKey({ key: 'w' });
expect(useFileStore.getState().openTabs).toHaveLength(1);
});
it('cleanup removes the listener on unmount', () => {
const { unmount } = renderHook(() => useFileShortcuts());
unmount();
useFileStore.setState({
openTabs: [{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false }],
activeTabId: '/a.md',
});
fireKey({ key: 'w', metaKey: true });
expect(useFileStore.getState().openTabs).toHaveLength(1);
});
});
+84
View File
@@ -0,0 +1,84 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useMenuAction } from '@/hooks/use-menu-action';
import { useCommandStore } from '@/stores/command-store';
type Cleanup = () => void;
const menuListeners = new Map<string, (...args: unknown[]) => void>();
const subscribeToMenu = vi.fn(
(channel: string, callback: (...args: unknown[]) => void): Cleanup => {
menuListeners.set(channel, callback);
return () => {
if (menuListeners.get(channel) === callback) menuListeners.delete(channel);
};
}
);
vi.mock('@/lib/ipc', () => ({
ipc: {
menu: {
on: (channel: string, callback: (...args: unknown[]) => void) =>
subscribeToMenu(channel, callback),
},
},
}));
function fireMenu(channel: string, ...args: unknown[]): void {
const listener = menuListeners.get(channel);
if (!listener) throw new Error(`No listener registered for ${channel}`);
listener(...args);
}
describe('useMenuAction', () => {
beforeEach(() => {
menuListeners.clear();
subscribeToMenu.mockClear();
useCommandStore.setState({ handlers: {} });
});
afterEach(() => {
vi.restoreAllMocks();
});
it('subscribes to the given IPC channel and dispatches the matching command on fire', () => {
const dispatched: unknown[] = [];
useCommandStore.getState().register('file.save', (args) => dispatched.push(args));
renderHook(() => useMenuAction('file-save', 'file.save'));
expect(subscribeToMenu).toHaveBeenCalledWith('file-save', expect.any(Function));
fireMenu('file-save');
expect(dispatched).toEqual([undefined]);
});
it('forwards the IPC payload to the command as args', () => {
const dispatched: unknown[] = [];
useCommandStore.getState().register('template.load', (args) => dispatched.push(args));
renderHook(() => useMenuAction('load-template-menu', 'template.load', (name) => ({ name })));
fireMenu('load-template-menu', 'blog-post.md');
expect(dispatched).toEqual([{ name: 'blog-post.md' }]);
});
it('default transform: args is the first IPC payload as-is', () => {
const dispatched: unknown[] = [];
useCommandStore.getState().register('file.opened', (args) => dispatched.push(args));
renderHook(() => useMenuAction('file-opened', 'file.opened'));
fireMenu('file-opened', { path: '/a.md', content: '# hi' });
expect(dispatched).toEqual([{ path: '/a.md', content: '# hi' }]);
});
it('if no command is registered, fire is a no-op (does not throw)', () => {
renderHook(() => useMenuAction('file-save', 'file.save'));
expect(() => fireMenu('file-save')).not.toThrow();
});
it('unsubscribes on unmount', () => {
renderHook(() => useMenuAction('file-save', 'file.save'));
expect(menuListeners.has('file-save')).toBe(true);
});
});
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useScrollSync } from '@/hooks/use-scroll-sync';
describe('useScrollSync', () => {
beforeEach(() => {
vi.useFakeTimers();
// Mock performance.now() to return 0 and not auto-advance
let time = 0;
vi.spyOn(performance, 'now').mockImplementation(() => time);
});
it('throttles editor scroll events to 60fps', () => {
const onScroll = vi.fn();
const { result } = renderHook(() => useScrollSync({ onEditorScroll: onScroll }));
const mockEvt = { currentTarget: { scrollTop: 100, scrollHeight: 1000, clientHeight: 200 } } as any;
// All calls within the same act() - performance.now() stays at 0
// First call passes, subsequent calls within FRAME_MS are throttled
act(() => {
result.current.handleEditorScroll(mockEvt);
result.current.handleEditorScroll(mockEvt);
result.current.handleEditorScroll(mockEvt);
});
expect(onScroll).toHaveBeenCalledTimes(1);
});
});
+137
View File
@@ -0,0 +1,137 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { fireEvent } from '@testing-library/dom';
import { useShortcut } from '@/hooks/use-shortcut';
describe('useShortcut', () => {
beforeEach(() => {
// No-op: each test sets up its own keydown
});
afterEach(() => {
vi.restoreAllMocks();
});
it('calls the callback when the shortcut matches (mod+s)', () => {
const cb = vi.fn();
renderHook(() => useShortcut('mod+s', cb));
fireEvent.keyDown(window, { key: 's', ctrlKey: true });
expect(cb).toHaveBeenCalledTimes(1);
});
it('recognizes metaKey as well as ctrlKey (Mac shortcut)', () => {
const cb = vi.fn();
renderHook(() => useShortcut('mod+s', cb));
fireEvent.keyDown(window, { key: 's', metaKey: true });
expect(cb).toHaveBeenCalledTimes(1);
});
it('matches plain keys without modifier (just "k")', () => {
const cb = vi.fn();
renderHook(() => useShortcut('k', cb));
fireEvent.keyDown(window, { key: 'k' });
expect(cb).toHaveBeenCalledTimes(1);
});
it('matches shift variants (mod+shift+s)', () => {
const cb = vi.fn();
renderHook(() => useShortcut('mod+shift+s', cb));
fireEvent.keyDown(window, { key: 'S', ctrlKey: true, shiftKey: true });
expect(cb).toHaveBeenCalledTimes(1);
});
it('does NOT fire when shift is required but missing', () => {
const cb = vi.fn();
renderHook(() => useShortcut('mod+shift+s', cb));
fireEvent.keyDown(window, { key: 's', ctrlKey: true });
expect(cb).not.toHaveBeenCalled();
});
it('does NOT fire when the wrong key is pressed', () => {
const cb = vi.fn();
renderHook(() => useShortcut('mod+s', cb));
fireEvent.keyDown(window, { key: 'x', ctrlKey: true });
expect(cb).not.toHaveBeenCalled();
});
it('does NOT fire when no modifier is held but one is required', () => {
const cb = vi.fn();
renderHook(() => useShortcut('mod+s', cb));
fireEvent.keyDown(window, { key: 's' });
expect(cb).not.toHaveBeenCalled();
});
it('does NOT fire when the target is an <input>', () => {
const cb = vi.fn();
renderHook(() => useShortcut('mod+s', cb));
const input = document.createElement('input');
document.body.appendChild(input);
fireEvent.keyDown(input, { key: 's', ctrlKey: true });
document.body.removeChild(input);
expect(cb).not.toHaveBeenCalled();
});
it('does NOT fire when the target is a <textarea>', () => {
const cb = vi.fn();
renderHook(() => useShortcut('mod+s', cb));
const ta = document.createElement('textarea');
document.body.appendChild(ta);
fireEvent.keyDown(ta, { key: 's', ctrlKey: true });
document.body.removeChild(ta);
expect(cb).not.toHaveBeenCalled();
});
it('does NOT fire when the target is contentEditable', () => {
const cb = vi.fn();
renderHook(() => useShortcut('mod+s', cb));
const div = document.createElement('div');
Object.defineProperty(div, 'isContentEditable', { value: true, configurable: true });
document.body.appendChild(div);
fireEvent.keyDown(div, { key: 's', ctrlKey: true });
document.body.removeChild(div);
expect(cb).not.toHaveBeenCalled();
});
it('calls preventDefault when the shortcut matches', () => {
const cb = vi.fn();
renderHook(() => useShortcut('mod+s', cb));
const event = new KeyboardEvent('keydown', { key: 's', ctrlKey: true, bubbles: true, cancelable: true });
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
});
it('does NOT call preventDefault when the shortcut does NOT match', () => {
const cb = vi.fn();
renderHook(() => useShortcut('mod+s', cb));
const event = new KeyboardEvent('keydown', { key: 'x', ctrlKey: true, bubbles: true, cancelable: true });
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
});
it('supports the special "Tab" key as a non-modifier shortcut', () => {
const cb = vi.fn();
renderHook(() => useShortcut('mod+tab', cb));
fireEvent.keyDown(window, { key: 'Tab', ctrlKey: true });
expect(cb).toHaveBeenCalledTimes(1);
});
it('unbinds the listener on unmount', () => {
const cb = vi.fn();
const { unmount } = renderHook(() => useShortcut('mod+s', cb));
unmount();
fireEvent.keyDown(window, { key: 's', ctrlKey: true });
expect(cb).not.toHaveBeenCalled();
});
it('updates the bound callback when the function reference changes', () => {
const a = vi.fn();
const b = vi.fn();
const { rerender } = renderHook(({ cb }) => useShortcut('mod+s', cb), {
initialProps: { cb: a },
});
rerender({ cb: b });
fireEvent.keyDown(window, { key: 's', ctrlKey: true });
expect(a).not.toHaveBeenCalled();
expect(b).toHaveBeenCalledTimes(1);
});
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { cn } from '@/lib/utils';
describe('cn', () => {
it('merges class names', () => {
expect(cn('a', 'b')).toBe('a b');
});
it('deduplicates conflicting tailwind classes', () => {
expect(cn('p-2', 'p-4')).toBe('p-4');
});
it('ignores falsy values', () => {
expect(cn('a', false, null, undefined, '', 'b')).toBe('a b');
});
it('accepts conditional class objects', () => {
expect(cn('base', { active: true, disabled: false })).toBe('base active');
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ipc } from '@/lib/ipc';
import type { FileResult } from '@/types/ipc';
describe('ipc wrapper', () => {
beforeEach(() => {
window.electronAPI = {
file: {
read: vi.fn().mockResolvedValue('# hello'),
write: vi.fn().mockResolvedValue(undefined),
list: vi.fn().mockResolvedValue([]),
},
};
});
it('file.read returns ok result on success', async () => {
const result = await ipc.file.read('/foo.md');
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toBe('# hello');
}
});
it('file.read returns err result when channel throws', async () => {
window.electronAPI.file.read = vi.fn().mockRejectedValue(new Error('ENOENT'));
const result = await ipc.file.read('/missing.md');
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toBe('ENOENT');
}
});
it('file.read returns err result when channel missing', async () => {
delete (window.electronAPI.file as any).read;
const result = await ipc.file.read('/foo.md');
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('CHANNEL_MISSING');
}
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { renderMarkdown } from '@/lib/markdown';
describe('renderMarkdown', () => {
it('renders headings', () => {
const html = renderMarkdown('# Hello');
expect(html).toContain('<h1>Hello</h1>');
});
it('renders bold and italic', () => {
const html = renderMarkdown('**bold** and *italic*');
expect(html).toContain('<strong>bold</strong>');
expect(html).toContain('<em>italic</em>');
});
it('renders code blocks with language class', () => {
const html = renderMarkdown('```js\nconst x = 1;\n```');
expect(html).toContain('language-js');
});
it('renders mermaid blocks with a placeholder', () => {
const html = renderMarkdown('```mermaid\ngraph TD\nA-->B\n```');
expect(html).toContain('data-mermaid-source=');
});
it('sanitizes dangerous HTML', () => {
const html = renderMarkdown('<script>alert(1)</script>');
expect(html).not.toContain('<script>');
});
});
+32
View File
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import {
fadeIn,
slideInRight,
modalPop,
toastSpring,
sidebarToggle,
} from '@/lib/motion';
describe('motion presets', () => {
it('fadeIn is a valid transition object', () => {
expect(fadeIn.duration).toBeGreaterThan(0);
expect(fadeIn.ease).toBeDefined();
});
it('slideInRight uses translateX transform', () => {
expect(slideInRight.x).toBe('100%');
});
it('modalPop uses scale and opacity', () => {
expect(modalPop.scale).toBeDefined();
expect(modalPop.opacity).toBeDefined();
});
it('toastSpring has spring physics', () => {
expect(toastSpring.type).toBe('spring');
});
it('sidebarToggle animates width', () => {
expect(sidebarToggle.width.duration).toBeGreaterThan(0);
});
});
@@ -0,0 +1,145 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, act } from '@testing-library/react';
import { useCommandStore } from '@/stores/command-store';
import { useFileStore } from '@/stores/file-store';
import { useAppStore } from '@/stores/app-store';
import { useRegisterMenuCommands, useBridgeNativeMenu } from '@/lib/commands/register-menu-commands';
type Cleanup = () => void;
const menuListeners = new Map<string, (...args: unknown[]) => void>();
const menuOn = vi.fn(
(channel: string, callback: (...args: unknown[]) => void): Cleanup => {
menuListeners.set(channel, callback);
return () => {
if (menuListeners.get(channel) === callback) menuListeners.delete(channel);
};
}
);
vi.mock('@/lib/ipc', () => ({
ipc: {
menu: {
on: (channel: string, cb: (...args: unknown[]) => void) => menuOn(channel, cb),
},
},
}));
function fireMenu(channel: string, ...args: unknown[]): void {
const listener = menuListeners.get(channel);
if (!listener) throw new Error(`No listener for ${channel}`);
listener(...args);
}
function Harness() {
useRegisterMenuCommands();
useBridgeNativeMenu();
return null;
}
describe('useRegisterMenuCommands + useBridgeNativeMenu', () => {
beforeEach(() => {
menuListeners.clear();
menuOn.mockClear();
useCommandStore.setState({ handlers: {} });
useFileStore.setState({
tree: null,
rootPath: null,
expanded: new Set(),
openTabs: [],
activeTabId: null,
});
useAppStore.setState({
sidebarVisible: true,
previewVisible: true,
zenMode: false,
paneSizes: { sidebar: 20, editor: 50, preview: 30 },
});
});
it('registers file.open, file.save, file.closeTab handlers in the command store', () => {
render(<Harness />);
const handlers = useCommandStore.getState().handlers;
expect(typeof handlers['file.open']).toBe('function');
expect(typeof handlers['file.save']).toBe('function');
expect(typeof handlers['file.closeTab']).toBe('function');
});
it('registers tab.next / tab.prev and view.toggle* commands', () => {
render(<Harness />);
const handlers = useCommandStore.getState().handlers;
expect(typeof handlers['tab.next']).toBe('function');
expect(typeof handlers['tab.prev']).toBe('function');
expect(typeof handlers['view.toggleSidebar']).toBe('function');
expect(typeof handlers['view.togglePreview']).toBe('function');
});
it('view.togglePreview command flips previewVisible', () => {
render(<Harness />);
expect(useAppStore.getState().previewVisible).toBe(true);
act(() => useCommandStore.getState().dispatch('view.togglePreview'));
expect(useAppStore.getState().previewVisible).toBe(false);
});
it('view.toggleSidebar command flips sidebarVisible', () => {
render(<Harness />);
expect(useAppStore.getState().sidebarVisible).toBe(true);
act(() => useCommandStore.getState().dispatch('view.toggleSidebar'));
expect(useAppStore.getState().sidebarVisible).toBe(false);
});
it('file.closeTab with no active tab is a no-op (does not throw)', () => {
render(<Harness />);
expect(() => act(() => useCommandStore.getState().dispatch('file.closeTab'))).not.toThrow();
});
it('tab.next wraps from last tab to first', () => {
useFileStore.setState({
openTabs: [
{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false },
{ id: '/b.md', path: '/b.md', title: 'b.md', dirty: false },
],
activeTabId: '/b.md',
});
render(<Harness />);
act(() => useCommandStore.getState().dispatch('tab.next'));
expect(useFileStore.getState().activeTabId).toBe('/a.md');
});
it('tab.prev wraps from first tab to last', () => {
useFileStore.setState({
openTabs: [
{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false },
{ id: '/b.md', path: '/b.md', title: 'b.md', dirty: false },
],
activeTabId: '/a.md',
});
render(<Harness />);
act(() => useCommandStore.getState().dispatch('tab.prev'));
expect(useFileStore.getState().activeTabId).toBe('/b.md');
});
it('file-save IPC event dispatches file.save command', () => {
render(<Harness />);
act(() => fireMenu('file-save'));
// No assertion on side effect (saveActiveBuffer is async) but
// we can verify the command ran by checking no error was thrown.
expect(true).toBe(true);
});
it('toggle-preview IPC event flips previewVisible via the command', () => {
render(<Harness />);
expect(useAppStore.getState().previewVisible).toBe(true);
act(() => fireMenu('toggle-preview'));
expect(useAppStore.getState().previewVisible).toBe(false);
});
it('load-template-menu IPC event forwards the template name as args', () => {
let captured: unknown;
useCommandStore.getState().register('template.load', (args) => {
captured = args;
});
render(<Harness />);
act(() => fireMenu('load-template-menu', 'blog-post.md'));
expect(captured).toBe('blog-post.md');
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useAppStore } from '@/stores/app-store';
describe('useAppStore', () => {
beforeEach(() => {
localStorage.clear();
useAppStore.setState({
sidebarVisible: true,
previewVisible: true,
zenMode: false,
paneSizes: { sidebar: 20, editor: 50, preview: 30 },
});
});
it('toggles sidebar visibility', () => {
expect(useAppStore.getState().sidebarVisible).toBe(true);
useAppStore.getState().toggleSidebar();
expect(useAppStore.getState().sidebarVisible).toBe(false);
});
it('toggles preview visibility', () => {
useAppStore.getState().togglePreview();
expect(useAppStore.getState().previewVisible).toBe(false);
});
it('updates pane sizes', () => {
useAppStore.getState().setPaneSizes({ sidebar: 25, editor: 50, preview: 25 });
expect(useAppStore.getState().paneSizes).toEqual({ sidebar: 25, editor: 50, preview: 25 });
});
it('enables and disables zen mode', () => {
useAppStore.getState().setZenMode(true);
expect(useAppStore.getState().zenMode).toBe(true);
});
});
@@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useCommandStore } from '@/stores/command-store';
describe('useCommandStore — user bindings persistence', () => {
beforeEach(() => {
localStorage.clear();
useCommandStore.setState({
handlers: {},
userBindings: {},
});
});
it('starts with empty userBindings', () => {
expect(useCommandStore.getState().userBindings).toEqual({});
});
it('setUserBinding stores a combo for a command id', () => {
useCommandStore.getState().setUserBinding('file.save', 'mod+shift+s');
expect(useCommandStore.getState().userBindings['file.save']).toBe('mod+shift+s');
});
it('setUserBinding overwrites an existing binding for the same command', () => {
useCommandStore.getState().setUserBinding('file.save', 'mod+shift+s');
useCommandStore.getState().setUserBinding('file.save', 'mod+alt+s');
expect(useCommandStore.getState().userBindings['file.save']).toBe('mod+alt+s');
});
it('clearUserBinding removes a binding', () => {
useCommandStore.getState().setUserBinding('file.save', 'mod+shift+s');
useCommandStore.getState().clearUserBinding('file.save');
expect(useCommandStore.getState().userBindings['file.save']).toBeUndefined();
});
it('clearUserBinding on a missing id is a no-op', () => {
expect(() => useCommandStore.getState().clearUserBinding('does.not.exist')).not.toThrow();
});
it('getUserBinding returns the combo or undefined', () => {
useCommandStore.getState().setUserBinding('file.save', 'mod+shift+s');
expect(useCommandStore.getState().getUserBinding('file.save')).toBe('mod+shift+s');
expect(useCommandStore.getState().getUserBinding('does.not.exist')).toBeUndefined();
});
it('persists userBindings to localStorage and restores on next mount', async () => {
useCommandStore.getState().setUserBinding('file.save', 'mod+shift+s');
useCommandStore.getState().setUserBinding('view.toggleSidebar', 'mod+b');
// Force the persist middleware to flush
await useCommandStore.persist?.flush?.();
const stored = JSON.parse(localStorage.getItem('mc-command-store') ?? '{}');
expect(stored.state.userBindings).toEqual({
'file.save': 'mod+shift+s',
'view.toggleSidebar': 'mod+b',
});
// Handlers must NOT be persisted (functions aren't serializable)
expect(stored.state.handlers).toBeUndefined();
});
});
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useCommandStore, CommandHandler } from '@/stores/command-store';
describe('useCommandStore', () => {
beforeEach(() => {
useCommandStore.setState({ handlers: {} });
});
it('register stores a handler keyed by id', () => {
const handler = vi.fn();
useCommandStore.getState().register('file.open', handler);
expect(useCommandStore.getState().handlers['file.open']).toBe(handler);
});
it('register overwrites any existing handler for the same id', () => {
const a = vi.fn();
const b = vi.fn();
useCommandStore.getState().register('file.open', a);
useCommandStore.getState().register('file.open', b);
expect(useCommandStore.getState().handlers['file.open']).toBe(b);
});
it('dispatch invokes the registered handler with the provided args', () => {
const handler = vi.fn();
useCommandStore.getState().register('file.save', handler);
useCommandStore.getState().dispatch('file.save', { path: '/x.md' });
expect(handler).toHaveBeenCalledWith({ path: '/x.md' });
});
it('dispatch with no args calls the handler with undefined', () => {
const handler = vi.fn();
useCommandStore.getState().register('view.toggleSidebar', handler);
useCommandStore.getState().dispatch('view.toggleSidebar');
expect(handler).toHaveBeenCalledWith(undefined);
});
it('dispatch on an unregistered id is a no-op (no throw)', () => {
expect(() => useCommandStore.getState().dispatch('does.not.exist')).not.toThrow();
});
it('unregister removes a handler', () => {
const handler = vi.fn();
useCommandStore.getState().register('file.open', handler);
useCommandStore.getState().unregister('file.open');
expect(useCommandStore.getState().handlers['file.open']).toBeUndefined();
useCommandStore.getState().dispatch('file.open');
expect(handler).not.toHaveBeenCalled();
});
it('unregister on a missing id is a no-op', () => {
expect(() => useCommandStore.getState().unregister('does.not.exist')).not.toThrow();
});
it('get returns the handler or undefined', () => {
const handler: CommandHandler = () => {};
useCommandStore.getState().register('file.open', handler);
expect(useCommandStore.getState().get('file.open')).toBe(handler);
expect(useCommandStore.getState().get('does.not.exist')).toBeUndefined();
});
it('registerMany registers multiple handlers in one call', () => {
const a = vi.fn();
const b = vi.fn();
useCommandStore.getState().registerMany({
'file.open': a,
'file.save': b,
});
expect(useCommandStore.getState().handlers['file.open']).toBe(a);
expect(useCommandStore.getState().handlers['file.save']).toBe(b);
});
it('registerMany does not clear handlers that were already registered', () => {
const existing = vi.fn();
const added = vi.fn();
useCommandStore.getState().register('file.open', existing);
useCommandStore.getState().registerMany({ 'file.save': added });
expect(useCommandStore.getState().handlers['file.open']).toBe(existing);
expect(useCommandStore.getState().handlers['file.save']).toBe(added);
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useEditorStore } from '@/stores/editor-store';
describe('useEditorStore', () => {
beforeEach(() => {
useEditorStore.setState({ buffers: new Map(), activeId: null });
});
it('creates a new buffer with the given content', () => {
useEditorStore.getState().openBuffer('file-1', '/foo.md', '# hello');
const buf = useEditorStore.getState().buffers.get('file-1');
expect(buf?.content).toBe('# hello');
expect(buf?.path).toBe('/foo.md');
});
it('updates content and marks dirty', () => {
useEditorStore.getState().openBuffer('file-1', '/foo.md', '');
useEditorStore.getState().updateContent('file-1', 'new content');
const buf = useEditorStore.getState().buffers.get('file-1');
expect(buf?.content).toBe('new content');
expect(buf?.dirty).toBe(true);
});
it('marks a buffer clean after save', () => {
useEditorStore.getState().openBuffer('file-1', '/foo.md', '');
useEditorStore.getState().updateContent('file-1', 'x');
useEditorStore.getState().markSaved('file-1');
expect(useEditorStore.getState().buffers.get('file-1')?.dirty).toBe(false);
});
it('closes a buffer and removes it', () => {
useEditorStore.getState().openBuffer('file-1', '/foo.md', '');
useEditorStore.getState().closeBuffer('file-1');
expect(useEditorStore.getState().buffers.has('file-1')).toBe(false);
});
});
+419
View File
@@ -0,0 +1,419 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useEditorStore } from '@/stores/editor-store';
import { useFileStore, FileNode, OpenTab } from '@/stores/file-store';
import type { FileEntry } from '@/types/ipc';
vi.mock('@/lib/ipc', () => ({
ipc: {
file: {
list: vi.fn(),
read: vi.fn(),
pickFolder: vi.fn(),
pickFile: vi.fn(),
write: vi.fn(),
},
},
}));
import { ipc } from '@/lib/ipc';
const fakeList = ipc.file.list as ReturnType<typeof vi.fn>;
const fakeRead = ipc.file.read as ReturnType<typeof vi.fn>;
const fakePickFolder = ipc.file.pickFolder as ReturnType<typeof vi.fn>;
const fakePickFile = ipc.file.pickFile as ReturnType<typeof vi.fn>;
const fakeWrite = ipc.file.write as ReturnType<typeof vi.fn>;
function fakeFileEntry(name: string, isDirectory: boolean): FileEntry {
return { name, path: `/root/${name}`, isDirectory };
}
describe('useFileStore', () => {
beforeEach(() => {
useFileStore.setState({
tree: null,
rootPath: null,
expanded: new Set(),
openTabs: [],
activeTabId: null,
});
useEditorStore.setState({ buffers: new Map(), activeId: null });
fakeList.mockClear();
fakeRead.mockClear();
fakePickFolder.mockClear();
fakePickFile.mockClear();
fakeWrite.mockClear();
});
// --- openFolder ---
it('openFolder calls ipc.file.list and sets tree with root node and mapped children', async () => {
fakeList.mockResolvedValue({
ok: true,
data: [
fakeFileEntry('README.md', false),
fakeFileEntry('src', true),
],
});
await useFileStore.getState().openFolder('/root');
expect(fakeList).toHaveBeenCalledWith('/root');
const tree = useFileStore.getState().tree;
expect(tree).not.toBeNull();
expect(tree!.name).toBe('root');
expect(tree!.path).toBe('/root');
expect(tree!.isDirectory).toBe(true);
expect(tree!.loaded).toBe(true);
expect(tree!.children).toHaveLength(2);
expect(tree!.children![0].name).toBe('README.md');
expect(tree!.children![0].isDirectory).toBe(false);
expect(tree!.children![1].name).toBe('src');
expect(tree!.children![1].isDirectory).toBe(true);
// children of a directory node should be null (lazy)
expect(tree!.children![1].children).toBeNull();
expect(useFileStore.getState().rootPath).toBe('/root');
});
it('openFolder failure leaves state unchanged', async () => {
fakeList.mockResolvedValue({
ok: false,
error: { code: 'ENOENT', message: 'not found' },
});
await useFileStore.getState().openFolder('/nonexistent');
expect(useFileStore.getState().tree).toBeNull();
expect(useFileStore.getState().rootPath).toBeNull();
});
// --- loadChildren ---
it('loadChildren IPC-loads children of the matching directory and sets loaded: true', async () => {
fakeList.mockResolvedValue({
ok: true,
data: [fakeFileEntry('nested.md', false)],
});
// Set up a tree with a loaded parent directory
const tree: FileNode = {
name: 'root',
path: '/root',
isDirectory: true,
loaded: true,
children: [
{
name: 'src',
path: '/root/src',
isDirectory: true,
children: null,
loaded: false,
},
],
};
useFileStore.setState({ tree, rootPath: '/root' });
await useFileStore.getState().loadChildren('/root/src');
const srcNode = useFileStore.getState().tree!.children![0];
expect(srcNode.loaded).toBe(true);
expect(srcNode.children).toHaveLength(1);
expect(srcNode.children![0].name).toBe('nested.md');
});
it('loadChildren on a non-directory node is a no-op', async () => {
const tree: FileNode = {
name: 'root',
path: '/root',
isDirectory: true,
loaded: true,
children: [{ name: 'file.md', path: '/root/file.md', isDirectory: false, children: null }],
};
useFileStore.setState({ tree });
await useFileStore.getState().loadChildren('/root/file.md');
expect(fakeList).not.toHaveBeenCalled();
});
it('loadChildren on a non-existent path is a no-op', async () => {
const tree: FileNode = {
name: 'root',
path: '/root',
isDirectory: true,
loaded: true,
children: [{ name: 'src', path: '/root/src', isDirectory: true, children: null, loaded: true }],
};
useFileStore.setState({ tree });
await useFileStore.getState().loadChildren('/root/src/not-there');
// Should not throw
expect(fakeList).not.toHaveBeenCalled();
});
it('loadChildren is idempotent: does not re-fetch when already loaded', async () => {
fakeList.mockResolvedValue({
ok: true,
data: [{ name: 'src', path: '/root/src', isDirectory: true }],
});
await useFileStore.getState().openFolder('/root');
fakeList.mockClear();
fakeList.mockResolvedValue({
ok: true,
data: [{ name: 'foo.ts', path: '/root/src/foo.ts', isDirectory: false }],
});
await useFileStore.getState().loadChildren('/root/src');
const callsAfterFirst = fakeList.mock.calls.length;
expect(callsAfterFirst).toBe(1);
// Second call should be a no-op (loaded is true)
await useFileStore.getState().loadChildren('/root/src');
expect(fakeList.mock.calls.length).toBe(callsAfterFirst);
});
// --- toggleExpanded ---
it('toggleExpanded adds and removes paths from the expanded Set', () => {
const { toggleExpanded } = useFileStore.getState();
toggleExpanded('/root/src');
expect(useFileStore.getState().expanded.has('/root/src')).toBe(true);
toggleExpanded('/root/src');
expect(useFileStore.getState().expanded.has('/root/src')).toBe(false);
});
// --- openFile ---
it('openFile calls ipc.file.read, opens an editor buffer, adds a tab, and sets it active', async () => {
fakeRead.mockResolvedValue({ ok: true, data: '# hello world' });
await useFileStore.getState().openFile('/root/README.md');
expect(fakeRead).toHaveBeenCalledWith('/root/README.md');
// Editor buffer should be open
const buf = useEditorStore.getState().buffers.get('/root/README.md');
expect(buf).not.toBeUndefined();
expect(buf!.content).toBe('# hello world');
expect(buf!.dirty).toBe(false);
// Tab should be added and active
const tabs = useFileStore.getState().openTabs;
expect(tabs).toHaveLength(1);
expect(tabs[0].path).toBe('/root/README.md');
expect(tabs[0].title).toBe('README.md');
expect(tabs[0].dirty).toBe(false);
expect(useFileStore.getState().activeTabId).toBe('/root/README.md');
});
it('openFile on an already-open file does not re-read — just activates the existing tab', async () => {
fakeRead.mockResolvedValue({ ok: true, data: '# original' });
// Open once
await useFileStore.getState().openFile('/root/README.md');
expect(fakeRead).toHaveBeenCalledTimes(1);
// Mark it dirty to prove dirty flag is preserved
useEditorStore.getState().updateContent('/root/README.md', '# modified');
// Open again
await useFileStore.getState().openFile('/root/README.md');
expect(fakeRead).toHaveBeenCalledTimes(1); // no second read
expect(useEditorStore.getState().buffers.get('/root/README.md')!.content).toBe('# modified');
expect(useFileStore.getState().openTabs[0].dirty).toBe(true); // dirty flag preserved
expect(useFileStore.getState().activeTabId).toBe('/root/README.md');
});
it('openFile failure does nothing', async () => {
fakeRead.mockResolvedValue({
ok: false,
error: { code: 'ENOENT', message: 'not found' },
});
await useFileStore.getState().openFile('/root/missing.md');
expect(useFileStore.getState().openTabs).toHaveLength(0);
expect(useEditorStore.getState().buffers.size).toBe(0);
});
// --- closeTab ---
it('closeTab removes the tab and updates activeTabId to the previous neighbor', async () => {
fakeRead.mockResolvedValue({ ok: true, data: 'a' });
fakeRead.mockResolvedValue({ ok: true, data: 'b' });
await useFileStore.getState().openFile('/root/a.md');
await useFileStore.getState().openFile('/root/b.md');
// Tab order: [a.md, b.md], active = b.md
useFileStore.getState().closeTab('/root/b.md');
const tabs = useFileStore.getState().openTabs;
expect(tabs).toHaveLength(1);
expect(tabs[0].id).toBe('/root/a.md');
expect(useFileStore.getState().activeTabId).toBe('/root/a.md');
});
it('closeTab on the active tab when it is the only tab sets activeTabId to null', async () => {
fakeRead.mockResolvedValue({ ok: true, data: '# only' });
await useFileStore.getState().openFile('/root/only.md');
useFileStore.getState().closeTab('/root/only.md');
expect(useFileStore.getState().openTabs).toHaveLength(0);
expect(useFileStore.getState().activeTabId).toBeNull();
});
// --- setActiveTab ---
it('setActiveTab updates activeTabId', async () => {
fakeRead.mockResolvedValue({ ok: true, data: 'a' });
fakeRead.mockResolvedValue({ ok: true, data: 'b' });
await useFileStore.getState().openFile('/root/a.md');
await useFileStore.getState().openFile('/root/b.md');
useFileStore.getState().setActiveTab('/root/a.md');
expect(useFileStore.getState().activeTabId).toBe('/root/a.md');
});
// --- markTabClean / markTabDirty ---
it('markTabClean sets dirty to false on the matching tab', async () => {
fakeRead.mockResolvedValue({ ok: true, data: '# doc' });
await useFileStore.getState().openFile('/root/doc.md');
useEditorStore.getState().updateContent('/root/doc.md', '# changed');
useFileStore.getState().markTabClean('/root/doc.md');
expect(useFileStore.getState().openTabs[0].dirty).toBe(false);
});
it('markTabDirty sets dirty to true on the matching tab', async () => {
fakeRead.mockResolvedValue({ ok: true, data: '# doc' });
await useFileStore.getState().openFile('/root/doc.md');
useFileStore.getState().markTabDirty('/root/doc.md');
expect(useFileStore.getState().openTabs[0].dirty).toBe(true);
});
// --- reorderTabs ---
it('reorderTabs moves a tab from one index to another', async () => {
fakeRead.mockResolvedValue({ ok: true, data: 'a' });
fakeRead.mockResolvedValue({ ok: true, data: 'b' });
fakeRead.mockResolvedValue({ ok: true, data: 'c' });
await useFileStore.getState().openFile('/root/a.md');
await useFileStore.getState().openFile('/root/b.md');
await useFileStore.getState().openFile('/root/c.md');
// [a, b, c] — move c to front
useFileStore.getState().reorderTabs(2, 0);
const tabs = useFileStore.getState().openTabs;
expect(tabs.map((t) => t.title)).toEqual(['c.md', 'a.md', 'b.md']);
});
// --- openFolderDialog ---
it('openFolderDialog calls pickFolder, then openFolder on the returned path', async () => {
fakePickFolder.mockResolvedValue({ ok: true, data: '/projects/myapp' });
fakeList.mockResolvedValue({
ok: true,
data: [fakeFileEntry('README.md', false)],
});
await useFileStore.getState().openFolderDialog();
expect(fakePickFolder).toHaveBeenCalled();
expect(fakeList).toHaveBeenCalledWith('/projects/myapp');
expect(useFileStore.getState().rootPath).toBe('/projects/myapp');
});
it('openFolderDialog on cancelled (null) is a no-op', async () => {
fakePickFolder.mockResolvedValue({ ok: true, data: null });
await useFileStore.getState().openFolderDialog();
expect(fakeList).not.toHaveBeenCalled();
expect(useFileStore.getState().tree).toBeNull();
});
// --- openFileDialog ---
it('openFileDialog calls pickFile, then openFile on the returned path', async () => {
fakePickFile.mockResolvedValue({ ok: true, data: '/root/README.md' });
fakeRead.mockResolvedValue({ ok: true, data: '# hello' });
await useFileStore.getState().openFileDialog();
expect(fakePickFile).toHaveBeenCalled();
expect(fakeRead).toHaveBeenCalledWith('/root/README.md');
const tabs = useFileStore.getState().openTabs;
expect(tabs).toHaveLength(1);
expect(tabs[0].path).toBe('/root/README.md');
});
it('openFileDialog on cancelled (null) is a no-op', async () => {
fakePickFile.mockResolvedValue({ ok: true, data: null });
await useFileStore.getState().openFileDialog();
expect(fakeRead).not.toHaveBeenCalled();
expect(useFileStore.getState().openTabs).toHaveLength(0);
});
// --- saveActiveBuffer ---
it('saveActiveBuffer with no active tab returns false', async () => {
const result = await useFileStore.getState().saveActiveBuffer();
expect(result).toBe(false);
expect(fakeWrite).not.toHaveBeenCalled();
});
it('saveActiveBuffer with active buffer calls write, then marks saved and clean, returns true', async () => {
fakeRead.mockResolvedValue({ ok: true, data: '# content' });
await useFileStore.getState().openFile('/root/doc.md');
fakeWrite.mockResolvedValue({ ok: true, data: undefined });
const result = await useFileStore.getState().saveActiveBuffer();
expect(fakeWrite).toHaveBeenCalledWith('/root/doc.md', '# content');
expect(useEditorStore.getState().buffers.get('/root/doc.md')!.dirty).toBe(false);
expect(useFileStore.getState().openTabs[0].dirty).toBe(false);
expect(result).toBe(true);
});
it('saveActiveBuffer on write failure returns false and does NOT mark clean', async () => {
fakeRead.mockResolvedValue({ ok: true, data: '# content' });
await useFileStore.getState().openFile('/root/doc.md');
// Mark the buffer dirty and sync the tab dirty flag before attempting save
useEditorStore.getState().updateContent('/root/doc.md', '# modified');
useFileStore.getState().markTabDirty('/root/doc.md');
fakeWrite.mockResolvedValue({ ok: false, error: { code: 'EIO', message: 'write error' } });
const result = await useFileStore.getState().saveActiveBuffer();
expect(result).toBe(false);
expect(useEditorStore.getState().buffers.get('/root/doc.md')!.dirty).toBe(true);
expect(useFileStore.getState().openTabs[0].dirty).toBe(true);
});
// --- persistence ---
it('persists rootPath to localStorage and restores on next mount', async () => {
fakeList.mockResolvedValue({
ok: true,
data: [{ name: 'a.md', path: '/root/a.md', isDirectory: false }],
});
await useFileStore.getState().openFolder('/root');
// Force the persist middleware to flush to localStorage
await useFileStore.persist?.flush?.();
const stored = JSON.parse(localStorage.getItem('mc-file-store') ?? '{}');
expect(stored.state.rootPath).toBe('/root');
// Ensure non-persisted fields are NOT in storage
expect(stored.state.tree).toBeUndefined();
expect(stored.state.openTabs).toBeUndefined();
expect(stored.state.expanded).toBeUndefined();
});
});
+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { usePreviewStore } from '@/stores/preview-store';
describe('usePreviewStore', () => {
beforeEach(() => {
vi.useFakeTimers();
usePreviewStore.setState({ scrollRatio: 0, source: '' });
});
it('debounces source updates (300 ms)', () => {
usePreviewStore.getState().setSource('# a');
usePreviewStore.getState().setSource('# b');
usePreviewStore.getState().setSource('# c');
expect(usePreviewStore.getState().source).toBe('');
vi.advanceTimersByTime(300);
expect(usePreviewStore.getState().source).toBe('# c');
});
it('updates scroll ratio', () => {
usePreviewStore.getState().setScrollRatio(0.5);
expect(usePreviewStore.getState().scrollRatio).toBe(0.5);
});
});
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
root: path.join(__dirname, 'src/renderer'),
base: './',
build: {
outDir: path.join(__dirname, 'dist/renderer'),
emptyOutDir: true,
sourcemap: true,
},
resolve: {
alias: {
'@': path.join(__dirname, 'src/renderer'),
},
},
server: {
port: 5173,
strictPort: true,
},
});
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/renderer/test/setup.ts'],
include: ['tests/**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
include: ['src/renderer/**/*.{ts,tsx}'],
exclude: ['src/renderer/test/**', 'src/renderer/**/*.test.*'],
},
},
resolve: {
alias: {
'@': path.join(__dirname, 'src/renderer'),
},
},
});