From ed13bf82508c032f1262a3c92e5827254857845e Mon Sep 17 00:00:00 2001 From: moilstack Date: Sun, 19 Jul 2026 13:21:53 +0530 Subject: [PATCH 01/11] Validate frontmatter contains YAML before parsing Add _looksLikeYaml() validation function across main and renderer processes to distinguish between actual YAML frontmatter and ordinary prose between markdown horizontal rules. Only treat `---` delimited blocks as frontmatter if they contain YAML syntax (key: value pairs), preventing false positives in frontmatter and tag extraction. --- CHANGELOG.md | 7 +++++++ src/main/ipc.js | 14 ++++++++++++-- src/renderer/fileTreeManager.js | 9 ++++++++- src/renderer/saveManager.js | 9 ++++++++- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bc3a71..ea3bfb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable new features and critical fixes for MoilStack .md. --- +## [Unreleased] + +### Fixed +- **File preview stripping non-frontmatter text** — the sidebar/Explorer content preview and tag extraction treated any leading `---` … `---` block as YAML frontmatter and discarded it, even when it was just two horizontal rules with ordinary prose between them (no actual `key: value` YAML). The block is now only treated as frontmatter if it actually contains YAML. + +--- + ## [1.1.0] - 2026-07-18 ### Added diff --git a/src/main/ipc.js b/src/main/ipc.js index 2e1aced..78b0e03 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(',')) { diff --git a/src/renderer/fileTreeManager.js b/src/renderer/fileTreeManager.js index 6a71271..89a7824 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 diff --git a/src/renderer/saveManager.js b/src/renderer/saveManager.js index 64e3d9b..3b7c7a7 100644 --- a/src/renderer/saveManager.js +++ b/src/renderer/saveManager.js @@ -101,11 +101,18 @@ const SaveManager = (() => { /* ── Sidebar preview update ───────────────────────────────────────── */ + // 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.replace(/^#+\s*/, '').replace(/[*_`]/g, '').trim(); From 0286761f3f2cc969c60033c2cd917296510e6bdc Mon Sep 17 00:00:00 2001 From: moilstack Date: Mon, 20 Jul 2026 19:56:52 +0530 Subject: [PATCH 02/11] Add Version History UI and universal backups Add a Version History modal and make backups cover every overwrite (AI edits, manual saves, autosaves, restores). Implement IPC handlers (backup:write, backup:list, backup:read) and expose listBackups/readBackup in the preload. Skip empty/duplicate snapshots and keep a rolling 10-per-file store under /backups/-. Integrate the modal into the file context menu, add UI/stylesheet and versionHistoryModal.js, and snapshot current on-save via SaveManager before overwriting. Update README and CHANGELOG to document behaviour and UI. --- CHANGELOG.md | 6 + README.md | 14 +- src/main/ipc.js | 107 ++++++++++++-- src/preload/index.js | 6 +- src/renderer/contextMenus.js | 2 + src/renderer/index.html | 51 +++++++ src/renderer/saveManager.js | 16 ++ src/renderer/styles.css | 142 ++++++++++++++++++ src/renderer/versionHistoryModal.js | 221 ++++++++++++++++++++++++++++ 9 files changed, 548 insertions(+), 17 deletions(-) create mode 100644 src/renderer/versionHistoryModal.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ea3bfb7..1310dbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ All notable new features and critical fixes for MoilStack .md. ## [Unreleased] +### Added +- **Version History panel** — right-click any file → "Version History…" for a two-pane view: every backup snapshot listed by date/time on the left, raw text of the selected one on the right, with a **Restore This Version** action (confirm step, backs up current content first). A pinned **Current** entry shows the file's live content for comparison and is selected by default when the panel opens. Panel width/height scale with the window instead of being fixed. Empty or duplicate-content snapshots are skipped rather than filling up the rolling 10-per-file store. + +### Changed +- **Automatic backups now cover every save, not just AI edits** — a snapshot of the on-disk content is taken before manual saves (`Ctrl+S`) and autosave overwrite it too, so accidental edits/deletions during normal editing are recoverable, not just unwanted AI changes. Same rolling 10-per-file store under `/backups/`. + ### Fixed - **File preview stripping non-frontmatter text** — the sidebar/Explorer content preview and tag extraction treated any leading `---` … `---` block as YAML frontmatter and discarded it, even when it was just two horizontal rules with ordinary prose between them (no actual `key: value` YAML). The block is now only treated as frontmatter if it actually contains YAML. diff --git a/README.md b/README.md index 92eb38e..9198836 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,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. @@ -145,9 +145,9 @@ 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 saves a backup to the app's user data directory, keyed to your file's parent folder — not inside your workspace: ``` /backups/-/ @@ -155,7 +155,13 @@ Every time the AI edits your document, MoilStack .md saves a backup to the app's On Windows this is typically under `%APPDATA%`, on macOS under `~/Library/Application Support`, and on Linux under `~/.config`. -Files are named `_.md` and the last **10 backups per file** are kept automatically. Use these to recover from any unwanted AI changes. +Files are named `_.md` and the last **10 backups per file** are kept automatically. Empty or duplicate-content snapshots are skipped so the 10 slots aren't wasted. Use these to recover from unwanted AI changes or accidental edits/deletions during normal editing. + +No need to dig through that folder by hand — right-click any file in the Explorer and choose **Version History…** to browse it: + +* 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/src/main/ipc.js b/src/main/ipc.js index 78b0e03..566ec6c 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -883,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(() => {}) } @@ -917,6 +949,57 @@ 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 } + } + }) + /* ── 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..6c25964 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -59,11 +59,15 @@ 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 }), + // ── 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/index.html b/src/renderer/index.html index f965841..f1cfd32 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -777,6 +777,13 @@

