Skip to content

ecogs-sys/MDViewer

Repository files navigation

MDViewer

A cross-platform desktop application for browsing and reading markdown files generated by AI agentic workflows. Read-only — no editing, no authoring. Open a folder, navigate the file tree, and read fully rendered markdown.

Latest Release Downloads Platform Electron React TypeScript


Download

One-click installers for the latest version:

⬇ Windows
MDViewer-windows-setup.exe
⬇ macOS
MDViewer-mac.dmg
⬇ Linux
MDViewer-linux.AppImage

All releases & changelog →

macOS users: the DMG is unsigned (no Apple Developer account). On first launch, right-click MDViewer.app → Open, then click Open in the dialog. After that it launches normally.


image

Features

  • Two-panel layout — collapsible file tree on the left, rendered markdown on the right
  • Full markdown rendering powered by unified / remark / rehype:
    • GFM (tables, strikethrough, task lists)
    • Syntax-highlighted code blocks via highlight.js
    • LaTeX math via KaTeX ($inline$ and $$display$$)
    • GitHub-style callouts: > [!NOTE], > [!WARNING], > [!TIP], > [!IMPORTANT], > [!CAUTION]
    • YAML frontmatter parsed and shown in a collapsible panel
  • Mermaid diagrams rendered to SVG, theme-aware
  • OS-adaptive theme — follows your system dark/light mode by default, with a manual toggle (Dark / Light / System)
  • Persistent state — remembers the last folder you opened, your theme choice, etc. (via electron-store)
  • Live file watching — tree and preview refresh automatically when files change on disk (useful when an AI agent is writing files in real time)
  • Fuzzy file searchCtrl/Cmd + P opens an overlay to jump to any .md in the open folder
  • Keyboard navigationCtrl/Cmd + O opens a folder from the menu

Tech Stack

Layer Choice
Desktop framework Electron 30
Frontend React 18 + TypeScript
Build tool Vite 5 + vite-plugin-electron
Markdown pipeline unified / remark / rehype
Diagrams mermaid.js
Math KaTeX
Code highlighting rehype-highlight (highlight.js)
Persistence electron-store
Packaging electron-builder
Tests vitest + @testing-library/react

Prerequisites

  • Node.js 18+ (Node 20 LTS recommended)
  • npm (ships with Node)
  • Windows users: for packaged builds, enable Developer Mode OR run the build terminal as Administrator so electron-builder can extract winCodeSign symlinks. See Troubleshooting.

Install

git clone https://github.com/ecogs-sys/MDViewer.git
cd MDViewer
npm install

Development

Run the app in hot-reload dev mode:

npm run dev

This launches Electron with the renderer served by Vite. Changes to React/CSS hot-reload instantly; changes to main/preload trigger a quick Electron restart.

Other dev scripts:

npm test            # Run the full test suite (vitest, jsdom + node projects)
npm run test:ui     # Run vitest with the UI
npm run coverage    # Run tests with V8 coverage
npm run lint        # ESLint

Building Installers

The full build runs TypeScript → Vite → electron-builder for your current platform:

npm run build

Platform-specific scripts:

npm run build:win     # Windows NSIS installer (.exe)
npm run build:mac     # macOS DMG (.dmg)
npm run build:linux   # Linux AppImage (.AppImage)

Output goes to release/<version>/:

Platform Artifact
Windows release/<version>/MDViewer-windows-setup.exe (NSIS installer)
release/<version>/win-unpacked/MDViewer.exe (portable, no install)
macOS release/<version>/MDViewer-mac.dmg
Linux release/<version>/MDViewer-linux.AppImage

Skip the installer (faster, no Developer Mode required)

For local testing on Windows, you can build only the unpacked folder — much faster and avoids the winCodeSign symlink issue:

npx vite build
npx electron-builder --dir --win

Then run release\0.0.0\win-unpacked\MDViewer.exe directly.


Running

From source (dev)

npm run dev

Installed (Windows)

After running the NSIS installer, launch MDViewer from the Start Menu or Desktop shortcut.

Portable (Windows)

Double-click release\0.0.0\win-unpacked\MDViewer.exe — no installation needed; copy the whole win-unpacked folder anywhere.

First launch

You'll see a Welcome Screen with a single "Open Folder" button. Pick the folder containing your markdown files. MDViewer remembers it, so subsequent launches go straight to the file tree.

Opening DevTools (for troubleshooting)

If you need to inspect renderer errors in a packaged build, launch with --devtools (DevTools opens in a detached window automatically):

# Windows
MDViewer.exe --devtools

# macOS
open -a MDViewer --args --devtools

# Linux (AppImage)
./MDViewer-0.1.0.AppImage --devtools

Equivalent via env var:

MDVIEWER_DEVTOOLS=1 MDViewer.exe

You can also open DevTools any time from the View → Toggle Developer Tools menu item.


Releasing (maintainers)

Cross-platform installers are built automatically by GitHub Actions on every version tag and attached to a GitHub Release.

# bump the version in package.json (e.g. 0.1.0 → 0.1.1)
npm version patch    # or `minor` / `major`

# push the new tag — this triggers the workflow
git push --follow-tags

The workflow (.github/workflows/release.yml) runs in parallel on:

Runner Builds
ubuntu-latest MDViewer-linux.AppImage
macos-latest MDViewer-mac.dmg (unsigned)
windows-latest MDViewer-windows-setup.exe (NSIS)

Filenames are version-less so the /releases/latest/download/<file> URLs in the Download section above always point at the newest build. All artifacts are uploaded to the GitHub Release matching the tag. GitHub Actions is free for public repositories across all runner types.

macOS unsigned-DMG warning

