Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,27 @@ All notable new features and critical fixes for MoilStack .md.

---

## [1.1.1] - 2026-07-25

### Added
- **Split view with synchronized scrolling** — a new Split mode sits between Edit and Preview; `Ctrl+\`` now cycles Edit → Split → Preview, and scrolling either pane scrolls the other to the matching line.
- **Focus mode** — a new header button hides both sidebars, maximizes the window, and switches to Split view for distraction-free writing; toggling it off restores the previous layout.
- **"Open first file in Explorer" launch option** — Settings → Editor → On Launch adds a third choice that opens the top-most file in the active Explorer folder on startup (falls back to the Recents screen if none is open).
- **Persisted untitled draft** — the untitled buffer is now continuously saved to `<userData>/backups/untitled-draft.md` instead of only being recovered after a crash, so it's restored exactly as left even after a normal close.
- **Version History panel** — right-click any file → "Version History…" to browse and restore prior backup snapshots.
- **"Update" indicator in the header** — an "Update" button next to Toggle Explorer/AI Assistant appears when a newer GitHub release is published.

### Changed
- **Automatic backups now cover every save, not just AI edits** — manual saves and autosave both snapshot the prior content first.
- **New-release toast repositioned and auto-hides** — moved to bottom-center and now fades out on its own after a few seconds.

### Fixed
- **File preview stripping non-frontmatter text** — a leading `---`…`---` block is now only treated as YAML frontmatter if it actually contains YAML.
- **Explorer tag pills not updating immediately after save** — saving now refreshes the sidebar's `#tag` pills right away instead of requiring a minimize/restore.
- **Pre-update untitled drafts orphaned by the new file-backed draft storage** — a one-time migration recovers any draft left behind in `localStorage` from before the update.

---

## [1.1.0] - 2026-07-18

### Added
Expand Down
39 changes: 22 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# MoilStack .md (markdown) — Privacy-First Markdown AI Editor & Viewer
# MoilStack .md (markdown) — AI-Powered Markdown Editor with Split View & Version History