AI Assistant

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

Select a version on the left to preview it.

+ + +
+ +
+ + + @@ -1039,6 +1049,7 @@

Editor

diff --git a/src/renderer/index.js b/src/renderer/index.js index 61172c4..ed80b27 100644 --- a/src/renderer/index.js +++ b/src/renderer/index.js @@ -18,17 +18,22 @@ var currentMode = 'edit'; const toggleEditBtn = document.getElementById('editBtn'); const togglePreviewBtn = document.getElementById('previewBtn'); +const toggleSplitBtn = document.getElementById('splitBtn'); function setMode(mode) { currentMode = mode; const editorPane = document.getElementById('editorPane'); const previewPane = document.getElementById('previewPane'); + const editorArea = document.getElementById('editorArea'); + + editorArea.setAttribute('data-view', mode); + toggleEditBtn.classList.toggle('active', mode === 'edit'); + togglePreviewBtn.classList.toggle('active', mode === 'preview'); + toggleSplitBtn.classList.toggle('active', mode === 'split'); if (mode === 'edit') { editorPane.classList.remove('hidden'); previewPane.classList.add('hidden'); - toggleEditBtn.classList.add('active'); - togglePreviewBtn.classList.remove('active'); if (mdEditor) { mdEditor.focus(); requestAnimationFrame(() => { @@ -36,17 +41,21 @@ function setMode(mode) { mdEditor.setSelectionRange(0, 0); }); } + } else if (mode === 'split') { + editorPane.classList.remove('hidden'); + previewPane.classList.remove('hidden'); + EditorCore.renderMarkdown(); + if (mdEditor) mdEditor.focus(); } else { editorPane.classList.add('hidden'); previewPane.classList.remove('hidden'); - toggleEditBtn.classList.remove('active'); - togglePreviewBtn.classList.add('active'); EditorCore.renderMarkdown(); } } if (toggleEditBtn) toggleEditBtn.addEventListener('click', () => setMode('edit')); if (togglePreviewBtn) togglePreviewBtn.addEventListener('click', () => setMode('preview')); +if (toggleSplitBtn) toggleSplitBtn.addEventListener('click', () => setMode('split')); /* ── Status bar / file selection ──────────────────────────────────── */ @@ -390,11 +399,12 @@ document.addEventListener('keydown', e => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); SaveManager.saveFile(); } }); -// Ctrl+` — toggle edit / preview +// Ctrl+` — cycle edit → split → preview document.addEventListener('keydown', e => { if (e.key === '`' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); - setMode(currentMode === 'edit' ? 'preview' : 'edit'); + const next = { edit: 'split', split: 'preview', preview: 'edit' }; + setMode(next[currentMode] || 'edit'); } }); diff --git a/src/renderer/markdownRenderer.js b/src/renderer/markdownRenderer.js index e3eb772..5a90015 100644 --- a/src/renderer/markdownRenderer.js +++ b/src/renderer/markdownRenderer.js @@ -114,7 +114,7 @@ const MarkdownRenderer = (() => { /* ── List parser (recursive — supports nested lists) ───────────── */ - function parseListBlock(lines, startIdx, baseIndent) { + function parseListBlock(lines, startIdx, baseIndent, topLine) { const ordered = /^\d+\. /.test(lines[startIdx].trimStart()); const items = []; let hasTaskItems = false; @@ -163,7 +163,10 @@ const MarkdownRenderer = (() => { const tag = ordered ? 'ol' : 'ul'; const attr = (!ordered && hasTaskItems) ? ' class="task-list"' : ''; - return { html: `<${tag}${attr}>${items.join('')}`, nextIdx: i }; + // topLine is only set by the outermost call (see call site) — nested sublists + // don't carry their own data-line, so scroll-sync anchors on the top item only. + const lineAttr = topLine !== undefined ? ` data-line="${topLine}"` : ''; + return { html: `<${tag}${attr}${lineAttr}>${items.join('')}`, nextIdx: i }; } /* ── Block parser ───────────────────────────────────────────────── */ @@ -189,11 +192,18 @@ const MarkdownRenderer = (() => { return nextNewline === -1 ? '' : src.slice(nextNewline + 1); } - function parseMarkdown(src) { + function parseMarkdown(src, startLine = 0) { if (!src || !src.trim()) return ''; + const beforeStrip = src; src = _stripFrontmatter(src); if (!src.trim()) return ''; + if (src !== beforeStrip) { + // Frontmatter removed — shift startLine so data-line still points at the + // matching line in the *original* editor source (frontmatter only ever + // appears at the very top, so this only affects the outermost call). + startLine += beforeStrip.split('\n').length - src.split('\n').length; + } const lines = src.split('\n'); const out = []; @@ -202,6 +212,7 @@ const MarkdownRenderer = (() => { while (i < lines.length) { const line = lines[i]; const trimmedLine = line.trimStart(); + const blockLine = startLine + i; /* ── Fenced code block ──────────────────────────────────────── */ // CommonMark: opening fence may have 0–3 leading spaces. @@ -220,7 +231,7 @@ const MarkdownRenderer = (() => { } i++; // consume closing ``` const langAttr = lang ? ` class="language-${escapeHtml(lang)}"` : ''; - out.push(`
${escapeHtml(codeLines.join('\n'))}
`); + out.push(`
${escapeHtml(codeLines.join('\n'))}
`); continue; } @@ -228,27 +239,28 @@ const MarkdownRenderer = (() => { const hm = trimmedLine.match(/^(#{1,6}) (.+)$/); if (hm) { const lvl = hm[1].length; - out.push(`${parseInlineMath(hm[2].trim())}`); + out.push(`${parseInlineMath(hm[2].trim())}`); i++; continue; } /* ── Horizontal rule ────────────────────────────────────────── */ if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(trimmedLine)) { - out.push('
'); + out.push(`
`); i++; continue; } /* ── Blockquote ─────────────────────────────────────────────── */ if (trimmedLine.startsWith('> ') || trimmedLine === '>') { + const quoteStartLine = blockLine; const quoteLines = []; while (i < lines.length && (lines[i].trimStart().startsWith('> ') || lines[i].trim() === '>')) { quoteLines.push(lines[i].trimStart().slice(2)); i++; } - // Recursively parse inner content for nested Markdown - out.push(`
${parseMarkdown(quoteLines.join('\n'))}
`); + // Recursively parse inner content for nested Markdown, preserving absolute line numbers + out.push(`
${parseMarkdown(quoteLines.join('\n'), quoteStartLine)}
`); continue; } @@ -314,7 +326,7 @@ const MarkdownRenderer = (() => { } out.push( - `${headerHtml}` + + `
${headerHtml}` + `${bodyRows.join('')}
` ); continue; @@ -323,7 +335,7 @@ const MarkdownRenderer = (() => { /* ── Unordered list (+ GFM task list) or Ordered list ─────────── */ if (/^[*\-+] /.test(trimmedLine) || /^\d+\. /.test(trimmedLine)) { const baseIndent = line.length - trimmedLine.length; - const result = parseListBlock(lines, i, baseIndent); + const result = parseListBlock(lines, i, baseIndent, blockLine); out.push(result.html); i = result.nextIdx; continue; @@ -358,7 +370,7 @@ const MarkdownRenderer = (() => { const rawPara = paraLines.join('\n'); const { text: mathPara, slots: mathParaSlots } = protectMath(rawPara); const escapedPara = escapeHtml(mathPara).replace(/\n/g, '
'); - out.push(`