Builds are not Apple-notarized. On first launch, macOS users will see "MDViewer can't be opened because Apple cannot check it for malicious software". They need to right-click → Open (or xattr -d com.apple.quarantine MDViewer.app from Terminal).

To sign with an Apple Developer ID, add CSC_LINK, CSC_KEY_PASSWORD, APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, and APPLE_TEAM_ID as repo secrets and electron-builder will pick them up automatically.


Architecture

MDViewer follows the standard Electron split:

┌─────────────────────────────────────────────────────────┐
│                  Main Process (Node.js)                  │
│  - BrowserWindow lifecycle, native menu                  │
│  - File-system access (walkTree, readFile)               │
│  - electron-store preferences                            │
│  - Native folder picker via dialog API                   │
└───────────────────┬─────────────────────────────────────┘
                    │ IPC (typed channels in src/shared/types.ts)
┌───────────────────┴─────────────────────────────────────┐
│              Renderer Process (React, sandboxed)         │
│  - All UI                                                │
│  - unified/remark/rehype markdown pipeline               │
│  - Mermaid + KaTeX client-side rendering                 │
│  - NO direct fs access — everything via IPC              │
└─────────────────────────────────────────────────────────┘

IPC channels

Channel Direction Purpose
dialog:open-folder renderer ⇄ main Native folder picker
fs:read-tree renderer ⇄ main Recursive walk for .md files
fs:read-file renderer ⇄ main UTF-8 read
fs:watch-start renderer → main Begin watching a folder
fs:watch-event main → renderer Filesystem change notification
prefs:get / prefs:set renderer ⇄ main electron-store wrapper
menu:open-folder main → renderer Native menu "Open Folder…" click

Source layout

src/
├── shared/types.ts             # Types + IPC channel constants (used by both sides)
├── main/                       # Electron main process (Node)
│   ├── index.ts                # Window creation, menu, lifecycle
│   ├── ipc-handlers.ts         # All ipcMain.handle() registrations
│   ├── file-service.ts         # Recursive .md walker, file reader
│   └── preferences.ts          # electron-store wrapper
├── preload/index.ts            # contextBridge: exposes window.electronAPI
└── renderer/src/               # React app
    ├── App.tsx                 # Root layout, theme + selected file state
    ├── components/             # Toolbar, FileTree, MarkdownPreview, ...
    ├── hooks/                  # useTheme, useFileTree, useFileContent
    ├── lib/                    # markdown-pipeline, mermaid-renderer, frontmatter, callouts-plugin
    └── styles/                 # global.css (theme tokens) + markdown.css

Markdown pipeline

Built once at module load in markdown-pipeline.ts:

remark-parse
  → remark-gfm            (tables, strikethrough, task lists)
  → remark-math           ($…$ and $$…$$)
  → remark-rehype         (MDAST → HAST)
  → rehype-highlight      (syntax highlighting)
  → rehype-katex          (LaTeX → HTML)
  → calloutsPlugin        (> [!NOTE] → styled <div>)
  → rehype-stringify      (HAST → HTML)

Frontmatter is extracted upstream by a small browser-safe parser in src/renderer/src/lib/frontmatter.ts (we don't use gray-matter because it's Node-only — see Notable gotchas).

Mermaid is rendered post-HTML: after React sets innerHTML, mermaid-renderer.ts scans for <code class="language-mermaid"> blocks and replaces them with rendered SVG, re-running when the theme changes.


Notable gotchas

These are baked into the codebase — but if you fork or restructure, watch out:

  1. Preload must be .cjs, not .mjs. package.json has "type": "module", so any .mjs file is loaded as ESM by Node. vite-plugin-electron outputs the preload using CommonJS require(), so it must have a .cjs extension or it will fail with require is not defined. See vite.config.ts (preload.vite.build.rollupOptions.output.entryFileNames: 'preload.cjs').

  2. ESM main has no __dirname. The main bundle is ESM (import syntax), where __dirname is undefined. We use app.getAppPath() instead — it works in ESM, CJS, dev, packaged-asar, and packaged-unpacked modes.

  3. Don't import gray-matter from the renderer. It does require("fs") at the top of one of its modules and breaks the browser bundle. Use src/renderer/src/lib/frontmatter.ts instead.

  4. if (!prefsLoaded) return null + show: false is dangerous. If anything in the prefs-loading effect throws, the window never gets a non-empty paint, ready-to-show never fires, and the window stays invisible forever. App.tsx wraps the prefs call in .catch().finally() for this reason, and main/index.ts has a did-fail-load handler that forces the window visible as a safety net.


Troubleshooting

Windows: Cannot create symbolic link : A required privilege is not held by the client

electron-builder extracts winCodeSign archives that contain macOS symlinks. Windows requires either Developer Mode or Administrator rights to create symlinks. Fix:

  • Settings → Privacy & security → For developers → Developer Mode: ON
  • OR run your terminal as Administrator

Alternatively, skip the installer and use --dir (see Skip the installer).

Window appears in Task Manager but no UI shows

This was the original Windows build bug, now fixed. If you ever see it again, the cause is one of:

  • Preload failed to load → check the renderer Console (uncomment the openDevTools in main/index.ts)
  • __dirname undefined in ESM main → use app.getAppPath() for all path joins
  • A renderer-bundled module uses require() → check the browser bundle for require(

Build succeeds but package.json got truncated

If npm run build:win fails mid-way at the NSIS step, electron-builder may leave behind the slim asar package.json instead of restoring the original. The full package.json lives in this repo's git history — git checkout package.json restores it. Prefer npx electron-builder --dir --win for local testing to avoid this.


License

See LICENSE in the repo root.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors