diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bc3a71..b230ef5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `/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 diff --git a/README.md b/README.md index 92eb38e..83e9958 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 @@ -22,7 +22,8 @@ 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 @@ -30,17 +31,23 @@ Pre-built installers are available on the [Releases page](https://github.com/moi - **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 @@ -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. @@ -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 | @@ -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. -``` -/backups/-/ -``` - -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 `_.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. diff --git a/assets/01_moilstack-md_Split.png b/assets/01_moilstack-md_Split.png new file mode 100644 index 0000000..11720f8 Binary files /dev/null and b/assets/01_moilstack-md_Split.png differ diff --git a/assets/02_moilstack-md_Tree.png b/assets/02_moilstack-md_Tree.png new file mode 100644 index 0000000..ca8a0b5 Binary files /dev/null and b/assets/02_moilstack-md_Tree.png differ diff --git a/assets/03_moilstack-md_AI.png b/assets/03_moilstack-md_AI.png new file mode 100644 index 0000000..b432881 Binary files /dev/null and b/assets/03_moilstack-md_AI.png differ diff --git a/assets/04_moilstack-md_Search.png b/assets/04_moilstack-md_Search.png new file mode 100644 index 0000000..7d38650 Binary files /dev/null and b/assets/04_moilstack-md_Search.png differ diff --git a/assets/05_moilstack-md_Edit.png b/assets/05_moilstack-md_Edit.png new file mode 100644 index 0000000..3d88941 Binary files /dev/null and b/assets/05_moilstack-md_Edit.png differ diff --git a/package.json b/package.json index 226a4fb..52d4340 100644 --- a/package.json +++ b/package.json @@ -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": { @@ -39,7 +39,7 @@ "zip" ], "icon": "src/assets/icon.ico", - "artifactName": "moilstack-md.${ext}" + "artifactName": "moilstack-md-${version}.${ext}" }, "nsis": { "oneClick": false, diff --git a/src/main/ipc.js b/src/main/ipc.js index 2e1aced..ae3de16 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -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. @@ -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 @@ -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(',')) { @@ -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 /backups/-/ 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 "_" 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 "_" 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(() => {}) } @@ -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. ──────────────────────────────────────────────────────────────── */ diff --git a/src/preload/index.js b/src/preload/index.js index bb553a6..85d6403 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -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 /backups/-/ (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 /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 /pinned-files.json — no localStorage, no size limit. pins: { diff --git a/src/renderer/contextMenus.js b/src/renderer/contextMenus.js index 37fe2ac..616e33a 100644 --- a/src/renderer/contextMenus.js +++ b/src/renderer/contextMenus.js @@ -87,6 +87,8 @@ const ContextMenus = (() => { ContextMenus.showDeleteConfirm(filePath); } else if (action === 'label') { LabelModal.show(filePath); + } else if (action === 'version-history') { + VersionHistoryModal.show(filePath); } }); }); diff --git a/src/renderer/editorCore.js b/src/renderer/editorCore.js index a2a8fcd..c63ac6c 100644 --- a/src/renderer/editorCore.js +++ b/src/renderer/editorCore.js @@ -362,6 +362,77 @@ const EditorCore = (() => { } } + /* ═══════════════════════════════════════════════════════════════════ + Split-mode scroll sync + ═══════════════════════════════════════════════════════════════════ */ + + // Set while a sync-triggered scroll is in flight, so the resulting 'scroll' + // event on the *other* pane doesn't bounce back and fight this one. + let _scrollSyncing = false; + + function _isSplitMode() { + const area = document.getElementById('editorArea'); + return !!area && area.getAttribute('data-view') === 'split'; + } + + /** Last [data-line] element at or before targetLine, within container. */ + function _closestLineEl(container, targetLine) { + const els = container.querySelectorAll('[data-line]'); + let best = null; + for (const el of els) { + if (+el.getAttribute('data-line') <= targetLine) best = el; + else break; + } + return best || els[0] || null; + } + + /** Scroll the preview pane to track the editor's current scroll position. */ + function syncPreviewScrollFromEditor() { + if (_scrollSyncing || !_isSplitMode()) return; + const editor = _deps.getEditor ? _deps.getEditor() : null; + const preview = _deps.getPreviewContent ? _deps.getPreviewContent() : null; + const previewPane = document.getElementById('previewPane'); + if (!editor || !preview || !previewPane) return; + + const totalLines = editor.value.split('\n').length; + const maxScroll = Math.max(1, editor.scrollHeight - editor.clientHeight); + const targetLine = (editor.scrollTop / maxScroll) * (totalLines - 1); + + const el = _closestLineEl(preview, targetLine); + if (!el) return; + + const paneMax = Math.max(1, previewPane.scrollHeight - previewPane.clientHeight); + previewPane.scrollTop = Math.min(paneMax, Math.max(0, el.offsetTop - 8)); + + _scrollSyncing = true; + requestAnimationFrame(() => { _scrollSyncing = false; }); + } + + /** Scroll the editor to track the preview pane's current scroll position. */ + function syncEditorScrollFromPreview() { + if (_scrollSyncing || !_isSplitMode()) return; + const editor = _deps.getEditor ? _deps.getEditor() : null; + const preview = _deps.getPreviewContent ? _deps.getPreviewContent() : null; + const previewPane = document.getElementById('previewPane'); + if (!editor || !preview || !previewPane) return; + + const els = preview.querySelectorAll('[data-line]'); + if (!els.length) return; + let best = els[0]; + for (const el of els) { + if (el.offsetTop <= previewPane.scrollTop + 4) best = el; + else break; + } + + const line = +best.getAttribute('data-line'); + const totalLines = editor.value.split('\n').length; + const maxScroll = Math.max(1, editor.scrollHeight - editor.clientHeight); + editor.scrollTop = Math.min(maxScroll, Math.max(0, (line / Math.max(1, totalLines - 1)) * maxScroll)); + + _scrollSyncing = true; + requestAnimationFrame(() => { _scrollSyncing = false; }); + } + /** Re-render preview and update status bar (debounced). */ function triggerUpdate() { clearTimeout(renderTimer); @@ -714,6 +785,8 @@ const EditorCore = (() => { // Reposition the selection ghost whenever the user scrolls if (typeof ChatPanel !== 'undefined') ChatPanel.positionSelectionGhost(); + + syncPreviewScrollFromEditor(); }); // When the editor loses focus and a line range is selected, paint the ghost @@ -771,8 +844,13 @@ const EditorCore = (() => { }); } - /* ── Preview: delegated task-checkbox listener ────────────────── */ + /* ── Preview: scroll sync + delegated task-checkbox listener ──── */ if (preview) { + const previewPane = document.getElementById('previewPane'); + if (previewPane) { + previewPane.addEventListener('scroll', () => syncEditorScrollFromPreview()); + } + preview.addEventListener('change', (e) => { const cb = e.target; if (!cb.matches('.task-checkbox')) return; diff --git a/src/renderer/fileTreeManager.js b/src/renderer/fileTreeManager.js index 6a71271..76f2b44 100644 --- a/src/renderer/fileTreeManager.js +++ b/src/renderer/fileTreeManager.js @@ -76,11 +76,18 @@ const FileTreeManager = (() => { .replace(/"/g, '"'); } + // Only treat a leading `---`…`---` block as frontmatter to skip if it + // actually contains YAML (`key: value`) — otherwise it's just two + // horizontal rules with ordinary prose between them. + function _looksLikeYaml(fmText) { + return /^[ \t]*[A-Za-z0-9_-]+:([ \t]|$)/m.test(fmText); + } + function _extractFirstLine(content) { let text = 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 @@ -309,11 +316,12 @@ const FileTreeManager = (() => { restoreActiveItem(); } - function touchFile(filePath, firstLine) { + function touchFile(filePath, firstLine, tags) { const file = _collectFiles(_cachedTree ?? []).find(f => f.path === filePath); if (file) { file.modified = Date.now(); if (firstLine !== undefined) file.firstLine = firstLine; + if (tags !== undefined) file.tags = tags; renderFileTree(); restoreActiveItem(); } diff --git a/src/renderer/index.html b/src/renderer/index.html index f965841..9af1960 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -143,6 +143,14 @@
@@ -430,6 +447,16 @@

Recent

Preview +
@@ -777,6 +804,13 @@

AI Assistant

Open in New Window
+ + +
+ + +
+
+ +
+ + +
+

Select a version on the left to preview it.

+ + +
+ +
+ + +