${restoreMath(parseInline(escapedPara), mathParaSlots)}

`); + out.push(`

${restoreMath(parseInline(escapedPara), mathParaSlots)}

`); } else { i++; // safety: no rule consumed this line — advance to prevent infinite loop } diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 4bea1ee..41a9926 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -1966,6 +1966,16 @@ body { border-left: none; } +/* Split mode — both panes visible side by side, divider persists */ +.editor-area[data-view="split"] #editorPane, +.editor-area[data-view="split"] #previewPane { + flex: 1; + min-width: 0; +} +.editor-area[data-view="split"] #editorPane { + border-right: 1px solid var(--border); +} + /* ─── Bottom App Status Bar ──────────────────────────────────────── */ .statusbar { display: flex; From 02bfe70141d1a3b8730672a585b3f3ae45526d9f Mon Sep 17 00:00:00 2001 From: moilstack Date: Mon, 20 Jul 2026 20:45:50 +0530 Subject: [PATCH 04/11] Add 'first-file' launch option and CDP helpers Introduce a new launchBehavior value 'first-file' and implement behavior to open the top-most file in the Explorer on startup (falls back to welcome screen). Adjust startup-mode logic so restored drafts always open in edit mode while other launch outcomes follow the startupMode preference. Add two small Chrome DevTools Protocol helper scripts: _cdp_set_prefs.js to set launch prefs/lastFolder and _cdp_check_state.js to inspect renderer state for tests/dev. Updated src/renderer/index.html and src/renderer/index.js accordingly. --- _cdp_check_state.js | 57 +++++++++++++++++++++++++++++++++++++++++ _cdp_set_prefs.js | 52 +++++++++++++++++++++++++++++++++++++ src/renderer/index.html | 1 + src/renderer/index.js | 21 +++++++++++---- 4 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 _cdp_check_state.js create mode 100644 _cdp_set_prefs.js diff --git a/_cdp_check_state.js b/_cdp_check_state.js new file mode 100644 index 0000000..250c8b1 --- /dev/null +++ b/_cdp_check_state.js @@ -0,0 +1,57 @@ +const http = require('http'); +const WebSocket = require('ws'); +const PORT = 9222; + +function getTargets() { + return new Promise((resolve, reject) => { + http.get(`http://127.0.0.1:${PORT}/json`, res => { + let data = ''; + res.on('data', c => data += c); + res.on('end', () => resolve(JSON.parse(data))); + }).on('error', reject); + }); +} + +function evalInPage(ws, expression) { + return new Promise((resolve, reject) => { + const id = Math.floor(Math.random() * 1e9); + const onMsg = (raw) => { + const msg = JSON.parse(raw); + if (msg.id === id) { + ws.removeListener('message', onMsg); + if (msg.error) reject(new Error(JSON.stringify(msg.error))); + else resolve(msg.result.result); + } + }; + ws.on('message', onMsg); + ws.send(JSON.stringify({ id, method: 'Runtime.evaluate', params: { expression, returnByValue: true, awaitPromise: true } })); + }); +} + +async function main() { + let targets; + for (let i = 0; i < 20; i++) { + try { targets = await getTargets(); break; } catch (e) { await new Promise(r => setTimeout(r, 500)); } + } + const page = targets.find(t => t.type === 'page'); + const ws = new WebSocket(page.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); }); + + // give the app's async init a moment + await new Promise(r => setTimeout(r, 1500)); + + const state = await evalInPage(ws, `(() => ({ + currentFileName: (typeof currentFile !== 'undefined') ? currentFile.name : null, + editorValueStart: document.getElementById('mdEditor')?.value.slice(0, 30), + dataView: document.getElementById('editorArea')?.getAttribute('data-view'), + editorHidden: document.getElementById('editorPane')?.classList.contains('hidden'), + previewHidden: document.getElementById('previewPane')?.classList.contains('hidden'), + activeFileItem: document.querySelector('.file-item.active')?.dataset.path || null, + fileListFirst: document.querySelector('#file-list .file-item')?.dataset.path || null, + welcomeVisible: !document.getElementById('welcome-screen')?.classList.contains('hidden') + }))()`); + console.log('STATE:', state); + ws.close(); +} + +main().then(() => process.exit(0)).catch(err => { console.error('FAILED:', err); process.exit(1); }); diff --git a/_cdp_set_prefs.js b/_cdp_set_prefs.js new file mode 100644 index 0000000..cf26c53 --- /dev/null +++ b/_cdp_set_prefs.js @@ -0,0 +1,52 @@ +const http = require('http'); +const WebSocket = require('ws'); +const PORT = 9222; + +function getTargets() { + return new Promise((resolve, reject) => { + http.get(`http://127.0.0.1:${PORT}/json`, res => { + let data = ''; + res.on('data', c => data += c); + res.on('end', () => resolve(JSON.parse(data))); + }).on('error', reject); + }); +} + +function evalInPage(ws, expression) { + return new Promise((resolve, reject) => { + const id = Math.floor(Math.random() * 1e9); + const onMsg = (raw) => { + const msg = JSON.parse(raw); + if (msg.id === id) { + ws.removeListener('message', onMsg); + if (msg.error) reject(new Error(JSON.stringify(msg.error))); + else resolve(msg.result.result); + } + }; + ws.on('message', onMsg); + ws.send(JSON.stringify({ id, method: 'Runtime.evaluate', params: { expression, returnByValue: true, awaitPromise: true } })); + }); +} + +async function main() { + const targets = await getTargets(); + const page = targets.find(t => t.type === 'page'); + const ws = new WebSocket(page.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); }); + + const folder = process.argv[2]; + const result = await evalInPage(ws, `(() => { + localStorage.setItem('launchBehavior', 'first-file'); + localStorage.setItem('startupMode', 'split'); + localStorage.setItem('lastFolder', ${JSON.stringify(folder)}); + return { + launchBehavior: localStorage.getItem('launchBehavior'), + startupMode: localStorage.getItem('startupMode'), + lastFolder: localStorage.getItem('lastFolder') + }; + })()`); + console.log('PREFS SET:', result); + ws.close(); +} + +main().then(() => process.exit(0)).catch(err => { console.error('FAILED:', err); process.exit(1); }); diff --git a/src/renderer/index.html b/src/renderer/index.html index 11954d2..1216ec5 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -1064,6 +1064,7 @@