[![Version](https://img.shields.io/github/v/release/moilstack/moilstack-md?label=version&include_prereleases)](https://github.com/moilstack/moilstack-md/releases)
![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS%20(soon)-blue)
Expand All @@ -8,7 +8,7 @@ An open-source desktop **Markdown AI editor** and standalone markdown viewer bui



![MoilStack .md](assets/screenshot-editor.png)
![MoilStack .md — Split view with synchronized scrolling](assets/01_moilstack-md_Split.png)

## Download

Expand All @@ -22,25 +22,32 @@ Pre-built installers are available on the [Releases page](https://github.com/moi

## Features

- **Dual-pane editor** — syntax-highlighted editor with Markdown preview (`Ctrl+\`` to toggle)
- **Edit, Split, and Preview modes** — syntax-highlighted editor, side-by-side split view with synchronized scrolling, and a clean preview pane, cycled with `Ctrl+\``
- **Focus mode** — one click hides both sidebars, maximizes the window, and switches to Split view for distraction-free writing
- **File explorer** — browse, create, rename, and open `.md` files from a folder, with Multi-level, Root-only, and Custom (no-folder) sidebar modes
- **Global search** — find filenames and in-file content across the open folder (`Ctrl+Shift+F`), with tag search via `#tag` or `tag:name`
- **AI Assistant** — ask the AI to edit your document, answer questions, or improve your writing
- **Smart AI editing** — document edits are applied silently and instantly; informational answers stream as chat
- **Undo AI edits** — every AI document change is reversible with the Undo button or `Ctrl+Z`
- **Visual table builder** — insert Markdown tables with a point-and-click grid editor
- **File labels & tags** — colour-tag files in the explorer for quick navigation, or add searchable tags stored in YAML frontmatter
- **File backups** — automatic snapshots to the app's user data folder before every AI edit
- **File backups & Version History** — every save (manual, autosave, or AI edit) is snapshotted to the app's user data folder; right-click a file → "Version History…" to browse and restore prior versions
- **File trash** — delete files to the OS Recycle Bin from the context menu
- **Multi-model support** — connect any OpenAI-compatible API (Groq, OpenAI, Mistral, Together AI) or run Ollama locally
- **Export to PDF** — one-click export via native save dialog
- **Dark / light theme** — persisted across sessions
- **Configurable editor** — font size and font family settings
- **Update notifications** — an "Update" button appears in the header when a newer release is available

## Screens
![AI assistant editing](assets/screenshot-preview.png)
![AI assistant editing](assets/screenshot-ai-edit.png)
![Table builder](assets/screenshot-table-builder.png)
### 01 - Explorer with treeview mode
![Explorer with treeview](assets/02_moilstack-md_Tree.png)
### 02 - Ask / Edit using AI
![AI assistant](assets/03_moilstack-md_AI.png)
### 03 - Global search with tag filtering
![Search](assets/04_moilstack-md_Search.png)
### 04 - Formatting menu with Add Tags
![Edit Options](assets/05_moilstack-md_Edit.png)


## Getting Started
Expand Down Expand Up @@ -125,7 +132,7 @@ When the AI assistant processes an edit, changes are applied silently and instan

### Safety & Version Controls
* **Instant Undo:** Every single document modification made by the AI can be instantly reversed using the UI Undo button (↺) or by pressing `Ctrl+Z`.
* **Automatic Snapshots:** For absolute safety, MoilStack .md saves automatic file backups to the app's user data directory (not your workspace folder) before any AI processing occurs.
* **Automatic Snapshots:** For absolute safety, MoilStack .md saves automatic file backups to the app's user data directory (not your workspace folder) before any AI processing occurs, and also before every manual save and autosave.
* **Scoped Selections:** Highlight specific sentences or code lines inside the editor pane before typing a prompt to limit the AI assistant's scope exclusively to that text selection.


Expand All @@ -135,7 +142,7 @@ When the AI assistant processes an edit, changes are applied silently and instan
|---|---|
| `Ctrl+S` | Save file |
| `Ctrl+Z` | Undo (AI edits first, then native undo) |
| `Ctrl+\`` | Toggle Edit / Preview mode |
| `Ctrl+\`` | Cycle Edit → Split → Preview mode |
| `Ctrl+O` | Open folder picker |
| `Ctrl+N` | New untitled file (in-memory) |
| `Ctrl+Shift+N` | New file on disk in Explorer's active folder |
Expand All @@ -145,17 +152,15 @@ When the AI assistant processes an edit, changes are applied silently and instan
| `Alt+Enter` | New line in chat input |
| `Escape` | Close any open modal or dropdown |

## File Backups
## File Backups & Version History

Every time the AI edits your document, MoilStack .md saves a backup to the app's user data directory, keyed to your file's parent folder — not inside your workspace:
Every time your document is written to disk — an AI edit, `Ctrl+S`, or autosave — MoilStack .md automatically stores a version, keeping the last **10 backups per file**. Empty or duplicate-content snapshots are skipped so the 10 slots aren't wasted.

```
<app userData>/backups/<folder-name>-<folder-hash>/
```

On Windows this is typically under `%APPDATA%`, on macOS under `~/Library/Application Support`, and on Linux under `~/.config`.
Right-click any file in the Explorer and choose **Version History…** to view and restore past versions:

Files are named `<filename>_<timestamp>.md` and the last **10 backups per file** are kept automatically. Use these to recover from any unwanted AI changes.
* A two-pane view lists every snapshot by date/time on the left; click one to see its raw text on the right.
* A pinned **Current** entry always shows the file's live content for comparison, and is selected by default when the panel opens.
* **Restore This Version** loads the selected snapshot back in (with a confirm step) and is disabled while Current is selected — restoring backs up whatever's there first, so nothing is lost either way.



Expand Down
Binary file added assets/01_moilstack-md_Split.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/02_moilstack-md_Tree.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/03_moilstack-md_AI.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/04_moilstack-md_Search.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/05_moilstack-md_Edit.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "moilstack-md",
"version": "1.1.0",
"version": "1.1.1",
"description": "Markdown editor with AI assistance by MoilStack",
"main": "src/main/index.js",
"scripts": {
Expand Down Expand Up @@ -39,7 +39,7 @@
"zip"
],
"icon": "src/assets/icon.ico",
"artifactName": "moilstack-md.${ext}"
"artifactName": "moilstack-md-${version}.${ext}"
},
"nsis": {
"oneClick": false,
Expand Down
161 changes: 147 additions & 14 deletions src/main/ipc.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,15 @@ function _stripBOM(content) {
return content.charCodeAt(0) === 0xFEFF ? content.slice(1) : content
}

/**
* Return true if the text between a pair of `---` delimiters actually looks
* like YAML (at least one `key: value` or `key:` line) rather than being
* ordinary prose that just happens to sit between two horizontal rules.
*/
function _looksLikeYaml(fmText) {
return /^[ \t]*[A-Za-z0-9_-]+:([ \t]|$)/m.test(fmText)
}

/**
* Extract a plain-text first line from file content.
* Skips YAML frontmatter and strips basic Markdown syntax.
Expand All @@ -157,7 +166,7 @@ function _extractFirstLine(content) {
let text = _stripBOM(content)
if (text.startsWith('---')) {
const fmEnd = text.indexOf('\n---', 3)
if (fmEnd !== -1) text = text.slice(fmEnd + 4)
if (fmEnd !== -1 && _looksLikeYaml(text.slice(3, fmEnd))) text = text.slice(fmEnd + 4)
}
for (const line of text.split('\n')) {
const stripped = line
Expand Down Expand Up @@ -189,8 +198,9 @@ function _extractTags(content) {
const fmEnd = content.indexOf('\n---', 3)
if (fmEnd === -1) return []

const tags = new Set()
const fm = content.slice(3, fmEnd)
if (!_looksLikeYaml(fm)) return []
const tags = new Set()
const inlineMatch = fm.match(/^tags:\s*\[([^\]]+)\]/m)
if (inlineMatch) {
for (const t of inlineMatch[1].split(',')) {
Expand Down Expand Up @@ -873,31 +883,63 @@ function registerIpcHandlers() {
})

/* ── Backup ──────────────────────────────────────────────────────────
backup:write — snapshot the current file content before an AI edit.
backup:write — snapshot a file's content before it gets overwritten,
whether by an AI edit or a plain save/autosave.
Stored under <userData>/backups/<folderName>-<hash8>/ to avoid
cluttering the user's workspace with .markflow directories.
Keeps the 10 most recent backups per file; older ones are pruned.
No distinction is made between what triggered a snapshot — just a
rolling list of "<basename>_<isoTimestamp><ext>" files, newest last.
──────────────────────────────────────────────────────────────── */

function _backupDir(filePath) {
const folderPath = path.dirname(filePath)
const folderKey = path.basename(folderPath) + '-' +
crypto.createHash('sha1').update(folderPath).digest('hex').slice(0, 8)
return path.join(app.getPath('userData'), 'backups', folderKey)
}

// Parses "<basename>_<isoTimestamp><ext>" back into a Date-parseable ISO string.
function _parseBackupFilename(filename, basename, ext) {
const tsRaw = filename.slice(basename.length + 1, filename.length - ext.length)
const m = tsRaw.match(/^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/)
if (!m) return null
return `${m[1]}T${m[2]}:${m[3]}:${m[4]}.${m[5]}Z`
}

ipcMain.handle('backup:write', async (_e, { filePath, content }) => {
try {
const folderPath = path.dirname(filePath)
const folderKey = path.basename(folderPath) + '-' +
crypto.createHash('sha1').update(folderPath).digest('hex').slice(0, 8)
const dir = path.join(app.getPath('userData'), 'backups', folderKey)
// An empty snapshot is never useful to restore — skip it rather than
// burning one of the 10 rolling slots on it.
if (!content || !content.trim()) return { ok: true, skipped: true }

const dir = _backupDir(filePath)
await fs.mkdir(dir, { recursive: true })

const ext = path.extname(filePath) || '.md'
const basename = path.basename(filePath, ext)
const ext = path.extname(filePath) || '.md'
const basename = path.basename(filePath, ext)

const all = await fs.readdir(dir).catch(() => [])
const mine = all
.filter(f => f.startsWith(basename + '_') && f.endsWith(ext))
.sort() // ISO timestamps sort correctly as strings

// Skip if identical to the most recent backup already stored — avoids
// near-duplicate snapshots when a save/autosave fires right after
// something else already backed up the same still-on-disk content.
const latest = mine[mine.length - 1]
if (latest) {
const latestContent = await fs.readFile(path.join(dir, latest), 'utf8').catch(() => null)
if (latestContent === content) return { ok: true, skipped: true }
}

const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const backupPath = path.join(dir, `${basename}_${timestamp}${ext}`)
await fs.writeFile(backupPath, content, 'utf8')

// Prune: keep only the 10 most recent backups for this file
const all = await fs.readdir(dir)
const mine = all
.filter(f => f.startsWith(basename + '_') && f.endsWith(ext))
.sort() // ISO timestamps sort correctly as strings
for (const old of mine.slice(0, Math.max(0, mine.length - 10))) {
const updated = [...mine, path.basename(backupPath)]
for (const old of updated.slice(0, Math.max(0, updated.length - 10))) {
await fs.unlink(path.join(dir, old)).catch(() => {})
}

Expand All @@ -907,6 +949,97 @@ function registerIpcHandlers() {
}
})

// backup:list — enumerate stored snapshots for a file, newest first.
ipcMain.handle('backup:list', async (_e, { filePath }) => {
try {
const dir = _backupDir(filePath)
const ext = path.extname(filePath) || '.md'
const basename = path.basename(filePath, ext)

const all = await fs.readdir(dir).catch(() => [])
const mine = all.filter(f => f.startsWith(basename + '_') && f.endsWith(ext))

const parsedEntries = mine
.map(f => {
const timestamp = _parseBackupFilename(f, basename, ext)
return timestamp ? { backupPath: path.join(dir, f), timestamp } : null
})
.filter(Boolean)

// Skip empty (or whitespace-only) snapshots — legacy backups written
// before backup:write started refusing empty content, or files that
// were emptied out. Nobody ever wants to restore a blank file.
const checked = await Promise.all(parsedEntries.map(async entry => {
const text = await fs.readFile(entry.backupPath, 'utf8').catch(() => '')
return text.trim() ? entry : null
}))

const items = checked
.filter(Boolean)
.sort((a, b) => b.timestamp.localeCompare(a.timestamp))

return { ok: true, items }
} catch (err) {
return { ok: false, error: err.message, items: [] }
}
})

// backup:read — read a single snapshot's content. Restricted to the backups
// directory so the renderer can't use this to read arbitrary files on disk.
ipcMain.handle('backup:read', async (_e, { backupPath }) => {
try {
const backupsRoot = path.join(app.getPath('userData'), 'backups')
const resolved = path.resolve(backupPath)
if (!resolved.startsWith(path.resolve(backupsRoot) + path.sep)) {
return { ok: false, error: 'Invalid backup path' }
}
const content = await fs.readFile(resolved, 'utf8')
return { ok: true, content }
} catch (err) {
return { ok: false, error: err.message }
}
})

/* ── Untitled-buffer draft ──────────────────────────────────────────
A single file (not the rolling per-file snapshot scheme above, since
there's no real filePath to key off of and only ever one live untitled
buffer). Lives alongside the backups so it doesn't clutter the user's
workspace. Read once at launch, rewritten on every silentSave() of the
untitled buffer, deleted once it's saved-as or explicitly discarded.
──────────────────────────────────────────────────────────────── */

const DRAFT_PATH = path.join(app.getPath('userData'), 'backups', 'untitled-draft.md')

ipcMain.handle('draft:read', async () => {
try {
const content = await fs.readFile(DRAFT_PATH, 'utf8')
return { ok: true, exists: true, content }
} catch (err) {
if (err.code === 'ENOENT') return { ok: true, exists: false, content: '' }
return { ok: false, exists: false, content: '', error: err.message }
}
})

ipcMain.handle('draft:write', async (_e, { content }) => {
try {
await fs.mkdir(path.dirname(DRAFT_PATH), { recursive: true })
await fs.writeFile(DRAFT_PATH, content ?? '', 'utf8')
return { ok: true }
} catch (err) {
return { ok: false, error: err.message }
}
})

ipcMain.handle('draft:clear', async () => {
try {
await fs.unlink(DRAFT_PATH)
return { ok: true }
} catch (err) {
if (err.code === 'ENOENT') return { ok: true }
return { ok: false, error: err.message }
}
})

/* ── Ollama ──────────────────────────────────────────────────────────
ollama:list-models — fetch the list of models from a running Ollama server.
──────────────────────────────────────────────────────────────── */
Expand Down
14 changes: 13 additions & 1 deletion src/preload/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,23 @@ contextBridge.exposeInMainWorld('electronAPI', {
showInExplorer: (filePath) =>
ipcRenderer.invoke('file:show-in-explorer', filePath),

// Write a backup snapshot before an AI edit is applied.
// Write a backup snapshot before a file is overwritten (AI edit, save, or autosave).
// Saved to <userData>/backups/<folderName>-<hash>/ (last 10 per file)
writeBackup: (filePath, content) =>
ipcRenderer.invoke('backup:write', { filePath, content }),

// List/read stored backup snapshots for a file (Version History modal).
listBackups: (filePath) => ipcRenderer.invoke('backup:list', { filePath }),
readBackup: (backupPath) => ipcRenderer.invoke('backup:read', { backupPath }),

// ── Untitled-buffer draft ──────────────────────────────────────────
// Single file at <userData>/backups/untitled-draft.md — not localStorage.
draft: {
read: () => ipcRenderer.invoke('draft:read'),
write: (content) => ipcRenderer.invoke('draft:write', { content }),
clear: () => ipcRenderer.invoke('draft:clear'),
},

// ── Pinned Files ────────────────────────────────────────────────────
// Stored in <userData>/pinned-files.json — no localStorage, no size limit.
pins: {
Expand Down
Loading
Loading