Editor

diff --git a/src/renderer/index.js b/src/renderer/index.js index ed80b27..8961669 100644 --- a/src/renderer/index.js +++ b/src/renderer/index.js @@ -561,7 +561,7 @@ document.addEventListener('DOMContentLoaded', async () => { // and the "start blank" preference — there's real work to hand back. const _pendingDraft = _openFileParam ? '' : SaveManager.getDraft(); - if (!_openFileParam && !_pendingDraft && launchBehavior !== 'untitled') { + if (!_openFileParam && !_pendingDraft && launchBehavior !== 'untitled' && launchBehavior !== 'first-file') { WelcomeScreen.showWelcomeScreen(); } @@ -623,6 +623,16 @@ document.addEventListener('DOMContentLoaded', async () => { StatusBar.showToast('Restored unsaved draft from your last session.'); } else if (launchBehavior === 'untitled') { await newUntitledFile(); + } else if (launchBehavior === 'first-file') { + // "Open first file in Explorer" — take the top-most file row as shown in + // the sidebar (pinned files first, then the active folder's tree/list), + // same order the user sees, not a re-derived alphabetical walk. + const firstItem = document.querySelector('#file-list .file-item'); + if (firstItem?.dataset.path) { + await openFileByPath(firstItem.dataset.path); + } else { + WelcomeScreen.showWelcomeScreen(); + } } if (!_openFileParam) ChatPanel.clearChat(); @@ -630,10 +640,11 @@ document.addEventListener('DOMContentLoaded', async () => { ChatPanel.updateTokenEstimate(); ChatPanel.updateFileSizeWarning(); - // A fresh untitled launch or a restored draft always opens in edit mode - // (newUntitledFile already set this) — the Startup Mode preference only - // applies when an existing file was opened. - if (_openFileParam || (!_pendingDraft && launchBehavior !== 'untitled')) { + // A restored draft always opens in edit mode (newUntitledFile already set + // this) — you're mid-thought, picking up where you left off. Every other + // "On Launch" outcome (untitled, first-file, recents/existing file) defers + // to the Startup Mode preference. + if (_openFileParam || !_pendingDraft) { const startupMode = localStorage.getItem('startupMode') || 'preview'; setMode(startupMode); } From 14814bdc5180975f9805df0845e2aa1e9653ddc8 Mon Sep 17 00:00:00 2001 From: moilstack Date: Tue, 21 Jul 2026 14:53:45 +0530 Subject: [PATCH 05/11] Persist untitled draft file; add Focus mode Delete legacy CDP helper scripts and implement a persisted untitled-buffer draft stored at /backups/untitled-draft.md. Add main IPC handlers (draft:read/write/clear), expose a draft API in preload, and switch SaveManager to an in-memory cache with initDraft/read/write/clear. Ensure draft is loaded at startup and startupMode is applied consistently. Add a Focus mode button and renderer logic to hide sidebars, maximize window, and force Split view (with restore). Make setAIVisible optionally non-persistent and add styles for active icon state. Files changed: src/main/ipc.js, src/preload/index.js, src/renderer/*, src/renderer/saveManager.js, src/renderer/sidebarManager.js. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- _cdp_check_state.js | 57 -------------------------------- _cdp_set_prefs.js | 52 ----------------------------- src/main/ipc.js | 40 +++++++++++++++++++++++ src/preload/index.js | 8 +++++ src/renderer/index.html | 9 +++++ src/renderer/index.js | 60 +++++++++++++++++++++++++++++----- src/renderer/recentsPanel.js | 8 ++++- src/renderer/saveManager.js | 33 +++++++++++++------ src/renderer/sidebarManager.js | 6 ++-- src/renderer/styles.css | 10 ++++++ 10 files changed, 152 insertions(+), 131 deletions(-) delete mode 100644 _cdp_check_state.js delete mode 100644 _cdp_set_prefs.js diff --git a/_cdp_check_state.js b/_cdp_check_state.js deleted file mode 100644 index 250c8b1..0000000 --- a/_cdp_check_state.js +++ /dev/null @@ -1,57 +0,0 @@ -const http = require('http'); -const WebSocket = require('ws'); -const PORT = 9222; - -function getTargets() { - return new Promise((resolve, reject) => { - http.get(`http://127.0.0.1:${PORT}/json`, res => { - let data = ''; - res.on('data', c => data += c); - res.on('end', () => resolve(JSON.parse(data))); - }).on('error', reject); - }); -} - -function evalInPage(ws, expression) { - return new Promise((resolve, reject) => { - const id = Math.floor(Math.random() * 1e9); - const onMsg = (raw) => { - const msg = JSON.parse(raw); - if (msg.id === id) { - ws.removeListener('message', onMsg); - if (msg.error) reject(new Error(JSON.stringify(msg.error))); - else resolve(msg.result.result); - } - }; - ws.on('message', onMsg); - ws.send(JSON.stringify({ id, method: 'Runtime.evaluate', params: { expression, returnByValue: true, awaitPromise: true } })); - }); -} - -async function main() { - let targets; - for (let i = 0; i < 20; i++) { - try { targets = await getTargets(); break; } catch (e) { await new Promise(r => setTimeout(r, 500)); } - } - const page = targets.find(t => t.type === 'page'); - const ws = new WebSocket(page.webSocketDebuggerUrl); - await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); }); - - // give the app's async init a moment - await new Promise(r => setTimeout(r, 1500)); - - const state = await evalInPage(ws, `(() => ({ - currentFileName: (typeof currentFile !== 'undefined') ? currentFile.name : null, - editorValueStart: document.getElementById('mdEditor')?.value.slice(0, 30), - dataView: document.getElementById('editorArea')?.getAttribute('data-view'), - editorHidden: document.getElementById('editorPane')?.classList.contains('hidden'), - previewHidden: document.getElementById('previewPane')?.classList.contains('hidden'), - activeFileItem: document.querySelector('.file-item.active')?.dataset.path || null, - fileListFirst: document.querySelector('#file-list .file-item')?.dataset.path || null, - welcomeVisible: !document.getElementById('welcome-screen')?.classList.contains('hidden') - }))()`); - console.log('STATE:', state); - ws.close(); -} - -main().then(() => process.exit(0)).catch(err => { console.error('FAILED:', err); process.exit(1); }); diff --git a/_cdp_set_prefs.js b/_cdp_set_prefs.js deleted file mode 100644 index cf26c53..0000000 --- a/_cdp_set_prefs.js +++ /dev/null @@ -1,52 +0,0 @@ -const http = require('http'); -const WebSocket = require('ws'); -const PORT = 9222; - -function getTargets() { - return new Promise((resolve, reject) => { - http.get(`http://127.0.0.1:${PORT}/json`, res => { - let data = ''; - res.on('data', c => data += c); - res.on('end', () => resolve(JSON.parse(data))); - }).on('error', reject); - }); -} - -function evalInPage(ws, expression) { - return new Promise((resolve, reject) => { - const id = Math.floor(Math.random() * 1e9); - const onMsg = (raw) => { - const msg = JSON.parse(raw); - if (msg.id === id) { - ws.removeListener('message', onMsg); - if (msg.error) reject(new Error(JSON.stringify(msg.error))); - else resolve(msg.result.result); - } - }; - ws.on('message', onMsg); - ws.send(JSON.stringify({ id, method: 'Runtime.evaluate', params: { expression, returnByValue: true, awaitPromise: true } })); - }); -} - -async function main() { - const targets = await getTargets(); - const page = targets.find(t => t.type === 'page'); - const ws = new WebSocket(page.webSocketDebuggerUrl); - await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); }); - - const folder = process.argv[2]; - const result = await evalInPage(ws, `(() => { - localStorage.setItem('launchBehavior', 'first-file'); - localStorage.setItem('startupMode', 'split'); - localStorage.setItem('lastFolder', ${JSON.stringify(folder)}); - return { - launchBehavior: localStorage.getItem('launchBehavior'), - startupMode: localStorage.getItem('startupMode'), - lastFolder: localStorage.getItem('lastFolder') - }; - })()`); - console.log('PREFS SET:', result); - ws.close(); -} - -main().then(() => process.exit(0)).catch(err => { console.error('FAILED:', err); process.exit(1); }); diff --git a/src/main/ipc.js b/src/main/ipc.js index 566ec6c..ae3de16 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -1000,6 +1000,46 @@ function registerIpcHandlers() { } }) + /* ── 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 6c25964..85d6403 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -68,6 +68,14 @@ contextBridge.exposeInMainWorld('electronAPI', { 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/index.html b/src/renderer/index.html index 1216ec5..0bdc901 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -406,6 +406,15 @@

Recent

stroke-linecap="round"/> +
diff --git a/src/renderer/index.js b/src/renderer/index.js index 8961669..bfa9c7f 100644 --- a/src/renderer/index.js +++ b/src/renderer/index.js @@ -57,6 +57,49 @@ if (toggleEditBtn) toggleEditBtn.addEventListener('click', () => setMode(' if (togglePreviewBtn) togglePreviewBtn.addEventListener('click', () => setMode('preview')); if (toggleSplitBtn) toggleSplitBtn.addEventListener('click', () => setMode('split')); +/* ── Focus mode: hide both sidebars, maximize, force Split view ────── */ + +const focusModeBtn = document.getElementById('btn-focus-split'); +// Snapshot of what to restore on exit — null while not in focus mode. +let _focusModeRestore = null; + +async function toggleFocusMode() { + if (!_focusModeRestore) { + const explorerVisible = !document.querySelector('.left-sidebar')?.classList.contains('left-sidebar--hidden'); + const aiVisible = !document.querySelector('.right-sidebar')?.classList.contains('right-sidebar--hidden'); + const wasMaximized = (await window.electronAPI?.window?.isMaximized?.()) ?? false; + + _focusModeRestore = { explorerVisible, aiVisible, prevMode: currentMode, wasMaximized }; + + SidebarManager.setExplorerVisible(false, false); + SidebarManager.setAIVisible(false, false); + setMode('split'); + if (!wasMaximized) window.electronAPI?.window?.toggleMaximize?.(); + + focusModeBtn?.classList.add('icon-btn--active'); + focusModeBtn?.setAttribute('aria-pressed', 'true'); + } else { + const { explorerVisible, aiVisible, prevMode, wasMaximized } = _focusModeRestore; + + SidebarManager.setExplorerVisible(explorerVisible, false); + SidebarManager.setAIVisible(aiVisible, false); + setMode(prevMode); + + // Only undo the maximize *we* triggered. If the user already restored + // the window manually while in focus mode, it's no longer maximized — + // toggling again would re-maximize it, so check current state fresh + // rather than trusting the entry-time snapshot. + const isMaximizedNow = (await window.electronAPI?.window?.isMaximized?.()) ?? false; + if (!wasMaximized && isMaximizedNow) window.electronAPI?.window?.toggleMaximize?.(); + + _focusModeRestore = null; + focusModeBtn?.classList.remove('icon-btn--active'); + focusModeBtn?.setAttribute('aria-pressed', 'false'); + } +} + +if (focusModeBtn) focusModeBtn.addEventListener('click', () => toggleFocusMode()); + /* ── Status bar / file selection ──────────────────────────────────── */ /** @@ -247,7 +290,6 @@ async function restoreDraftFile() { WelcomeScreen.hideWelcomeScreen(); ChatPanel.clearChat(); - setMode('edit'); RecentsPanel.render(); } @@ -557,6 +599,9 @@ document.addEventListener('DOMContentLoaded', async () => { }); const launchBehavior = localStorage.getItem('launchBehavior') || 'untitled'; + // Draft cache lives in memory (see saveManager.js) — load it from disk once + // before anything reads getDraft()/hasDraft(). + await SaveManager.initDraft(); // An unsaved draft from a previous session outranks both the Recents screen // and the "start blank" preference — there's real work to hand back. const _pendingDraft = _openFileParam ? '' : SaveManager.getDraft(); @@ -640,14 +685,11 @@ document.addEventListener('DOMContentLoaded', async () => { ChatPanel.updateTokenEstimate(); ChatPanel.updateFileSizeWarning(); - // A restored draft always opens in edit mode (newUntitledFile already set - // this) — you're mid-thought, picking up where you left off. Every other - // "On Launch" outcome (untitled, first-file, recents/existing file) defers - // to the Startup Mode preference. - if (_openFileParam || !_pendingDraft) { - const startupMode = localStorage.getItem('startupMode') || 'preview'; - setMode(startupMode); - } + // Startup Mode applies to every launch outcome above — restored draft, + // fresh untitled, first-file, or an existing file — so it stays consistent + // regardless of which "On Launch" path was taken. + const startupMode = localStorage.getItem('startupMode') || 'preview'; + setMode(startupMode); // ── IPC: Save-and-close (user clicked "Save" in unsaved-changes dialog) window.electronAPI?.onSaveAndClose?.(async () => { diff --git a/src/renderer/recentsPanel.js b/src/renderer/recentsPanel.js index 8cc2a11..4be9d84 100644 --- a/src/renderer/recentsPanel.js +++ b/src/renderer/recentsPanel.js @@ -266,7 +266,13 @@ const RecentsPanel = (() => { if (SaveManager.hasDraft()) { await window.restoreDraftFile?.(); // real persisted content — restore it } else { - await window.newUntitledFile?.(); // nothing persisted — fresh blank buffer + // Nothing persisted — fresh blank buffer. newUntitledFile() forces edit + // mode for its "create a new file" callers (menu/Ctrl+N); here we're + // just navigating back to the (already blank) untitled tab, so restore + // whatever mode was active rather than resetting it. + const prevMode = currentMode; + await window.newUntitledFile?.(); + setMode(prevMode); } } else if (row.dataset.path) { await window.openRecentFile?.(row.dataset.path); diff --git a/src/renderer/saveManager.js b/src/renderer/saveManager.js index dc8bc9f..2ae376d 100644 --- a/src/renderer/saveManager.js +++ b/src/renderer/saveManager.js @@ -17,27 +17,39 @@ const SaveManager = (() => { const AUTO_SAVE_DELAY = 30_000; let _autoSaveTimer = null; - /* ── Untitled-buffer draft (survives close / crash until saved-as or discarded) ── */ - - const DRAFT_KEY = 'untitledDraft'; - const DRAFT_EXISTS_KEY = 'untitledDraftExists'; + /* ── Untitled-buffer draft (survives close / crash until saved-as or discarded) ── + Persisted to a single file — /backups/untitled-draft.md — via + the main process (see ipc.js draft:read/write/clear), not localStorage. + getDraft()/hasDraft() are called synchronously from many places + (recentsPanel.render() fires on nearly every file action), so the file + is read once into this in-memory cache at startup (initDraft(), awaited + by index.js before anything reads it) and kept in sync on every write/ + clear; the disk round-trip itself is fire-and-forget from the caller's + perspective, same as the existing beforeunload silentSave() pattern. ── */ + + let _draftCache = { content: '', exists: false }; + + async function initDraft() { + const result = await window.electronAPI?.draft?.read?.(); + if (result?.ok) _draftCache = { content: result.content, exists: result.exists }; + } - function getDraft() { return localStorage.getItem(DRAFT_KEY) || ''; } + function getDraft() { return _draftCache.content; } // Distinct from getDraft() being non-empty: an untitled buffer that was // switched away from before anything was typed still needs to be // reachable from Recent Files, so its "slot" is tracked even when the // persisted content is an empty string. - function hasDraft() { return localStorage.getItem(DRAFT_EXISTS_KEY) === '1'; } + function hasDraft() { return _draftCache.exists; } function _setDraft(content) { - localStorage.setItem(DRAFT_KEY, content); - localStorage.setItem(DRAFT_EXISTS_KEY, '1'); + _draftCache = { content, exists: true }; + window.electronAPI?.draft?.write?.(content); } function clearDraft() { - localStorage.removeItem(DRAFT_KEY); - localStorage.removeItem(DRAFT_EXISTS_KEY); + _draftCache = { content: '', exists: false }; + window.electronAPI?.draft?.clear?.(); } /** @@ -397,6 +409,7 @@ const SaveManager = (() => { saveFile, exportFile, extractFirstLine: _extractFirstLine, + initDraft, getDraft, hasDraft, clearDraft, diff --git a/src/renderer/sidebarManager.js b/src/renderer/sidebarManager.js index e54bc61..57f4d96 100644 --- a/src/renderer/sidebarManager.js +++ b/src/renderer/sidebarManager.js @@ -15,13 +15,15 @@ const SidebarManager = (() => { } } - function setAIVisible(visible) { + function setAIVisible(visible, persist = true) { const sidebar = document.querySelector('.right-sidebar'); const btn = document.getElementById('btn-toggle-ai'); if (!sidebar) return; sidebar.classList.toggle('right-sidebar--hidden', !visible); if (btn) btn.classList.toggle('sidebar-toggle-btn--active', visible); - localStorage.setItem('sidebar-ai', visible ? 'visible' : 'hidden'); + if (persist) { + localStorage.setItem('sidebar-ai', visible ? 'visible' : 'hidden'); + } } function initSidebarToggles() { diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 41a9926..c455e8a 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -743,6 +743,16 @@ body { height: 32px; } +/* Toggled-on state (e.g. Focus mode) — mirrors .sidebar-toggle-btn--active */ +.icon-btn--active { + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); +} +.icon-btn--active:hover { + background: color-mix(in srgb, var(--primary) 20%, transparent); + color: var(--primary); +} + /* ── 2. Folder row ───────────────────────────────────────────────── */ .folder-row { display: flex; From 1345e8bbdfd46e2cd84e00b74c3ec016b1427e1e Mon Sep 17 00:00:00 2001 From: moilstack Date: Wed, 22 Jul 2026 10:56:44 +0530 Subject: [PATCH 06/11] Add legacy draft migration from localStorage Migrate draft content from old localStorage-based storage to new file-backed draft storage system. The migration runs once during initialization, only when the new file-backed storage is empty, ensuring no data loss when upgrading. Legacy localStorage keys are removed after migration to prevent re-running. --- src/renderer/saveManager.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/renderer/saveManager.js b/src/renderer/saveManager.js index 2ae376d..1bac67f 100644 --- a/src/renderer/saveManager.js +++ b/src/renderer/saveManager.js @@ -29,9 +29,27 @@ const SaveManager = (() => { let _draftCache = { content: '', exists: false }; + // One-time migration from the pre-file-backed draft storage (localStorage + // keys 'untitledDraft'/'untitledDraftExists'). Only runs when the new + // file has nothing yet, so it can't clobber a draft already migrated or + // written since. Legacy keys are removed either way so this never re-runs. + async function _migrateLegacyLocalStorageDraft() { + const legacyExists = localStorage.getItem('untitledDraftExists') === '1'; + const legacyContent = localStorage.getItem('untitledDraft') || ''; + + if (legacyExists && !_draftCache.exists) { + _draftCache = { content: legacyContent, exists: true }; + await window.electronAPI?.draft?.write?.(legacyContent); + } + + localStorage.removeItem('untitledDraft'); + localStorage.removeItem('untitledDraftExists'); + } + async function initDraft() { const result = await window.electronAPI?.draft?.read?.(); if (result?.ok) _draftCache = { content: result.content, exists: result.exists }; + await _migrateLegacyLocalStorageDraft(); } function getDraft() { return _draftCache.content; } From 08527f691d804c3098db7033a319bc51eaf20bc9 Mon Sep 17 00:00:00 2001 From: moilstack Date: Wed, 22 Jul 2026 12:35:11 +0530 Subject: [PATCH 07/11] Add update button and auto-hide release notice Enhance release notification UX by adding a persistent update button in the header, making the release toast auto-hide after 8 seconds, and repositioning the notification from top-right to bottom-center. Also extract and sync file tags when saving to keep sidebar badges updated without waiting for a folder refresh. --- src/renderer/fileTreeManager.js | 3 ++- src/renderer/index.html | 8 ++++++++ src/renderer/releaseNotice.js | 21 +++++++++++++++++++- src/renderer/saveManager.js | 34 +++++++++++++++++++++++++++++++-- src/renderer/styles.css | 34 +++++++++++++++++++++++++++++---- 5 files changed, 92 insertions(+), 8 deletions(-) diff --git a/src/renderer/fileTreeManager.js b/src/renderer/fileTreeManager.js index 89a7824..76f2b44 100644 --- a/src/renderer/fileTreeManager.js +++ b/src/renderer/fileTreeManager.js @@ -316,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 0bdc901..9af1960 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -143,6 +143,14 @@